exploration.oldJournal

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

Note: This file is incomplete, and tests have been disabled! A working version will be released in a future iteration of the library.


A journal fundamentally consists of a number of records detailing rooms entered, which entrance was used, which exit was eventually taken, and what decisions were made in between. Other information like enemies fought, items acquired, or general comments may also be present. These records are just part of a string, where blank lines separate records, and special symbols denote different kinds of entries within a record.

updateExplorationFromEntry converts these text journals into core.Exploration objects for a more formal representation of the graph state at each step of the journal.

To support slightly different journal formats, a Format dictionary is used to define the exact delimiters used for various events/actions/transitions.

   1"""
   2- Authors: Peter Mawhorter
   3- Consulted:
   4- Date: 2022-3-20
   5- Purpose: Parsing for journal-format exploration records.
   6
   7Note: This file is incomplete, and tests have been disabled! A working
   8version will be released in a future iteration of the library.
   9
  10---
  11
  12A journal fundamentally consists of a number of records detailing rooms
  13entered, which entrance was used, which exit was eventually taken, and
  14what decisions were made in between. Other information like enemies
  15fought, items acquired, or general comments may also be present. These
  16records are just part of a string, where blank lines separate records,
  17and special symbols denote different kinds of entries within a record.
  18
  19`updateExplorationFromEntry` converts these text journals into
  20`core.Exploration` objects for a more formal representation of the graph
  21state at each step of the journal.
  22
  23To support slightly different journal formats, a `Format` dictionary is
  24used to define the exact delimiters used for various
  25events/actions/transitions.
  26"""
  27
  28from typing import (
  29    Optional, List, Tuple, Dict, Union, Literal, Set,
  30    get_args, cast, Type
  31)
  32
  33import re
  34import warnings
  35
  36from . import core
  37
  38
  39#----------------------#
  40# Parse format details #
  41#----------------------#
  42
  43JournalEntryType = Literal[
  44    'room',
  45    'entrance',
  46    'exit',
  47    'blocked',
  48    'unexplored',
  49    'unexploredOneway',
  50    'pickup',
  51    'unclaimed',
  52    'randomDrop',
  53    'progress',
  54    'frontier',
  55    'frontierEnd',
  56    'action',
  57    'challenge',
  58    'oops',
  59    'oneway',
  60    'hiddenOneway',
  61    'otherway',
  62    'detour',
  63    'unify',
  64    'obviate',
  65    'warp',
  66    'death',
  67    'runback',
  68    'traverse',
  69    'ending',
  70    'note',
  71    'tag',
  72]
  73"""
  74One of the types of entries that can be present in a journal. Each
  75journal line is either an entry or a continuation of a previous entry.
  76The available types are:
  77
  78- 'room': Used for room names & detour rooms.
  79- 'entrance': Indicates an entrance (must come first in a room).
  80- 'exit': Indicates an exit taken (must be last in a room).
  81- 'blocked': Indicates a blocked route.
  82- 'unexplored': Indicates an unexplored exit.
  83- 'unexploredOneway': Indicates an unexplored exit which is known to be
  84    one-directional outgoing. Use the 'oneway' or 'hiddenOneway'
  85    markers instead for in-room one-way transitions, and use 'otherway'
  86    for one-directional entrances.
  87- 'pickup': Indicates an item pickup.
  88- 'unclaimed': Indicates an unclaimed but visible pickup.
  89- 'randomDrop': Indicates an item picked up via a random drop, which
  90    isn't necessarily tied to the particular room where it occurred.
  91    TODO: This!
  92- 'progress': Indicates progress within a room (engenders a sub-room).
  93    Also used before the room name in a block to indicate rooms passed
  94    through while retracing steps. The content is the name of the
  95    sub-room entered, listing an existing sub-room will create a new
  96    connection to that sub-room from the current sub-room if necessary.
  97    This marker can also be used as a sub-room name to refer to the
  98    default (unnamed) sub-room.
  99- 'frontier': Indicates a new frontier has opened up, which creates a
 100    new unknown node tagged 'frontier' to represent that frontier and
 101    connects it to the current node, as well as creating a new known
 102    node tagged 'frontier' also connected to the current node. While a
 103    frontier is open in a certain room, every new sub-room created will
 104    be connected to both of these nodes. Any requirements or other
 105    transition properties specified when the frontier is defined will be
 106    copied to each of the created transitions. If a frontier has been
 107    closed, it can be re-opened.
 108- 'frontierEnd': Indicates that a specific frontier is no longer open,
 109    which removes the frontier's unknown node and prevents further
 110    sub-rooms from being connected to it. If the frontier is later
 111    re-opened, a new unknown node will be generated and re-connected to
 112    each of the sub-rooms previously connected to that frontier;
 113    transitions to the re-opened unexplored node will copy transition
 114    properties specified when the frontier is reopened since their old
 115    transition properties will be lost, and these will also be used for
 116    new connections to the known frontier node. Old connections to the
 117    known node of the frontier will not be updated.
 118- 'action': Indicates an action taken in a room, which does not create a
 119    sub-room. The effects of the action are not noted in the journal,
 120    but an accompanying ground-truth map would have them, and later
 121    journal entries may imply them.
 122- 'challenge': Indicates a challenge of some sort. A entry tagged with
 123    'failed' immediately afterwards indicates a challenge outcome.
 124- 'oops': Indicates mistaken actions in rooms or transitions.
 125- 'oneway': Indicates a one-way connection inside of a room, which we
 126    assume is visible as such from both sides. Also used at the end of a
 127    block for outgoing connections that are visibly one-way.
 128- 'hiddenOneway': Indicates a one-way connection in a room that's not
 129    visible as such from the entry side. To mark a hidden one-way
 130    between rooms, simply use a normal exit marker and a one-way
 131    entrance marker in the next room.
 132- 'otherway': Indicates incoming one-ways; also used as an entrance
 133    marker for the first entry in a block to denote that the entrance
 134    one just came through cannot be accessed in reverse. Whether this is
 135    expected or a surprise depends on the exit annotation for the
 136    preceding block.
 137- 'detour': Indicates a detour (a quick visit to a one-entrance room
 138    that doesn't get a full entry), or a quick out-and-in for the current
 139    room via a particular exit.
 140- 'unify': Indicates the realization that one's current position is
 141    actually a previously-known sub-room, with the effect of merging
 142    those two sub-rooms.
 143- 'obviate': Indicates when a previously-unexplored between-room transition
 144    gets explored from the other side, without crossing the transition,
 145    or when a link back to a known sub-room is observed without actually
 146    crossing that link.
 147- 'warp': Indicates a warp not due to a death. Again a particular room
 148    is named as the destination. Although the player moves, no
 149    connection is made in the graph, since it's assumed that this is a
 150    one-time move and/or a repeatable power where the start and/or
 151    destination are variable. If there's something like a teleporter
 152    with two fixed endpoints, just use a normal transition. On the other
 153    hand, if there's a multi-entrance/exit teleporter system,
 154    represent this using a room for "inside the system" that has
 155    multiple connections to each of the destinations throughout the
 156    graph.
 157- 'death': Indicates a death taken. The content will specify which room
 158    the player ends up in (usually a save room); depends on the game and
 159    particular mechanics (like save-anywhere).
 160- 'runback': Indicates that a detour to the named room was made, after
 161    which the player returned to the current location in the current room.
 162    The exact path taken is not specified; it is assumed that nothing of
 163    note happens along the way (if something did happen, the journal
 164    should include a room entry and event where it did, so a runback
 165    would not be used). This is used for things like running back to a
 166    save point before continuing exploration where you left off.
 167    TODO: Figure out rules for that path?
 168    TODO: What about e.g., running somewhere to flip a switch? We could
 169    allow a single-line anon-room-style action?
 170- 'traverse': Indicates unspecified traversal through sub-rooms in the
 171    current room to an existing sub-room.
 172    TODO: Pathfinding rules for this?
 173- 'ending': Indicates a game ending state was reached. Progress after
 174    this implies a save was loaded, and the assumption is hat there is no
 175    actual link between the rooms before and afterwards. This should only
 176    appear as the final entry of a journal block (excepting notes/tags).
 177    If exploration continues in the same room, a new block for that room
 178    should be made.
 179- 'note': A comment. May also appear at the end of another entry.
 180- 'tag': a tag or tags which will be added to the room or transition
 181    depending on which line they appear on. Tag a room or sub-room by
 182    putting tag delimiters around space-separated tag words as an entry
 183    in that room or sub-room, and tag transitions by including tag
 184    delimiters around tag words at the end of the line defining the
 185    transition.
 186"""
 187
 188JournalInfoType = Literal[
 189    'subroom',
 190    'anonSep',
 191    'unknownItem',
 192    'tokenQuantity',
 193    'requirement',
 194    'reciprocalSeparator',
 195    'transitionAtDecision'
 196]
 197"""
 198Represents a part of the journal syntax which isn't an entry type but is
 199used to mark something else. For example, the character denoting a
 200sub-room as part of a room name. The available values are:
 201
 202- 'subroom': present in a room name to indicate the rest of the name
 203    identifies a sub-room. Used to mark some connections as 'internal'
 204    even when the journal has separate entries.
 205- 'anonSep': Used to join together a room base name and an exit name to
 206    form the name of an anonymous room.
 207- 'unknownItem': Used in place of an item name to indicate that
 208    although an item is known to exist, it's not yet know what that item
 209    is. Note that when journaling, you should make up names for items
 210    you pick up, even if you don't know what they do yet. This notation
 211    should only be used for items that you haven't picked up because
 212    they're inaccessible, and despite being apparent, you don't know
 213    what they are because they come in a container (e.g., you see a
 214    sealed chest, but you don't know what's in it).
 215- 'tokenQuantity': This is used to separate a token name from a token
 216    quantity when defining items picked up. Note that the parsing for
 217    requirements is not as flexible, and always uses '*' for this, so to
 218    avoid confusion it's preferable to leave this at '*'.
 219- 'requirement': used to indicate what is required when something is
 220    blocked or temporarily one-way, or when traversing a connection that
 221    would be blocked if not for the current game state.
 222- 'reciprocalSeparator': Used to indicate, within a requirement or a
 223    tag set, a separation between requirements/tags to be applied to the
 224    forward direction and requirements/tags to be applied to the reverse
 225    direction. Not always applicable (e.g., actions have no reverse
 226    direction).
 227- 'transitionAtDecision' Used to separate a decision name from a
 228    transition name when identifying a specific transition.
 229"""
 230
 231JournalMarkerType = Union[JournalEntryType, JournalInfoType]
 232"Any journal marker type."
 233
 234DelimiterMarkerType = Literal['room', 'requirement', 'tag']
 235"""
 236The marker types which need delimiter marker values.
 237"""
 238
 239Format = Dict[JournalMarkerType, str]
 240"""
 241A journal format is specified using a dictionary with keys that denote
 242journal marker types and values which are several-character strings
 243indicating the markup used for that entry/info type.
 244"""
 245
 246DEFAULT_FORMAT: Format = {
 247    # Room name markers
 248    'room': '[]',
 249    # Entrances and exits
 250    'entrance': '<',
 251    'exit': '>',
 252    'oneway': '->',
 253    'otherway': 'x<', # unexplored when not in entrance position
 254    # Unexplored options
 255    'blocked': 'x',
 256    'unexplored': '?',
 257    'unexploredOneway': '?>',
 258    # Special progress
 259    'detour': '><',
 260    'unify': '`',
 261    'obviate': '``',
 262    'oops': '@',
 263    # In-room progress (sub-rooms)
 264    'progress': '-',
 265    'frontier': '--',
 266    'frontierEnd': '-/',
 267    'action': '*',
 268    'hiddenOneway': '>>',
 269    # Non-transition events
 270    'challenge': '#',
 271    'pickup': '.',
 272    'unclaimed': ':',
 273    'randomDrop': '$',
 274    # Warps and other special transitions
 275    'warp': '~~',
 276    'death': '!',
 277    'runback': '...',
 278    'traverse': '---',
 279    'ending': '$$',
 280    # Annotations
 281    'note': '"',
 282    'tag': '{}',
 283    # Non-entry-type markers
 284    'subroom': '%',
 285    'anonSep': '$',
 286    'unknownItem': '?',
 287    'tokenQuantity': '*',
 288    'requirement': '()',
 289    'reciprocalSeparator': '/',
 290    'transitionAtDecision': ':',
 291}
 292"""
 293The default `Format` dictionary.
 294"""
 295
 296
 297DELIMITERS = {'()', '[]', '{}'}
 298"""
 299Marker values which are treated as delimiters.
 300"""
 301
 302
 303class ParseFormat:
 304    """
 305    A ParseFormat manages the mapping from markers to entry types and
 306    vice versa.
 307    """
 308    def __init__(self, formatDict: Format = DEFAULT_FORMAT):
 309        """
 310        Sets up the parsing format. Requires a `Format` dictionary to
 311        define the specifics. Raises a `ValueError` unless the keys of
 312        the `Format` dictionary exactly match the `JournalMarkerType`
 313        values.
 314        """
 315        self.formatDict = formatDict
 316
 317        # Check that formatDict doesn't have any extra keys
 318        markerTypes = get_args(JournalEntryType) + get_args(JournalInfoType)
 319        for key in formatDict:
 320            if key not in markerTypes:
 321                raise ValueError(
 322                    f"Format dict has key '{key}' which is not a"
 323                    f" recognized entry or info type."
 324                )
 325
 326        # Check completeness of formatDict
 327        for mtype in markerTypes:
 328            if mtype not in formatDict:
 329                raise ValueError(
 330                    f"Format dict is missing an entry for marker type"
 331                    f" '{mtype}'."
 332                )
 333
 334        # Check that delimiters are assigned appropriately:
 335        needDelimeters = get_args(DelimiterMarkerType)
 336        for needsDelimiter in needDelimeters:
 337            if formatDict[needsDelimiter] not in DELIMITERS:
 338                raise ValueError(
 339                    f"Format dict entry for '{needsDelimiter}' must be"
 340                    f" a delimiter ('[]', '()', or '{{}}')."
 341                )
 342
 343        # Check for misplaced delimiters
 344        for name in formatDict:
 345            if (
 346                name not in needDelimeters
 347            and formatDict[name] in DELIMITERS
 348            ):
 349                raise ValueError(
 350                    f"Format dict entry for '{name}' may not be a"
 351                    f" delimiter ('[]', '()', or '{{}}')."
 352                )
 353
 354        # Build reverse dictionary from markers to entry types (But
 355        # exclude info types from this)
 356        self.entryMap: Dict[str, JournalEntryType] = {}
 357        entryTypes = set(get_args(JournalEntryType))
 358
 359        # Inspect each association
 360        for name, fullMarker in formatDict.items():
 361            if name not in entryTypes:
 362                continue
 363
 364            # Marker is only the first char of a delimiter
 365            if fullMarker in DELIMITERS:
 366                marker = fullMarker[0]
 367            else:
 368                marker = fullMarker
 369
 370            # Duplicates not allowed
 371            if marker in self.entryMap:
 372                raise ValueError(
 373                    f"Format dict entry for '{name}' duplicates"
 374                    f" previous format dict entry for"
 375                    f" '{self.entryMap[marker]}'."
 376                )
 377
 378            # Map markers to entry types
 379            self.entryMap[marker] = cast(JournalEntryType, name)
 380
 381        # These are used to avoid recompiling the RE for
 382        # end-of-anonymous-room markers. See anonymousRoomEnd.
 383        self.roomEnd = None
 384        self.anonEndPattern = None
 385
 386    def markers(self) -> List[str]:
 387        """
 388        Returns the list of all entry-type markers (but not info
 389        markers), sorted from longest to shortest to help avoid
 390        ambiguities when matching. Note that '()', '[]', and '{}'
 391        markers are interpreted as delimiters, and should only be used
 392        for 'room', 'requirement', and/or 'tag' entries.
 393        """
 394        return sorted(
 395            (
 396                m if m not in DELIMITERS else m[0]
 397                for (et, m) in self.formatDict.items()
 398                if et in get_args(JournalEntryType)
 399            ),
 400            key=lambda m: -len(m)
 401        )
 402
 403    def markerFor(self, markerType: JournalMarkerType) -> str:
 404        """
 405        Returns the marker for the specified entry or info type.
 406        """
 407        return self.formatDict[markerType]
 408
 409    def determineEntryType(self, entry: str) -> Tuple[JournalEntryType, str]:
 410        """
 411        Given a single line from a journal, returns a tuple containing
 412        the entry type for that line, and a string containing the entry
 413        content (which is just the line minus the entry-type-marker).
 414        """
 415        bits = entry.strip().split()
 416        if bits[0] in self.entryMap:
 417            eType = self.entryMap[bits[0]]
 418            eContent = entry[len(bits[0]):].lstrip()
 419        else:
 420            first = bits[0]
 421            prefix = None
 422            # Try from longest to shortest to defeat ambiguity
 423            for marker in self.markers():
 424                if first.startswith(marker):
 425                    prefix = marker
 426                    eContent = entry[len(marker):]
 427                    break
 428
 429            if prefix is None:
 430                raise JournalParseError(
 431                    f"Entry does not begin with a recognized entry"
 432                    f" marker:\n{entry}"
 433                )
 434            else:
 435                eType = self.entryMap[prefix]
 436
 437        if eType in get_args(DelimiterMarkerType):
 438            # Punch out the closing delimiter from the middle of the
 439            # content, since things like requirements or tags might be
 440            # after it, and the rest of the code doesn't want to have to
 441            # worry about them (we already removed the starting
 442            # delimiter).
 443            marker = self.formatDict[eType]
 444            matching = eContent.find(marker[-1])
 445            if matching > -1:
 446                eContent = eContent[:matching] + eContent[matching + 1:]
 447            else:
 448                warnings.warn(
 449                    (
 450                        f"Delimiter-style marker '{marker}' is missing"
 451                        f" closing part in entry:\n{entry}"
 452                    ),
 453                    JournalParseWarning
 454                )
 455
 456        return eType, eContent
 457
 458    def parseSpecificTransition(
 459        self,
 460        content: str
 461    ) -> Tuple[core.Decision, core.Transition]:
 462        """
 463        Splits a decision:transition pair to the decision and transition
 464        part, using a custom separator if one is defined.
 465        """
 466        sep = self.formatDict['transitionAtDecision']
 467        n = content.count(sep)
 468        if n == 0:
 469            raise JournalParseError(
 470                f"Cannot split '{content}' into a decision name and a"
 471                f" transition name (no separator '{sep}' found)."
 472            )
 473        elif n > 1:
 474            raise JournalParseError(
 475                f"Cannot split '{content}' into a decision name and a"
 476                f" transition name (too many ({n}) '{sep}' separators"
 477                f" found)."
 478            )
 479        else:
 480            return cast(
 481                Tuple[core.Decision, core.Transition],
 482                tuple(content.split(sep))
 483            )
 484
 485    def splitFinalNote(self, content: str) -> Tuple[str, Optional[str]]:
 486        """
 487        Given a string defining entry content, splits it into true
 488        content and another string containing an annotation attached to
 489        the end of the content. Any text after the 'note' marker on a
 490        line is part of an annotation, rather than part of normal
 491        content. If there is no 'note' marker on the line, then the
 492        second element of the return value will be `None`. Any trailing
 493        whitespace will be stripped from the content (but not the note).
 494
 495        A single space will be stripped from the beginning of the note
 496        if there is one.
 497        """
 498        marker = self.formatDict['note']
 499        if marker in content:
 500            first = content.index(marker)
 501            before = content[:first].rstrip()
 502            after = content[first + 1:]
 503            if after.startswith(' '):
 504                after = after[1:]
 505            return (before, after)
 506        else:
 507            return (content.rstrip(), None)
 508
 509    def splitDelimitedSuffix(
 510        self,
 511        content: str,
 512        delimiters: str,
 513    ) -> Tuple[str, Optional[str]]:
 514        """
 515        Given a string defining entry content, splits it into true
 516        content and another string containing a part surrounded by the
 517        specified delimiters (must be a length-2 string). The line must
 518        end with the ending delimiter (after stripping whitespace) or
 519        else the second part of the return value will be `None`.
 520
 521        If the delimiters argument is not a length-2 string or both
 522        characters are the same, a `ValueError` will be raised. If
 523        mismatched delimiters are encountered, a `JournalParseError` will
 524        be raised.
 525
 526        Whitespace space inside the delimiters will be stripped, as will
 527        whitespace at the end of the content if a delimited part is found.
 528
 529        Examples:
 530
 531        >>> from exploration import journal as j
 532        >>> pf = j.ParseFormat()
 533        >>> pf.splitDelimitedSuffix('abc (def)', '()')
 534        ('abc', 'def')
 535        >>> pf.splitDelimitedSuffix('abc def', '()')
 536        ('abc def', None)
 537        >>> pf.splitDelimitedSuffix('abc [def]', '()')
 538        ('abc [def]', None)
 539        >>> pf.splitDelimitedSuffix('abc [d(e)f]', '()')
 540        ('abc [d(e)f]', None)
 541        >>> pf.splitDelimitedSuffix(' abc d ( ef )', '()')
 542        (' abc d', 'ef')
 543        >>> pf.splitDelimitedSuffix(' abc d ( ef ) ', '[]')
 544        (' abc d ( ef ) ', None)
 545        >>> pf.splitDelimitedSuffix(' abc ((def))', '()')
 546        (' abc', '(def)')
 547        >>> pf.splitDelimitedSuffix(' (abc)', '()')
 548        ('', 'abc')
 549        >>> pf.splitDelimitedSuffix(' a(bc )def)', '()')
 550        Traceback (most recent call last):
 551        ...
 552        exploration.journal.JournalParseError...
 553        >>> pf.splitDelimitedSuffix(' abc def', 'd')
 554        Traceback (most recent call last):
 555        ...
 556        ValueError...
 557        >>> pf.splitDelimitedSuffix(' abc .def.', '..')
 558        Traceback (most recent call last):
 559        ...
 560        ValueError...
 561        """
 562        if len(delimiters) != 2:
 563            raise ValueError(
 564                f"Delimiters must a length-2 string specifying a"
 565                f" starting and ending delimiter (got"
 566                f" {repr(delimiters)})."
 567            )
 568        begin = delimiters[0]
 569        end = delimiters[1]
 570        if begin == end:
 571            raise ValueError(
 572                f"Delimiters must be distinct (got {repr(delimiters)})."
 573            )
 574        if not content.rstrip().endswith(end) or begin not in content:
 575            # No requirement present
 576            return (content, None)
 577        else:
 578            # March back cancelling delimiters until we find the
 579            # matching one
 580            left = 1
 581            findIn = content.rstrip()
 582            for index in range(len(findIn) - 2, -1, -1):
 583                if findIn[index] == end:
 584                    left += 1
 585                elif findIn[index] == begin:
 586                    left -= 1
 587                    if left == 0:
 588                        break
 589
 590            if left > 0:
 591                raise JournalParseError(
 592                    f"Unmatched '{end}' in content:\n{content}"
 593                )
 594
 595            return (content[:index].rstrip(), findIn[index + 1:-1].strip())
 596
 597    def splitDirections(
 598        self,
 599        content: str
 600    ) -> Tuple[Optional[str], Optional[str]]:
 601        """
 602        Splits a piece of text using the 'reciprocalSeparator' into two
 603        pieces. If there is no separator, the second piece will be
 604        `None`; if either side of the separator is blank, that side will
 605        be `None`, and if there is more than one separator, a
 606        `JournalParseError` will be raised. Whitespace will be stripped
 607        from both sides of each result.
 608
 609        Examples:
 610
 611        >>> pf = ParseFormat()
 612        >>> pf.splitDirections('abc / def')
 613        ('abc', 'def')
 614        >>> pf.splitDirections('abc def ')
 615        ('abc def', None)
 616        >>> pf.splitDirections('abc def /')
 617        ('abc def', None)
 618        >>> pf.splitDirections('/abc def')
 619        (None, 'abc def')
 620        >>> pf.splitDirections('a/b/c') # doctest: +IGNORE_EXCEPTION_DETAIL
 621        Traceback (most recent call last):
 622          ...
 623        JournalParseError: ...
 624        """
 625        sep = self.formatDict['reciprocalSeparator']
 626        count = content.count(sep)
 627        if count > 1:
 628            raise JournalParseError(
 629                f"Too many split points ('{sep}') in content:"
 630                f" '{content}' (only one is allowed)."
 631            )
 632
 633        elif count == 1:
 634            before, after = content.split(sep)
 635            before = before.strip()
 636            after = after.strip()
 637            return (before or None, after or None)
 638
 639        else: # no split points
 640            stripped = content.strip()
 641            if stripped:
 642                return stripped, None
 643            else:
 644                return None, None
 645
 646    def splitRequirement(
 647        self,
 648        content: str
 649    ) -> Tuple[str, Optional[core.Requirement], Optional[core.Requirement]]:
 650        """
 651        Splits a requirement suffix from main content, returning a
 652        triple containing the main content and up to two requirements.
 653        The first requirement is the forward-direction requirement, and
 654        the second is the reverse-direction requirement. One or both may
 655        be None to indicate that no requirement in that direction was
 656        included. Raises a `JournalParseError` if something goes wrong
 657        with the parsing.
 658        """
 659        main, req = self.splitDelimitedSuffix(
 660            content,
 661            self.formatDict['requirement']
 662        )
 663        print("SPR", main, req)
 664
 665        # If there wasn't any requirement:
 666        if req is None:
 667            return (main, None, None)
 668
 669        # Split into forward/reverse parts
 670        fwd, rev = self.splitDirections(req)
 671
 672        try:
 673            result = (
 674                main,
 675                core.Requirement.parse(fwd) if fwd is not None else None,
 676                core.Requirement.parse(rev) if rev is not None else None
 677            )
 678        except ValueError as e:
 679            raise JournalParseError(*e.args)
 680
 681        return result
 682
 683    def splitTags(self, content: str) -> Tuple[str, Set[str], Set[str]]:
 684        """
 685        Works like `splitRequirement` but for tags. The tags are split
 686        into words and turned into a set, which will be empty if no tags
 687        are present.
 688        """
 689        base, tags = self.splitDelimitedSuffix(
 690            content,
 691            self.formatDict['tag']
 692        )
 693        if tags is None:
 694            return (base, set(), set())
 695
 696        # Split into forward/reverse parts
 697        fwd, rev = self.splitDirections(tags)
 698
 699        return (
 700            base,
 701            set(fwd.split()) if fwd is not None else set(),
 702            set(rev.split()) if rev is not None else set()
 703        )
 704
 705    def startsAnonymousRoom(self, line: str) -> bool:
 706        """
 707        Returns true if the given line from a journal block starts a
 708        multi-line anonymous room. Use `ParseFormat.anonymousRoomEnd` to
 709        figure out where the end of the anonymous room is.
 710        """
 711        return line.rstrip().endswith(self.formatDict['room'][0])
 712
 713    def anonymousRoomEnd(self, block, startFrom):
 714        """
 715        Given a journal block (a multi-line string) and a starting index
 716        that's somewhere inside a multi-line anonymous room, returns the
 717        index within the entire journal block of the end of the room
 718        (the ending delimiter character). That ending delimiter must
 719        appear alone on a line.
 720
 721        Returns None if no ending marker can be found.
 722        """
 723        # Recompile our regex only if needed
 724        if self.formatDict['room'][-1] != self.roomEnd:
 725            self.roomEnd = self.formatDict['room'][-1]
 726            self.anonEndPattern = re.compile(rf'^\s*{self.roomEnd}\s*$')
 727
 728        # Look for our end marker, alone on a line, with or without
 729        # whitespace on either side:
 730        nextEnd = self.anonEndPattern.search(block, startFrom)
 731        if nextEnd is None:
 732            return None
 733
 734        # Find the actual ending marker ignoring whitespace that might
 735        # have been part of the match
 736        return block.index(self.roomEnd, nextEnd.start())
 737
 738    def splitAnonymousRoom(
 739        self,
 740        content: str
 741    ) -> Tuple[str, Union[str, None]]:
 742        """
 743        Works like `splitRequirement` but for anonymous rooms. If an
 744        anonymous room is present, the second element of the result will
 745        be a one-line string containing room content, which in theory
 746        should be a single event (multiple events would require a
 747        multi-line room, which is handled by
 748        `ParseFormat.startsAnonymousRoom` and
 749        `ParseFormat.anonymousRoomEnd`). If the anonymous room is the
 750        only thing on the line, it won't be counted, since that's a
 751        normal room name.
 752        """
 753        leftovers, anonRoom = self.splitDelimitedSuffix(
 754            content,
 755            self.formatDict['room']
 756        )
 757        if not leftovers.strip():
 758            # Return original content: an anonymous room cannot be the
 759            # only thing on a line (that's a room label).
 760            return (content, None)
 761        else:
 762            return (leftovers, anonRoom)
 763
 764    def subRoomName(
 765        self,
 766        roomName: core.Decision,
 767        subName: core.Decision
 768    ) -> core.Decision:
 769        """
 770        Returns a new room name that includes the provided sub-name to
 771        distinguish it from other parts of the same room. If the subName
 772        matches the progress marker for this parse format, then just the
 773        base name is returned.
 774
 775        Examples:
 776
 777        >>> fmt = ParseFormat()
 778        >>> fmt.subRoomName('a', 'b')
 779        'a%b'
 780        >>> fmt.subRoomName('a%b', 'c')
 781        'a%b%c'
 782        >>> fmt.formatDict['progress'] == '-'
 783        True
 784        >>> fmt.subRoomName('a', '-')
 785        'a'
 786        """
 787        if subName == self.formatDict['progress']:
 788            return roomName
 789        else:
 790            return roomName + self.formatDict['subroom'] + subName
 791
 792    def baseRoomName(self, fullName: core.Decision) -> core.Decision:
 793        """
 794        Returns the base room name for a room name that may contain
 795        one or more sub-room part(s).
 796
 797        >>> fmt = ParseFormat()
 798        >>> fmt.baseRoomName('a%b%c')
 799        'a'
 800        >>> fmt.baseRoomName('a')
 801        'a'
 802        """
 803        marker = self.formatDict['subroom']
 804        if marker in fullName:
 805            return fullName[:fullName.index(marker)]
 806        else:
 807            return fullName
 808
 809    def roomPartName(
 810        self,
 811        fullName: core.Decision
 812    ) -> Optional[core.Decision]:
 813        """
 814        Returns the room part name for a room name that may contain
 815        one or more sub-room part(s). If multiple sub-name parts are
 816        present, they all included together in one string. Returns None
 817        if there is no room part name included in the given full name.
 818
 819        Example:
 820
 821        >>> fmt = ParseFormat()
 822        >>> fmt.roomPartName('a%b')
 823        'b'
 824        >>> fmt.roomPartName('a%b%c')
 825        'b%c'
 826        >>> fmt.roomPartName('a%')
 827        ''
 828        >>> fmt.roomPartName('a')
 829        None
 830        """
 831        marker = self.formatDict['subroom']
 832        if marker in fullName:
 833            return fullName[fullName.index(marker) + 1:]
 834        else:
 835            return None
 836
 837    def roomMinusPart(
 838        self,
 839        fullName: core.Decision,
 840        partName: core.Decision
 841    ) -> core.Decision:
 842        """
 843        Returns the room name, minus the specified sub-room indicator.
 844        Raises a `JournalParseError` if the full room name does not end
 845        in the given sub-room indicator.
 846        Examples:
 847
 848        >>> fmt = ParseFormat()
 849        >>> fmt.roomMinusPart('a%b', 'b')
 850        'a'
 851        >>> fmt.roomMinusPart('a%b%c', 'c')
 852        'a%b'
 853        >>> fmt.roomMinusPart('a%b%c', 'b') # doctest: +IGNORE_EXCEPTION_DETAIL
 854        Traceback (most recent call last):
 855          ...
 856        JournalParseError: ...
 857        """
 858        marker = self.formatDict['subroom']
 859        if not fullName.endswith(marker + partName):
 860            raise JournalParseError(
 861                f"Cannot remove sub-room part '{partName}' from room"
 862                f" '{fullName}' because it does not end with that part."
 863            )
 864
 865        return fullName[:-(len(partName) + 1)]
 866
 867    def allSubRooms(
 868        self,
 869        graph: core.DecisionGraph,
 870        roomName: core.Decision
 871    ) -> Set[core.Decision]:
 872        """
 873        The journal format organizes decisions into groups called
 874        "rooms" within which "sub-rooms" indicate specific parts where a
 875        decision is needed. This function returns a set of
 876        `core.Decision`s naming each decision that's part of the named
 877        room in the given graph. If the name contains a sub-room part,
 878        that part is ignored. The parse format is used to understand how
 879        sub-rooms are named. Note that unknown nodes will NOT be
 880        included, even if the connections to them are tagged with
 881        'internal', which is used to tag within-room transitions.
 882
 883        Note that this function requires checking each room in the entire
 884        graph, since there could be disconnected components of a room.
 885        """
 886        base = self.baseRoomName(roomName)
 887        return {
 888            node
 889            for node in graph.nodes
 890            if self.baseRoomName(node) == base
 891            and not graph.isUnknown(node)
 892        }
 893
 894    def getEntranceDestination(
 895        self,
 896        graph: core.DecisionGraph,
 897        room: core.Decision,
 898        entrance: core.Transition
 899    ) -> Optional[core.Decision]:
 900        """
 901        Given a graph and a room being entered, as well as the entrance
 902        name in that room (i.e., the name of the reciprocal of the
 903        transition being used to enter the room), returns the name of
 904        the specific sub-room being entered, based on the known site for
 905        that entrance, or returns None if that entrance hasn't been used
 906        in any sub-room of the specified room. If the room has a
 907        sub-room part in it, that will be ignored.
 908
 909        Before searching the entire graph, we do check whether the given
 910        transition exists in the target (possibly sub-) room.
 911        """
 912        easy = graph.getDestination(room, entrance)
 913        if easy is not None:
 914            return easy
 915
 916        check = self.allSubRooms(graph, room)
 917        for sub in check:
 918            hope = graph.getDestination(sub, entrance)
 919            if hope is not None:
 920                return hope
 921
 922        return None
 923
 924    def getSubRoom(
 925        self,
 926        graph: core.DecisionGraph,
 927        roomName: core.Decision,
 928        subPart: core.Decision
 929    ) -> Optional[core.Decision]:
 930        """
 931        Given a graph and a room name, plus a sub-room name, returns the
 932        name of the existing sub-room that's part of the same room as
 933        the target room but has the specified sub-room name part.
 934        Returns None if no such room has been defined already.
 935        """
 936        base = self.baseRoomName(roomName)
 937        lookingFor = self.subRoomName(base, subPart)
 938        if lookingFor in graph:
 939            return lookingFor
 940        else:
 941            return None
 942
 943    def parseItem(
 944        self,
 945        item: str
 946    ) -> Union[core.Power, Tuple[core.Token, int]]:
 947        """
 948        Parses an item, which is either a power (just a string) or a
 949        token-type:number pair (returned as a tuple with the number
 950        converted to an integer). The 'tokenQuantity' format value
 951        determines the separator which indicates a token instead of a
 952        power.
 953        """
 954        sep = self.formatDict['tokenQuantity']
 955        if sep in item:
 956            # It's a token w/ an associated count
 957            parts = item.split(sep)
 958            if len(parts) != 2:
 959                raise JournalParseError(
 960                    f"Item '{item}' has a '{sep}' but doesn't separate"
 961                    f" into a token type and a count."
 962                )
 963            typ, count = parts
 964            try:
 965                num = int(count)
 966            except ValueError:
 967                raise JournalParseError(
 968                    f"Item '{item}' has invalid token count '{count}'."
 969                )
 970
 971            return (typ, num)
 972        else:
 973            # It's just a power
 974            return item
 975
 976    def anonName(self, room: core.Decision, exit: core.Transition):
 977        """
 978        Returns the anonymous room name for an anonymous room that's
 979        connected to the specified room via the specified transition.
 980        Example:
 981
 982        >>> pf = ParseFormat()
 983        >>> pf.anonName('MidHall', 'Bottom')
 984        'MidHall$Bottom'
 985        """
 986        return room + self.formatDict['anonSep'] + exit
 987
 988
 989#-------------------#
 990# Errors & Warnings #
 991#-------------------#
 992
 993class JournalParseError(ValueError):
 994    """
 995    Represents a error encountered when parsing a journal.
 996    """
 997    pass
 998
 999
1000class JournalParseWarning(Warning):
1001    """
1002    Represents a warning encountered when parsing a journal.
1003    """
1004    pass
1005
1006
1007class InterRoomEllipsis:
1008    """
1009    Represents part of an inter-room path which has been omitted from a
1010    journal and which should therefore be inferred.
1011    """
1012    pass
1013
1014
1015#-----------------#
1016# Parsing manager #
1017#-----------------#
1018
1019class JournalObserver:
1020    """
1021    Keeps track of extra state needed when parsing a journal in order to
1022    produce a `core.Exploration` object. The methods of this class act
1023    as an API for constructing explorations that have several special
1024    properties (for example, some transitions are tagged 'internal' and
1025    decision names are standardized so that a pattern of "rooms" emerges
1026    above the decision level). The API is designed to allow journal
1027    entries (which represent specific observations/events during an
1028    exploration) to be directly accumulated into an exploration object,
1029    including some ambiguous entries which cannot be directly
1030    interpreted until further entries are observed. The basic usage is
1031    as follows:
1032
1033    1. Create a `JournalObserver`, optionally specifying a custom
1034        `ParseFormat`.
1035    2. Repeatedly either:
1036        * Call `observe*` API methods corresponding to specific entries
1037            observed or...
1038        * Call `JournalObserver.observe` to parse one or more
1039            journal blocks from a string and call the appropriate
1040            methods automatically.
1041    3. Call `JournalObserver.applyState` to clear any remaining
1042        un-finalized state.
1043    4. Call `JournalObserver.getExploration` to retrieve the
1044        `core.Exploration` object that's been created.
1045
1046    Notes:
1047
1048    - `JournalObserver.getExploration` may be called at any time to get
1049        the exploration object constructed so far, and that that object
1050        (unless it's `None`) will always be the same object (which gets
1051        modified as entries are observed). Modifying this object
1052        directly is possible for making changes not available via the
1053        API, but must be done carefully, as there are important
1054        conventions around things like decision names that must be
1055        respected if the API functions need to keep working.
1056    - To get the latest graph, simply use the
1057        `core.Exploration.currentGraph` method of the
1058        `JournalObserver.getExploration` result.
1059    - If you don't call `JournalObserver.applyState` some entries may
1060        not have affected the exploration yet, because they're ambiguous
1061        and further entries need to be observed (or `applyState` needs
1062        to be called) to resolve that ambiguity.
1063
1064    ## Example
1065
1066    >>> obs = JournalObserver()
1067    >>> obs.getExploration() is None
1068    True
1069    >>> # We start by using the observe* methods...
1070    >>> obs.observeRoom("Start") # no effect until entrance is observed
1071    >>> obs.getExploration() is None
1072    True
1073    >>> obs.observeProgress("bottom") # New sub-room within current room
1074    >>> e = obs.getExploration()
1075    >>> len(e) # base state + first movement
1076    2
1077    >>> e.positionAtStep(0)
1078    'Start'
1079    >>> e.positionAtStep(1)
1080    'Start%bottom'
1081    >>> e.transitionAtStep(0)
1082    'bottom'
1083    >>> obs.observeOneway("R") # no effect yet (might be one-way progress)
1084    >>> len(e)
1085    2
1086    >>> obs.observeRoom("Second") # Need to know entrance
1087    >>> len(e) # oneway is now understood to be an inter-room transition
1088    2
1089    >>> obs.observeProgress("bad") # Need to see an entrance first!
1090    Traceback (most recent call last):
1091    ...
1092    exploration.journal.JournalParseError...
1093    >>> obs.observeEntrance("L")
1094    >>> len(e) # Now full transition can be mapped
1095    3
1096    >>> e.positionAtStep(2)
1097    'Second'
1098    >>> e.transitionAtStep(1)
1099    'R'
1100    >>> e.currentGraph().getTransitionRequirement('Second', 'L')
1101    ReqImpossible()
1102    >>> # Now we demonstrate the use of "observe"
1103    >>> obs.observe("x< T (tall)\\n? R\\n> B\\n\\n[Third]\\nx< T")
1104    >>> len(e)
1105    4
1106    >>> m2 = e.graphAtStep(2) # Updates were applied without adding a step
1107    >>> m2.getDestination('Second', 'T')
1108    '_u.1'
1109    >>> m2.getTransitionRequirement('Second', 'T')
1110    ReqPower('tall')
1111    >>> m2.getDestination('Second', 'R')
1112    '_u.2'
1113    >>> m2.getDestination('Second', 'B')
1114    '_u.3'
1115    >>> m = e.currentGraph()
1116    >>> m == e.graphAtStep(3)
1117    >>> m.getDestination('Second', 'B')
1118    'Third'
1119    >>> m.getDestination('Third', 'T')
1120    'Second'
1121    >>> m.getTransitionRequirement('Third', 'T') # Due to 'x<' for entrance
1122    ReqImpossible()
1123    """
1124    parseFormat: ParseFormat = ParseFormat()
1125    """
1126    The parse format used to parse entries supplied as text. This also
1127    ends up controlling some of the decision and transition naming
1128    conventions that are followed, so it is not safe to change it
1129    mid-journal; it should be set once before observation begins, and
1130    may be accessed but should not be changed.
1131    """
1132
1133    exploration: core.Exploration
1134    """
1135    This is the exploration object being built via journal observations.
1136    Note that the exploration object may be empty (i.e., have length 0)
1137    even after the first few entries have been observed because in some
1138    cases entries are ambiguous and are not translated into exploration
1139    steps until a further entry resolves that ambiguity.
1140    """
1141
1142    def __init__(self, parseFormat: Optional[ParseFormat] = None):
1143        """
1144        Sets up the observer. If a parse format is supplied, that will
1145        be used instead of the default parse format, which is just the
1146        result of creating a `ParseFormat` with default arguments.
1147        """
1148        if parseFormat is not None:
1149            self.parseFormat = parseFormat
1150
1151        # Create  blank exploration
1152        self.exploration = core.Exploration()
1153
1154        # State variables
1155
1156        # Tracks the current room name and tags for the room, once a
1157        # room has been declared
1158        self.currentRoomName: Optional[core.Decision] = None
1159        self.currentRoomTags: Set[core.Tag] = set()
1160
1161        # Whether we've seen an entrance/exit yet in the current room
1162        self.seenRoomEntrance = False
1163
1164        # The room & transition used to exit
1165        self.previousRoom: Optional[core.Decision] = None
1166        self.previousTransition: Optional[core.Transition] = None
1167
1168        # The room & transition identified as our next source/transition
1169        self.exitTransition = None
1170
1171        # This tracks the current note text, since notes can continue
1172        # across multiple lines
1173        self.currentNote: Optional[Tuple[
1174            Union[
1175                core.Decision,
1176                Tuple[core.Decision, core.Transition]
1177            ], # target
1178            bool, # was this note indented?
1179            str # note text
1180        ]] = None
1181
1182        # Tracks a pending progress step, since things like a oneway can
1183        # be used for either within-room progress OR room-to-room
1184        # transitions.
1185        self.pendingProgress: Optional[Tuple[
1186            core.Decision, # destination of progress (maybe just sub-part)
1187            Optional[core.Transition], # transition name (None -> auto)
1188            Union[bool, str], # is it one-way; 'hidden' for a hidden one-way?
1189            Optional[core.Requirement], # requirement for the transition
1190            Optional[core.Requirement], # reciprocal requirement
1191            Optional[Set[core.Tag]], # tags to apply
1192            Optional[Set[core.Tag]], # reciprocal tags
1193            Optional[List[core.Annotation]], # annotations to apply
1194            Optional[List[core.Annotation]] # reciprocal annotations
1195        ]] = None
1196
1197        # This tracks the current entries in an inter-room abbreviated
1198        # path, since we first have to accumulate all of them and then
1199        # do pathfinding to figure out a concrete inter-room path.
1200        self.interRoomPath: List[
1201            Union[Type[InterRoomEllipsis], core.Decision]
1202        ] = []
1203
1204        # Tracks presence of an end entry, which must be final in the
1205        # block it occurs in except for notes or tags.
1206        self.blockEnded = False
1207
1208    def observe(self, journalText: str) -> None:
1209        """
1210        Ingests one or more journal blocks in text format (as a
1211        multi-line string) and updates the exploration being built by
1212        this observer, as well as updating internal state. Note that
1213        without later calling `applyState`, some parts of the observed
1214        entries may remain saved as internal state that hasn't yet been
1215        disambiguated and applied to the exploration. jor example, a
1216        final one-way transition could indicate in-room one-way
1217        progress, or a one-way transition to another room, and this is
1218        disambiguated by observing whether the next entry is another
1219        entry in the same block or a blank line to indicate the end of a
1220        block.
1221
1222        This method can be called multiple times to process a longer
1223        journal incrementally including line-by-line. If you give it an
1224        empty string, that will count as the end of a journal block (or
1225        a continuation of space between blocks).
1226
1227        ## Example:
1228
1229        >>> obs = JournalObserver()
1230        >>> obs.observe('''\\
1231        ... [Room1]
1232        ... < Top " Comment
1233        ... x nope (power|tokens*3)
1234        ... ? unexplored
1235        ... -> sub_room " This is a one-way transition
1236        ... -> - " The default sub-room is named '-'
1237        ... > Bottom
1238        ...
1239        ... [Room2]
1240        ... < Top
1241        ... * switch " Took an action in this room
1242        ... ? Left
1243        ... > Right {blue}
1244        ...
1245        ... [Room3]
1246        ... < Left
1247        ... # Miniboss " Faced a challenge
1248        ... . power " Get a power
1249        ... >< Right [
1250        ...    - ledge (tall)
1251        ...    . treasure
1252        ... ] " Detour to an anonymous room
1253        ... > Left
1254        ...
1255        ... - Room2 " Visited along the way
1256        ... [Room1]
1257        ... - nope " Entrance may be omitted if implied
1258        ... > Right
1259        ... ''')
1260        >>> e = obs.getExploration()
1261        >>> len(e)
1262        12
1263        >>> m = e.currentGraph()
1264        >>> len(m)
1265        11
1266        >>> def showDestinations(m, r):
1267        ...     d = m.destinationsFrom(r)
1268        ...     for outgoing in d:
1269        ...         req = m.getTransitionRequirement(r, outgoing)
1270        ...         if req is None:
1271        ...             req = ''
1272        ...         else:
1273        ...             req = ' (' + repr(req) + ')'
1274        ...         print(outgoing, d[outgoing] + req)
1275        ...
1276        >>> showDestinations(m, "Room1")
1277        Top _u.0
1278        nope Room1%nope ReqAny(ReqPower("power"), ReqTokens("tokens", 3))
1279        unexplored _u.1
1280        sub_room Room1%sub_room
1281        sub_room.1 Room1%sub_room ReqImpossible()
1282        Bottom: Room2
1283        >>> showDestinations(m, "Room1%nope")
1284        - Room1 ReqAny(ReqPower("power"), ReqTokens("tokens", 3))
1285        Right _u.3
1286        >>> showDestinations(m, "Room1%sub_room")
1287        - Room1 ReqImpossible()
1288        -.1 Room1
1289        >>> showDestinations(m, "Room2")
1290        Top Room1
1291        action@5 Room2
1292        Left _u.2
1293        Right: Room3
1294        >>> m.transitionTags("Room3", "Right")
1295        {'blue'}
1296        >>> showDestinations(m, "Room3")
1297        Left Room2
1298        action@7 Room3
1299        Right Room3$Right
1300        >>> showDestinations(m, "Room3$Right")
1301        ledge Room3$Right%ledge ReqPower("tall")
1302        return Room3
1303        >>> showDestinations(m, "Room3$Right%ledge")
1304        - Room3$Right
1305        action@9 Room3$Right%ledge
1306        >>> m.decisionAnnotations("Room3")
1307        ['challenge: Miniboss']
1308        >>> e.currentPosition()
1309        'Room1%nope'
1310
1311        Note that there are plenty of other annotations not shown in
1312        this example; see `DEFAULT_FORMAT` for the default mapping from
1313        journal entry types to markers, and see `JournalEntryType` for
1314        the explanation for each entry type.
1315
1316        Most entries start with a marker followed by a single space, and
1317        everything after that is the content of the entry. A few
1318        different modifiers are removed from the right-hand side of
1319        entries first:
1320
1321        - Notes starting with `"` by default and going to the end of the
1322            line, possibly continued on other lines that are indented
1323            and start with the note marker.
1324        - Tags surrounded by `{` and `}` by default and separated from
1325            each other by commas and optional spaces. These are applied
1326            to the current room (if alone on a line) or to the decision
1327            or transition implicated in the line they're at the end of.
1328        - Requirements surrounded by `(` and `)` by default, with `/`
1329            used to separate forward/reverse requirements. These are
1330            applied to the transition implicated by the rest of the
1331            line, and are not allowed on lines that don't imply a
1332            transition. The contents are parsed into a requirement using
1333            `core.Requirement.parse`. Warnings may be issued for
1334            requirements specified on transitions that are taken which
1335            are not met at the time.
1336        - For detours and a few other select entry types, anonymous room
1337            or transition info may be surrounded by `[` and `]` at the
1338            end of the line. For detours, there may be multiple lines
1339            between `[` and `]` as shown in the example above.
1340        """
1341        # Normalize newlines
1342        journalText = journalText\
1343            .replace('\r\n', '\n')\
1344            .replace('\n\r', '\n')\
1345            .replace('\r', '\n')
1346
1347        # Line splitting variables
1348        lineNumber = 0 # first iteration will increment to 1 before use
1349        cursor = 0 # Character index into the block tracking progress
1350        journalLen = len(journalText) # So we know when to stop
1351        lineIncrement = 1 # How many lines we've processed
1352        thisBlock = '' # Lines in this block of the journal
1353
1354        # Shortcut variable
1355        pf = self.parseFormat
1356
1357        # Parse each line separately, but collect multiple lines for
1358        # multi-line entries such as detours
1359        while cursor < journalLen:
1360            lineNumber += lineIncrement
1361            lineIncrement = 1
1362            try:
1363                # Find the next newline
1364                nextNL = journalText.index('\n', cursor)
1365                fullLine = journalText[cursor:nextNL]
1366                cursor = nextNL + 1
1367            except ValueError:
1368                # If there isn't one, rest of the journal is the next line
1369                fullLine = journalText[cursor:]
1370                cursor = journalLen
1371
1372            thisBlock += fullLine + '\n'
1373
1374            # TODO: DEBUG
1375            print("LL", lineNumber, fullLine)
1376
1377            # Check for and split off anonymous room content
1378            line, anonymousContent = pf.splitAnonymousRoom(fullLine)
1379            if (
1380                anonymousContent is None
1381            and pf.startsAnonymousRoom(fullLine)
1382            ):
1383                endIndex = pf.anonymousRoomEnd(journalText, cursor)
1384                if endIndex is None:
1385                    raise JournalParseError(
1386                        f"Anonymous room started on line {lineNumber}"
1387                        f" was never closed in block:\n{thisBlock}\n..."
1388                    )
1389                anonymousContent = journalText[nextNL + 1:endIndex].strip()
1390                thisBlock += anonymousContent + '\n'
1391                # TODO: Is this correct?
1392                lineIncrement = anonymousContent.count('\n') + 1
1393                # Skip to end of line where anonymous room ends
1394                cursor = journalText.index('\n', endIndex + 1)
1395
1396                # Trim the start of the anonymous room from the line end
1397                line = line.rstrip()[:-1]
1398
1399            # Blank lines end one block and start another
1400            if not line.strip():
1401                thisBlock = ''
1402                lineNumber = 0
1403                self.previousRoom = self.exploration.currentPosition()
1404                self.previousTransition = self.exitTransition
1405                self.exitTransition = None
1406                self.currentRoomName = None
1407                self.blockEnded = False
1408                # TODO: More inter-block state here...!
1409                continue
1410
1411            # Check for indentation (mostly ignored, but important for
1412            # comments).
1413            indented = line[0] == ' '
1414
1415            # Strip indentation going forward
1416            line = line.strip()
1417
1418            # Detect entry type and separate content
1419            eType, eContent = pf.determineEntryType(line)
1420
1421            # TODO: DEBUG
1422            print("EE", lineNumber, eType, eContent)
1423
1424            if self.exitTransition is not None and eType != 'note':
1425                raise JournalParseError(
1426                    f"Entry after room exit on line {lineNumber} in"
1427                    f" block:\n{thisBlock}"
1428                )
1429
1430            if (
1431                eType not in ('detour', 'obviate')
1432            and anonymousContent is not None
1433            ):
1434                raise JournalParseError(
1435                    f"Entry on line #{lineNumber} with type {eType}"
1436                    f" does not support anonymous room content. Block"
1437                    f" is:\n{thisBlock}"
1438                )
1439
1440            # Handle note creation
1441            if self.currentNote is not None and eType != 'note':
1442                # This ends a note, so we can apply the pending note and
1443                # reset it.
1444                self.applyCurrentNote()
1445            elif eType == 'note':
1446                self.observeNote(eContent, indented=indented)
1447                # In (only) this case, we've handled the entire line
1448                continue
1449
1450            # Handle a pending progress step if there is one
1451            if self.pendingProgress is not None:
1452                # Any kind of entry except a note (which we would have
1453                # hit above and continued) indicates that a progress
1454                # marker is in-room progress rather than being a room
1455                # exit.
1456                self.makeProgressInRoom(*self.pendingProgress)
1457
1458                # Clean out pendingProgress
1459                self.pendingProgress = None
1460
1461            # Check for valid eType if pre-room
1462            if (
1463                self.currentRoomName is None
1464            and eType not in ('room', 'progress')
1465            ):
1466                raise JournalParseError(
1467                    f"Invalid entry on line #{lineNumber}: Entry type"
1468                    f" '{eType}' not allowed before room name. Block"
1469                    f" is:\n{thisBlock}"
1470                )
1471
1472            # Check for valid eType if post-room
1473            if self.blockEnded and eType not in ('note', 'tag'):
1474                raise JournalParseError(
1475                    f"Invalid entry on line #{lineNumber}: Entry type"
1476                    f" '{eType}' not allowed after an block ends. Block"
1477                    f" is:\n{thisBlock}"
1478                )
1479
1480            # Parse a line-end note if there is one
1481            # Note that note content will be handled after we handle main
1482            # entry stuff
1483            content, note = pf.splitFinalNote(eContent)
1484
1485            # Parse a line-end tags section if there is one
1486            content, fTags, rTags = pf.splitTags(content)
1487
1488            # Parse a line-end requirements section if there is one
1489            content, forwardReq, backReq = pf.splitRequirement(content)
1490
1491            # Strip any remaining whitespace from the edges of our content
1492            content = content.strip()
1493
1494            # Get current graph
1495            now = self.exploration.getCurrentGraph()
1496
1497            # This will trigger on the first line in the room, and handles
1498            # the actual room creation in the graph
1499            handledEntry = False # did we handle the entry in this block?
1500            if (
1501                self.currentRoomName is not None
1502            and not self.seenRoomEntrance
1503            ):
1504                # We're looking for an entrance and if we see anything else
1505                # except a tag, we'll assume that the entrance is implicit,
1506                # and give an error if we don't have an implicit entrance
1507                # set up. If the entrance is explicit, we'll give a warning
1508                # if it doesn't match the previous entrance for the same
1509                # prior-room exit from last time.
1510                if eType in ('entrance', 'otherway'):
1511                    # An explicit entrance; must match previous associated
1512                    # entrance if there was one.
1513                    self.observeRoomEntrance(
1514                        taken, # TODO: transition taken?
1515                        newRoom, # TODO: new room name?
1516                        content,
1517                        eType == 'otherway',
1518                        fReq=forwardReq,
1519                        rReq=backReq,
1520                        fTags=fTags,
1521                        rTags=rTags
1522                    )
1523
1524                elif eType == 'tag':
1525                    roomTags |= set(content.split())
1526                    if fTags or rTags:
1527                        raise JournalParseError(
1528                            f"Found tags on tag entry on line #{lineNumber}"
1529                            f" of block:\n{journalBlock}"
1530                        )
1531                    # don't do anything else here since it's a tag;
1532                    # seenEntrance remains False
1533                    handledEntry = True
1534
1535                else:
1536                    # For any other entry type, it counts as an implicit
1537                    # entrance. We need to follow that transition, or if an
1538                    # appropriate link does not already exist, raise an
1539                    # error.
1540                    seenEntrance = True
1541                    # handledEntry remains False in this case
1542
1543                    # Check that the entry point for this room can be
1544                    # deduced, and deduce it so that we can figure out which
1545                    # sub-room we're actually entering...
1546                    if enterFrom is None:
1547                        if len(exploration) == 0:
1548                            # At the start of the exploration, there's often
1549                            # no specific transition we come from, which is
1550                            # fine.
1551                            exploration.start(roomName, [])
1552                        else:
1553                            # Continuation after an ending
1554                            exploration.warp(roomName, 'restart')
1555                    else:
1556                        fromDecision, fromTransition = enterFrom
1557                        prevReciprocal = None
1558                        if now is not None:
1559                            prevReciprocal = now.getReciprocal(
1560                                fromDecision,
1561                                fromTransition
1562                            )
1563                        if prevReciprocal is None:
1564                            raise JournalParseError(
1565                                f"Implicit transition into room {roomName}"
1566                                f" is invalid because no reciprocal"
1567                                f" transition has been established for exit"
1568                                f" {fromTransition} in previous room"
1569                                f" {fromDecision}."
1570                            )
1571
1572                        # In this case, we retrace the transition, and if
1573                        # that fails because of a ValueError (e.g., because
1574                        # that transition doesn't exist yet or leads to an
1575                        # unknown node) then we'll raise the error as a
1576                        # JournalParseError.
1577                        try:
1578                            exploration.retrace(fromTransition)
1579                        except ValueError as e:
1580                            raise JournalParseError(
1581                                f"Implicit transition into room {roomName}"
1582                                f" is invalid because:\n{e.args[0]}"
1583                            )
1584
1585                        # Note: no tags get applied here, because this is an
1586                        # implicit transition, so there's no room to apply
1587                        # new tags. An explicit transition could be used
1588                        # instead to update transition properties.
1589
1590            # Previous block may have updated the current graph
1591            now = exploration.getCurrentGraph()
1592
1593            # At this point, if we've seen an entrance we're in the right
1594            # room, so we should apply accumulated room tags
1595            if seenEntrance and roomTags:
1596                if now is None:
1597                    raise RuntimeError(
1598                        "Inconsistency: seenEntrance is True but the current"
1599                        " graph is None."
1600                    )
1601
1602                here = exploration.currentPosition()
1603                now.tagDecision(here, roomTags)
1604                roomTags = set() # reset room tags
1605
1606            # Handle all entry types not handled above (like note)
1607            if handledEntry:
1608                # We skip this if/else but still do end-of-loop cleanup
1609                pass
1610
1611            elif eType == 'note':
1612                raise RuntimeError("Saw 'note' eType in lower handling block.")
1613
1614            elif eType == 'room':
1615                if roomName is not None:
1616                    raise ValueError(
1617                        f"Multiple room names detected on line {lineNumber}"
1618                        f" in block:\n{journalBlock}"
1619                    )
1620
1621                # Setting the room name changes the loop state
1622                roomName = content
1623
1624                # These will be applied later
1625                roomTags = fTags
1626
1627                if rTags:
1628                    raise JournalParseError(
1629                        f"Reverse tags cannot be applied to a room"
1630                        f" (found tags {rTags} for room '{roomName}')."
1631                    )
1632
1633            elif eType == 'entrance':
1634                # would be handled above if seenEntrance was false
1635                raise JournalParseError(
1636                    f"Multiple entrances on line {lineNumber} in"
1637                    f" block:\n{journalBlock}"
1638                )
1639
1640            elif eType == 'exit':
1641                # We note the exit transition and will use that as our
1642                # return value. This also will cause an error on the next
1643                # iteration if there are further non-note entries in the
1644                # journal block
1645                exitRoom = exploration.currentPosition()
1646                exitTransition = content
1647
1648                # At this point we add an unexplored edge for this exit,
1649                # assuming it's not one we've seen before. Note that this
1650                # does not create a new exploration step (that will happen
1651                # later).
1652                knownDestination = None
1653                if now is not None:
1654                    knownDestination = now.getDestination(
1655                        exitRoom,
1656                        exitTransition
1657                    )
1658
1659                    if knownDestination is None:
1660                        now.addUnexploredEdge(
1661                            exitRoom,
1662                            exitTransition,
1663                            tags=fTags,
1664                            revTags=rTags,
1665                            requires=forwardReq,
1666                            revRequires=backReq
1667                        )
1668
1669                    else:
1670                        # Otherwise just apply any tags to the transition
1671                        now.tagTransition(exitRoom, exitTransition, fTags)
1672                        existingReciprocal = now.getReciprocal(
1673                            exitRoom,
1674                            exitTransition
1675                        )
1676                        if existingReciprocal is not None:
1677                            now.tagTransition(
1678                                knownDestination,
1679                                existingReciprocal,
1680                                rTags
1681                            )
1682
1683            elif eType in (
1684                'blocked',
1685                'otherway',
1686                'unexplored',
1687                'unexploredOneway',
1688            ):
1689                # Simply add the listed transition to our current room,
1690                # leading to an unknown destination, without creating a new
1691                # exploration step
1692                transition = content
1693                here = exploration.currentPosition()
1694
1695                # If there isn't a listed requirement, infer ReqImpossible
1696                # where appropriate
1697                if forwardReq is None and eType in ('blocked', 'otherway'):
1698                    forwardReq = core.ReqImpossible()
1699                if backReq is None and eType in ('blocked', 'unexploredOneway'):
1700                    backReq = core.ReqImpossible()
1701
1702                # TODO: What if we've annotated a known source for this
1703                # link?
1704
1705                if now is None:
1706                    raise JournalParseError(
1707                        f"On line {lineNumber}: Cannot create an unexplored"
1708                        f" transition before we've created the starting"
1709                        f" graph. Block is:\n{journalBlock}"
1710                    )
1711
1712                now.addUnexploredEdge(
1713                    here,
1714                    transition,
1715                    tags=fTags,
1716                    revTags=rTags,
1717                    requires=forwardReq,
1718                    revRequires=backReq
1719                )
1720
1721            elif eType in ('pickup', 'unclaimed', 'action'):
1722                # We both add an action to the current room, and then take
1723                # that action, or if the type is unclaimed, we don't take
1724                # the action.
1725
1726                if eType == 'unclaimed' and content[0] == '?':
1727                    fTags.add('unknown')
1728
1729                name: Optional[str] = None # auto by default
1730                gains: Optional[str] = None
1731                if eType == 'action':
1732                    name = content
1733                    # TODO: Generalize action effects; also handle toggles,
1734                    # repeatability, etc.
1735                else:
1736                    gains = content
1737
1738                actionName = takeActionInRoom(
1739                    exploration,
1740                    parseFormat,
1741                    name,
1742                    gains,
1743                    forwardReq,
1744                    backReq,
1745                    fTags,
1746                    rTags,
1747                    eType == 'unclaimed' # whether to leave it untaken
1748                )
1749
1750                # Limit scope to this case
1751                del name
1752                del gains
1753
1754            elif eType == 'progress':
1755                # If the room name hasn't been specified yet, this indicates
1756                # a room that we traverse en route. If the room name has
1757                # been specified, this is movement to a new sub-room.
1758                if roomName is None:
1759                    # Here we need to accumulate the named route, since the
1760                    # navigation of sub-rooms has to be figured out by
1761                    # pathfinding, but that's only possible once we know
1762                    # *all* of the listed rooms. Note that the parse
1763                    # format's 'runback' symbol may be used as a room name
1764                    # to indicate that some of the route should be
1765                    # auto-completed.
1766                    if content == parseFormat.formatDict['runback']:
1767                        interRoomPath.append(InterRoomEllipsis)
1768                    else:
1769                        interRoomPath.append(content)
1770                else:
1771                    # This is progress to a new sub-room. If we've been
1772                    # to that sub-room from the current sub-room before, we
1773                    # retrace the connection, and if not, we first add an
1774                    # unexplored connection and then explore it.
1775                    makeProgressInRoom(
1776                        exploration,
1777                        parseFormat,
1778                        content,
1779                        False,
1780                        forwardReq,
1781                        backReq,
1782                        fTags,
1783                        rTags
1784                        # annotations handled separately
1785                    )
1786
1787            elif eType == 'frontier':
1788                pass
1789                # TODO: HERE
1790
1791            elif eType == 'frontierEnd':
1792                pass
1793                # TODO: HERE
1794
1795            elif eType == 'oops':
1796                # This removes the specified transition from the graph,
1797                # creating a new exploration step to do so. It tags that
1798                # transition as an oops in the previous graph, because
1799                # the transition won't exist to be tagged in the new
1800                # graph. If the transition led to a non-frontier unknown
1801                # node, that entire node is removed; otherwise just the
1802                # single transition is removed, along with its
1803                # reciprocal.
1804                if now is None:
1805                    raise JournalParseError(
1806                        f"On line {lineNumber}: Cannot mark an oops before"
1807                        f" we've created the starting graph. Block"
1808                        f" is:\n{journalBlock}"
1809                    )
1810
1811                prev = now # remember the previous graph
1812                # TODO
1813                now = exploration.currentGraph()
1814                here = exploration.currentPosition()
1815                print("OOP", now.destinationsFrom(here))
1816                exploration.wait('oops') # create new step w/ no changes
1817                now = exploration.currentGraph()
1818                here = exploration.currentPosition()
1819                accidental = now.getDestination(here, content)
1820                if accidental is None:
1821                    raise JournalParseError(
1822                        f"Cannot erase transition '{content}' because it"
1823                        f" does not exist at decision {here}."
1824                    )
1825
1826                # If it's an unknown (the usual case) then we remove the
1827                # entire node
1828                if now.isUnknown(accidental):
1829                    now.remove_node(accidental)
1830                else:
1831                    # Otherwise re move the edge and its reciprocal
1832                    reciprocal = now.getReciprocal(here, content)
1833                    now.remove_edge(here, accidental, content)
1834                    if reciprocal is not None:
1835                        now.remove_edge(accidental, here, reciprocal)
1836
1837                # Tag the transition as an oops in the step before it gets
1838                # removed:
1839                prev.tagTransition(here, content, 'oops')
1840
1841            elif eType in ('oneway', 'hiddenOneway'):
1842                # In these cases, we create a pending progress value, since
1843                # it's possible to use 'oneway' as the exit from a room in
1844                # which case it's not in-room progress but rather a room
1845                # transition.
1846                pendingProgress = (
1847                    content,
1848                    True if eType == 'oneway' else 'hidden',
1849                    forwardReq,
1850                    backReq,
1851                    fTags,
1852                    rTags,
1853                    None, # No annotations need be applied now
1854                    None
1855                )
1856
1857            elif eType == 'detour':
1858                if anonymousContent is None:
1859                    raise JournalParseError(
1860                        f"Detour on line #{lineNumber} is missing an"
1861                        f" anonymous room definition. Block"
1862                        f" is:\n{journalBlock}"
1863                    )
1864                # TODO: Support detours to existing rooms w/out anonymous
1865                # content...
1866                if now is None:
1867                    raise JournalParseError(
1868                        f"On line {lineNumber}: Cannot create a detour"
1869                        f" before we've created the starting graph. Block"
1870                        f" is:\n{journalBlock}"
1871                    )
1872
1873                # First, we create an unexplored transition and then use it
1874                # to enter the anonymous room...
1875                here = exploration.currentPosition()
1876                now.addUnexploredEdge(
1877                    here,
1878                    content,
1879                    tags=fTags,
1880                    revTags=rTags,
1881                    requires=forwardReq,
1882                    revRequires=backReq
1883                )
1884
1885                if roomName is None:
1886                    raise JournalParseError(
1887                        f"Detour on line #{lineNumber} occurred before room"
1888                        f" name was known. Block is:\n{journalBlock}"
1889                    )
1890
1891                # Get a new unique anonymous name
1892                anonName = parseFormat.anonName(roomName, content)
1893
1894                # Actually enter our detour room
1895                exploration.explore(
1896                    content,
1897                    anonName,
1898                    [], # No connections yet
1899                    content + '-return'
1900                )
1901
1902                # Tag the new room as anonymous
1903                now = exploration.currentGraph()
1904                now.tagDecision(anonName, 'anonymous')
1905
1906                # Remember transitions needed to get out of room
1907                thread: List[core.Transition] = []
1908
1909                # Parse in-room activity and create steps for it
1910                anonLines = anonymousContent.splitlines()
1911                for anonLine in anonLines:
1912                    anonLine = anonLine.strip()
1913                    try:
1914                        anonType, anonContent = parseFormat.determineEntryType(
1915                            anonLine
1916                        )
1917                    except JournalParseError:
1918                        # One liner that doesn't parse -> treat as tag(s)
1919                        anonType = 'tag'
1920                        anonContent = anonLine.strip()
1921                        if len(anonLines) > 1:
1922                            raise JournalParseError(
1923                                f"Detour on line #{lineNumber} has multiple"
1924                                f" lines but one cannot be parsed as an"
1925                                f" entry:\n{anonLine}\nBlock"
1926                                f" is:\n{journalBlock}"
1927                            )
1928
1929                    # Parse final notes, tags, and/or requirements
1930                    if anonType != 'note':
1931                        anonContent, note = parseFormat.splitFinalNote(
1932                            anonContent
1933                        )
1934                        anonContent, fTags, rTags = parseFormat.splitTags(
1935                            anonContent
1936                        )
1937                        (
1938                            anonContent,
1939                            forwardReq,
1940                            backReq
1941                        ) = parseFormat.splitRequirement(anonContent)
1942
1943                    if anonType == 'note':
1944                        here = exploration.currentPosition()
1945                        now.annotateDecision(here, anonContent)
1946                        # We don't handle multi-line notes in anon rooms
1947
1948                    elif anonType == 'tag':
1949                        tags = set(anonContent.split())
1950                        here = exploration.currentPosition()
1951                        now.tagDecision(here, tags)
1952                        if note is not None:
1953                            now.annotateDecision(here, note)
1954
1955                    elif anonType == 'progress':
1956                        makeProgressInRoom(
1957                            exploration,
1958                            parseFormat,
1959                            anonContent,
1960                            False,
1961                            forwardReq,
1962                            backReq,
1963                            fTags,
1964                            rTags,
1965                            [ note ] if note is not None else None
1966                            # No reverse annotations
1967                        )
1968                        # We don't handle multi-line notes in anon rooms
1969
1970                        # Remember the way back
1971                        # TODO: HERE Is this still accurate?
1972                        thread.append(anonContent + '-return')
1973
1974                    elif anonType in ('pickup', 'unclaimed', 'action'):
1975
1976                        if (
1977                            anonType == 'unclaimed'
1978                        and anonContent.startswith('?')
1979                        ):
1980                            fTags.add('unknown')
1981
1982                        # Note: these are both type Optional[str], but since
1983                        # they exist in another case, they can't be
1984                        # explicitly typed that way here. See:
1985                        # https://github.com/python/mypy/issues/1174
1986                        name = None
1987                        gains = None
1988                        if anonType == 'action':
1989                            name = anonContent
1990                        else:
1991                            gains = anonContent
1992
1993                        actionName = takeActionInRoom(
1994                            exploration,
1995                            parseFormat,
1996                            name,
1997                            gains,
1998                            forwardReq,
1999                            backReq,
2000                            fTags,
2001                            rTags,
2002                            anonType == 'unclaimed' # leave it untaken or not?
2003                        )
2004
2005                        # Limit scope
2006                        del name
2007                        del gains
2008
2009                    elif anonType == 'challenge':
2010                        here = exploration.currentPosition()
2011                        now.annotateDecision(
2012                            here,
2013                            "challenge: " + anonContent
2014                        )
2015
2016                    elif anonType in ('blocked', 'otherway'):
2017                        here = exploration.currentPosition()
2018
2019                        # Mark as blocked even when no explicit requirement
2020                        # has been provided
2021                        if forwardReq is None:
2022                            forwardReq = core.ReqImpossible()
2023                        if backReq is None and anonType == 'blocked':
2024                            backReq = core.ReqImpossible()
2025
2026                        now.addUnexploredEdge(
2027                            here,
2028                            anonContent,
2029                            tags=fTags,
2030                            revTags=rTags,
2031                            requires=forwardReq,
2032                            revRequires=backReq
2033                        )
2034
2035                    else:
2036                        # TODO: Any more entry types we need to support in
2037                        # anonymous rooms?
2038                        raise JournalParseError(
2039                            f"Detour on line #{lineNumber} includes an"
2040                            f" entry of type '{anonType}' which is not"
2041                            f" allowed in an anonymous room. Block"
2042                            f" is:\n{journalBlock}"
2043                        )
2044
2045                # If we made progress, backtrack to the start of the room
2046                for backwards in thread:
2047                    exploration.retrace(backwards)
2048
2049                # Now we exit back to the original room
2050                exploration.retrace(content + '-return')
2051
2052            elif eType == 'unify': # TODO: HERE
2053                pass
2054
2055            elif eType == 'obviate': # TODO: HERE
2056                # This represents a connection to somewhere we've been
2057                # before which is recognized but not traversed.
2058                # Note that when you want to use this to replace a mis-named
2059                # unexplored connection (which you now realize actually goes
2060                # to an existing sub-room, not a new one) you should just
2061                # oops that connection first, and then obviate to the actual
2062                # destination.
2063                if now is None:
2064                    raise JournalParseError(
2065                        f"On line {lineNumber}: Cannot obviate a transition"
2066                        f" before we've created the starting graph. Block"
2067                        f" is:\n{journalBlock}"
2068                    )
2069
2070                here = exploration.currentPosition()
2071
2072                # Two options: if the content lists a room:entrance combo in
2073                # brackets after a transition name, then it represents the
2074                # other side of a door from another room. If, on the other
2075                # hand, it just has a transition name, it represents a
2076                # sub-room name.
2077                content, otherSide = parseFormat.splitAnonymousRoom(content)
2078
2079                if otherSide is None:
2080                    # Must be in-room progress
2081                    # We create (but don't explore) a transition to that
2082                    # sub-room.
2083                    baseRoom = parseFormat.baseRoomName(here)
2084                    currentSubPart = parseFormat.roomPartName(here)
2085                    if currentSubPart is None:
2086                        currentSubPart = parseFormat.formatDict["progress"]
2087                    fromDecision = parseFormat.subRoomName(
2088                        baseRoomName,
2089                        content
2090                    )
2091
2092                    existingReciprocalDestination = now.getDestination(
2093                        fromDecision,
2094                        currentSubPart
2095                    )
2096                    # If the place we're linking to doesn't have a link back
2097                    # to us, then we just create a completely new link.
2098                    if existingReciprocalDestination is None:
2099                        pass
2100                        if now.getDestination(here, content):
2101                            pass
2102                        # TODO: HERE
2103                        # ISSUE: Sub-room links cannot just be named after
2104                        # their destination, because they might not be
2105                        # unique!
2106
2107                    elif now.isUnknown(existingReciprocalDestination):
2108                        pass
2109                        # TODO
2110
2111                    else:
2112                        # TODO
2113                        raise JournalParseError("")
2114
2115                    transitionName = content + '-return'
2116                    # fromDecision, incoming = fromOptions[0]
2117                    # TODO
2118                else:
2119                    # Here the content specifies an outgoing transition name
2120                    # and otherSide specifies the other side, so we don't
2121                    # have to search for anything
2122                    transitionName = content
2123
2124                    # Split decision name and transition name
2125                    fromDecision, incoming = parseFormat.parseSpecificTransition(
2126                        otherSide
2127                    )
2128                    dest = now.getDestination(fromDecision, incoming)
2129
2130                    # Check destination exists and is unknown
2131                    if dest is None:
2132                        # TODO: Look for alternate sub-room?
2133                        raise JournalParseError(
2134                            f"Obviate entry #{lineNumber} for transition"
2135                            f" {content} has invalid reciprocal transition"
2136                            f" {otherSide}. (Did you forget to specify the"
2137                            f" sub-room?)"
2138                        )
2139                    elif not now.isUnknown(dest):
2140                        raise JournalParseError(
2141                            f"Obviate entry #{lineNumber} for transition"
2142                            f" {content} has invalid reciprocal transition"
2143                            f" {otherSide}: that transition's destination"
2144                            f" is already known."
2145                        )
2146
2147                # Now that we know which edge we're obviating, do that
2148                # Note that while the other end is always an existing
2149                # transition to an unexplored destination, our end might be
2150                # novel, so we use replaceUnexplored from the other side
2151                # which allows it to do the work of creating the new
2152                # outgoing transition.
2153                now.replaceUnexplored(
2154                    fromDecision,
2155                    incoming,
2156                    here,
2157                    transitionName,
2158                    requirement=backReq, # flipped
2159                    revRequires=forwardReq,
2160                    tags=rTags, # also flipped
2161                    revTags=fTags,
2162                )
2163
2164            elif eType == 'challenge':
2165                # For now, these are just annotations
2166                if now is None:
2167                    raise JournalParseError(
2168                        f"On line {lineNumber}: Cannot annotate a challenge"
2169                        f" before we've created the starting graph. Block"
2170                        f" is:\n{journalBlock}"
2171                    )
2172
2173                here = exploration.currentPosition()
2174                now.annotateDecision(here, f"{eType}: " + content)
2175
2176            elif eType in ('warp', 'death'):
2177                # These warp the player without creating a connection
2178                if forwardReq or backReq:
2179                    raise JournalParseError(
2180                        f"'{eType}' entry #{lineNumber} cannot include"
2181                        f" requirements. Block is:\n{journalBlock}"
2182                    )
2183                if fTags or rTags:
2184                    raise JournalParseError(
2185                        f"'{eType}' entry #{lineNumber} cannot include"
2186                        f" tags. Block is:\n{journalBlock}"
2187                    )
2188
2189                try:
2190                    exploration.warp(
2191                        content,
2192                        'death' if eType == 'death' else ''
2193                    )
2194                    # TODO: Death effects?!?
2195                    # TODO: We could rewind until we're in a room marked
2196                    # 'save' and pick up that position and even state
2197                    # automatically ?!? But for save-anywhere games, we'd
2198                    # need to have some way of marking a save (could be an
2199                    # entry type that creates a special wait?).
2200                    # There could even be a way to clone the old graph for
2201                    # death, since things like tags applied would presumably
2202                    # not be? Or maybe some would and some wouldn't?
2203                except KeyError:
2204                    raise JournalParseError(
2205                        f"'{eType}' entry #{lineNumber} specifies"
2206                        f" non-existent destination '{content}'. Block"
2207                        f" is:\n{journalBlock}"
2208                    )
2209
2210            elif eType == 'runback':
2211                # For now, we just warp there and back
2212                # TODO: Actually trace the path of the runback...
2213                # TODO: Allow for an action to be taken at the destination
2214                # (like farming health, flipping a switch, etc.)
2215                if forwardReq or backReq:
2216                    raise JournalParseError(
2217                        f"Runback on line #{lineNumber} cannot include"
2218                        f" requirements. Block is:\n{journalBlock}"
2219                    )
2220                if fTags or rTags:
2221                    raise JournalParseError(
2222                        f"Runback on line #{lineNumber} cannot include tags."
2223                        f" Block is:\n{journalBlock}"
2224                    )
2225
2226                # Remember where we are
2227                here = exploration.currentPosition()
2228
2229                # Warp back to the runback point
2230                try:
2231                    exploration.warp(content, 'runaway')
2232                except KeyError:
2233                    raise JournalParseError(
2234                        f"Runback on line #{lineNumber} specifies"
2235                        f" non-existent destination '{content}'. Block"
2236                        f" is:\n{journalBlock}"
2237                    )
2238
2239                # Then warp back to the current decision
2240                exploration.warp(here, 'runback')
2241
2242            elif eType == 'traverse':
2243                # For now, we just warp there
2244                # TODO: Actually trace the path of the runback...
2245                if forwardReq or backReq:
2246                    raise JournalParseError(
2247                        f"Traversal on line #{lineNumber} cannot include"
2248                        f" requirements. Block is:\n{journalBlock}"
2249                    )
2250                if fTags or rTags:
2251                    raise JournalParseError(
2252                        f"Traversal on line #{lineNumber} cannot include tags."
2253                        f" Block is:\n{journalBlock}"
2254                    )
2255
2256                if now is None:
2257                    raise JournalParseError(
2258                        f"Cannot traverse sub-rooms on line #{lineNumber}"
2259                        f" before exploration is started. Block"
2260                        f" is:\n{journalBlock}"
2261                    )
2262
2263                # Warp to the destination
2264                here = exploration.currentPosition()
2265                destination = parseFormat.getSubRoom(now, here, content)
2266                if destination is None:
2267                    raise JournalParseError(
2268                        f"Traversal on line #{lineNumber} specifies"
2269                        f" non-existent sub-room destination '{content}' in"
2270                        f" room '{parseFormat.baseRoomName(here)}'. Block"
2271                        f" is:\n{journalBlock}"
2272                    )
2273                else:
2274                    exploration.warp(destination, 'traversal')
2275
2276            elif eType == 'ending':
2277                if now is None:
2278                    raise JournalParseError(
2279                        f"On line {lineNumber}: Cannot annotate an ending"
2280                        f" before we've created the starting graph. Block"
2281                        f" is:\n{journalBlock}"
2282                    )
2283
2284                if backReq:
2285                    raise JournalParseError(
2286                        f"Ending on line #{lineNumber} cannot include"
2287                        f" reverse requirements. Block is:\n{journalBlock}"
2288                    )
2289
2290                # Create ending
2291                here = exploration.currentPosition()
2292                # Reverse tags are applied to the ending room itself
2293                now.addEnding(
2294                    here,
2295                    content,
2296                    tags=fTags,
2297                    endTags=rTags,
2298                    requires=forwardReq
2299                )
2300                # Transition to the ending
2301                print("ED RT", here, content, len(exploration))
2302                exploration.retrace('_e:' + content)
2303                print("ED RT", len(exploration))
2304                ended = True
2305
2306            elif eType == 'tag':
2307                tagsToApply = set(content.split())
2308                if fTags or rTags:
2309                    raise JournalParseError(
2310                        f"Found tags on tag entry on line #{lineNumber}"
2311                        f" of block:\n{journalBlock}"
2312                    )
2313
2314                if now is None:
2315                    raise JournalParseError(
2316                        f"On line {lineNumber}: Cannot add a tag before"
2317                        f" we've created the starting graph. Block"
2318                        f" is:\n{journalBlock}"
2319                    )
2320
2321                here = exploration.currentPosition()
2322                now.tagDecision(here, tagsToApply)
2323
2324            else:
2325                raise NotImplementedError(
2326                    f"Unhandled entry type '{eType}' (fix"
2327                    f" updateExplorationFromEntry)."
2328                )
2329
2330            # Note: at this point, currentNote must be None. If there is an
2331            # end-of-line note, set up currentNote to apply that to whatever
2332            # is on this line.
2333            if note is not None:
2334                if eType in (
2335                    'entrance',
2336                    'exit',
2337                    'blocked',
2338                    'otherway',
2339                    'unexplored',
2340                    'unexploredOneway',
2341                    'progress'
2342                    'oneway',
2343                    'hiddenOneway',
2344                    'detour'
2345                ):
2346                    # Annotate a specific transition
2347                    target = (exploration.currentPosition(), content)
2348
2349                elif eType in (
2350                    'pickup',
2351                    'unclaimed',
2352                    'action',
2353                ):
2354                    # Action name might be auto-generated
2355                    target = (
2356                        exploration.currentPosition(),
2357                        actionName
2358                    )
2359
2360                else:
2361                    # Default: annotate current room
2362                    target = exploration.currentPosition()
2363
2364                # Set current note value for accumulation
2365                currentNote = (
2366                    target,
2367                    True, # all post-entry notes count as indented
2368                    f"(step #{len(exploration)}) " + note
2369                )
2370
2371        # If we ended, return None
2372        if ended:
2373            return None
2374        elif exitRoom is None or exitTransition is None:
2375            raise JournalParseError(
2376                f"Missing exit room and/or transition ({exitRoom},"
2377                f" {exitTransition}) at end of journal"
2378                f" block:\n{journalBlock}"
2379            )
2380
2381        return exitRoom, exitTransition
2382
2383    def observeNote(
2384        self,
2385        noteText: str,
2386        indented: bool = False,
2387        target: Optional[
2388            Union[core.Decision, Tuple[core.Decision, core.Transition]]
2389        ] = None
2390    ) -> None:
2391        """
2392        Observes a whole-line note in a journal, which may or may not be
2393        indented (level of indentation is ignored). Creates or extends
2394        the current pending note, or applies that note and starts a new
2395        one if the indentation statues or targets are different. Except
2396        in that case, no change is made to the exploration or its
2397        graphs; the annotations are actually applied when
2398        `applyCurrentNote` is called.
2399
2400        ## Example
2401
2402        >>> obs = JournalObserver()
2403        >>> obs.observe('[Room]\\n? Left\\n')
2404        >>> obs.observeNote('hi')
2405        >>> obs.observeNote('the same note')
2406        >>> obs.observeNote('a new note', indented=True) # different indent
2407        >>> obs.observeNote('another note', indented=False)
2408        >>> obs.observeNote('this applies to Left', target=('Room', 'Left'))
2409        >>> obs.observeNote('more') # same target by implication
2410        >>> obs.observeNote('another', target='Room') # different target
2411        >>> e = obs.getExploration()
2412        >>> m = e.currentGraph()
2413        >>> m.decisionAnnotations('Room') # Last note is not here yet...
2414        ['hi\\nthe same note', 'a new note', 'another note']
2415        >>> m.transitionAnnotations('Room', 'Left')
2416        ['this applies to Left\\nmore']
2417        >>> m.applyCurrentNote()
2418        >>> m.decisionAnnotations('Room') # Last note is not here yet...
2419        ['hi\\nthe same note', 'a new note', 'another note', 'another']
2420        """
2421
2422        # whole line is a note; handle new vs. continuing note
2423        if self.currentNote is None:
2424            # Start a new note
2425            if target is None:
2426                target = self.exploration.currentPosition()
2427            self.currentNote = (
2428                target,
2429                indented,
2430                f"(step #{len(self.exploration)}) " + noteText
2431            )
2432        else:
2433            # Previous note exists, use indentation & target to decide
2434            # if we're continuing or starting a new note
2435            oldTarget, wasIndented, prevText = self.currentNote
2436            if (
2437                indented != wasIndented
2438             or (target is not None and target != oldTarget)
2439            ):
2440                # Then we apply the old note and create a new note (at
2441                # the decision level by default)
2442                self.applyCurrentNote()
2443                self.currentNote = (
2444                    target or self.exploration.currentPosition(),
2445                    indented,
2446                    f"(step #{len(self.exploration)}) " + noteText
2447                )
2448            else:
2449                # Else indentation matched and target either matches or
2450                # was None, so add to previous note
2451                self.currentNote = (
2452                    oldTarget,
2453                    wasIndented,
2454                    prevText + '\n' + noteText
2455                )
2456
2457    def applyCurrentNote(self) -> None:
2458        """
2459        If there is a note waiting to be either continued or applied,
2460        applies that note to whatever it is targeting, and clears it.
2461        Does nothing if there is no pending note.
2462
2463        See `observeNote` for an example.
2464        """
2465        if self.currentNote is not None:
2466            target, _, noteText = self.currentNote
2467            self.currentNote = None
2468            # Apply our annotation to the room or transition it targets
2469            # TODO: Annotate the exploration instead?!?
2470            if isinstance(target, str):
2471                self.exploration.currentGraph().annotateDecision(
2472                    target,
2473                    noteText
2474                )
2475            else:
2476                room, transition = target
2477                self.exploration.currentGraph().annotateTransition(
2478                    room,
2479                    transition,
2480                    noteText
2481                )
2482
2483    def makeProgressInRoom(
2484        self,
2485        subRoomName: core.Decision,
2486        transitionName: Optional[core.Transition] = None,
2487        oneway: Union[bool, str] = False,
2488        requires: Optional[core.Requirement] = None,
2489        revRequires: Optional[core.Requirement] = None,
2490        tags: Optional[Set[core.Tag]] = None,
2491        revTags: Optional[Set[core.Tag]] = None,
2492        annotations: Optional[List[core.Annotation]] = None,
2493        revAnnotations: Optional[List[core.Annotation]] = None
2494    ) -> None:
2495        """
2496        Updates the exploration state to indicate that movement to a new
2497        sub-room has occurred. Handles three cases: a
2498        previously-observed but unexplored sub-room, a
2499        never-before-observed sub-room, and a previously-visited
2500        sub-room. By using the parse format's progress marker (default
2501        '-') as the room name, a transition to the base subroom can be
2502        specified.
2503
2504        The destination sub-room name is required, and the exploration
2505        object's current position will dictate which decision the player
2506        is currently at. If no transition name is specified, the
2507        transition name will be the same as the destination name (only
2508        the provided sub-room part) or the same as the first previous
2509        transition to the specified destination from the current
2510        location is such a transition already exists. Optional arguments
2511        may specify requirements, tags, and/or annotations to be applied
2512        to the transition, and requirements, tags, and/or annotations
2513        for the reciprocal transition; these will be applied in the new
2514        graph that results, but not retroactively. If the transition is
2515        a one-way transition, set `oneway` to True (default is False).
2516        `oneway` may also be set to the string 'hidden' to indicate a
2517        hidden one-way. The `newConnection` argument should be set to
2518        True (default False) if a new connection should be created even
2519        in cases where a connection already exists.
2520
2521        ## Example:
2522
2523        >>> obs = JournalObserver()
2524        >>> obs.observe("[Room]\\n< T")
2525        >>> obs.makeProgressInRoom("subroom")
2526        >>> e = obs.getExploration()
2527        >>> len(e)
2528        2
2529        >>> e.currentPosition()
2530        'Room%subroom'
2531        >>> g = e.currentGraph()
2532        >>> g.destinationsFrom("Room")
2533        { 'T': '_u.0', 'subroom': 'Room%subroom' }
2534        >>> g.destinationsFrom("Room%subroom")
2535        { '-': 'Room' }
2536        >>> obs.makeProgressInRoom("-") # Back to base subroom
2537        >>> len(e)
2538        3
2539        >>> e.currentPosition()
2540        'Room'
2541        >>> g = e.currentGraph()
2542        >>> g.destinationsFrom("Room")
2543        { 'T': '_u.0', 'subroom': 'Room%subroom' }
2544        >>> g.destinationsFrom("Room%subroom")
2545        { '-': 'Room' }
2546        >>> obs.makeProgressInRoom(
2547        ...   "other",
2548        ...   oneway='hidden',
2549        ...   tags={"blue"},
2550        ...   requires=core.ReqPower("fly"),
2551        ...   revRequires=core.ReqAll(
2552        ...     core.ReqPower("shatter"),
2553        ...     core.ReqPower("fly")
2554        ...   ),
2555        ...   revTags={"blue"},
2556        ...   annotations=["Another subroom"],
2557        ...   revAnnotations=["This way back"],
2558        ... )
2559        >>> len(e)
2560        4
2561        >>> e.currentPosition()
2562        'Room%other'
2563        >>> g = e.currentGraph()
2564        >>> g.destinationsFrom("Room")
2565        { 'T': '_u.0', 'subroom': 'Room%subroom', 'other': 'Room%other' }
2566        >>> g.destinationsFrom("Room%subroom")
2567        { '-': 'Room' }
2568        >>> g.destinationsFrom("Room%other")
2569        { '-': 'Room' }
2570        >>> g.getTransitionRequirement("Room", "other")
2571        ReqPower('fly')
2572        >>> g.getTransitionRequirement("Room%other", "-")
2573        ReqAll(ReqPower('shatter'), ReqPower('fly'))
2574        >>> g.transitionTags("Room", "other")
2575        {'blue'}
2576        >>> g.transitionTags("Room%other", "-")
2577        {'blue'}
2578        >>> g.transitionAnnotations("Room", "other")
2579        ['Another subroom']
2580        >>> g.transitionAnnotations("Room%other", "-")
2581        ['This way back']
2582        >>> prevM = e.graphAtStep(-2)
2583        >>> prevM.destinationsFrom("Room")
2584        { 'T': '_u.0', 'subroom': 'Room%subroom', 'other': '_u.2' }
2585        >>> prevM.destinationsFrom("Room%subroom")
2586        { '-': 'Room' }
2587        >>> "Room%other" in prevM
2588        False
2589        >>> obs.makeProgressInRoom("-", transitionName="-.1", oneway=True)
2590        >>> len(e)
2591        5
2592        >>> e.currentPosition()
2593        'Room'
2594        >>> g = e.currentGraph()
2595        >>> d = g.destinationsFrom("Room")
2596        >>> g['T']
2597        '_u.0'
2598        >>> g['subroom']
2599        'Room%subroom'
2600        >>> g['other']
2601        'Room%other'
2602        >>> g['other.1']
2603        'Room%other'
2604        >>> g.destinationsFrom("Room%subroom")
2605        { '-': 'Room' }
2606        >>> g.destinationsFrom("Room%other")
2607        { '-': 'Room', '-.1': 'Room' }
2608        >>> g.getTransitionRequirement("Room", "other")
2609        ReqPower('fly')
2610        >>> g.getTransitionRequirement("Room%other", "-")
2611        ReqAll(ReqPower('shatter'), ReqPower('fly'))
2612        >>> g.getTransitionRequirement("Room", "other.1")
2613        ReqImpossible()
2614        >>> g.getTransitionRequirement("Room%other", "-.1")
2615        ReqNothing()
2616        """
2617
2618        # Default argument values
2619        if transitionName is None:
2620            transitionName = subRoomName
2621        if tags is None:
2622            tags = set()
2623        if revTags is None:
2624            revTags = set()
2625        if annotations is None:
2626            annotations = []
2627        if revAnnotations is None:
2628            revAnnotations = []
2629
2630        # Tag the transition with 'internal' since this is in-room progress
2631        tags.add('internal')
2632
2633        # Get current stuff
2634        now = self.exploration.currentGraph()
2635        here = self.exploration.currentPosition()
2636        outgoing = now.destinationsFrom(here)
2637        base = self.parseFormat.baseRoomName(here)
2638        currentSubPart = self.parseFormat.roomPartName(here)
2639        if currentSubPart is None:
2640            currentSubPart = self.parseFormat.formatDict["progress"]
2641        destination = self.parseFormat.subRoomName(base, subRoomName)
2642        isNew = destination not in now
2643
2644        # Handle oneway settings (explicit requirements override them)
2645        if oneway is True and revRequires is None: # not including 'hidden'
2646            revRequires = core.ReqImpossible()
2647
2648        # Did we end up creating a new subroom?
2649        createdSubRoom = False
2650
2651        # A hidden oneway applies both explicit and implied transition
2652        # requirements only after the transition has been taken
2653        if oneway == "hidden":
2654            postRevReq: Optional[core.Requirement] = None
2655            if revRequires is None:
2656                postRevReq = core.ReqImpossible()
2657            else:
2658                postRevReq = revRequires
2659            revRequires = None
2660        else:
2661            postRevReq = revRequires
2662
2663        # Are we going somewhere new, or not?
2664        if transitionName in outgoing: # A transition we've seen before
2665            rev = now.getReciprocal(here, transitionName)
2666            if not now.isUnknown(destination): # Just retrace it
2667                self.exploration.retrace(transitionName)
2668            else: # previously unknown
2669                self.exploration.explore(
2670                    transitionName,
2671                    destination,
2672                    [],
2673                    rev # No need to worry here about collisions
2674                )
2675                createdSubRoom = True
2676
2677        else: # A new connection (not necessarily destination)
2678            # Find a unique name for the returning connection
2679            rev = currentSubPart
2680            if not isNew:
2681                rev = core.uniqueName(
2682                    rev,
2683                    now.destinationsFrom(destination)
2684                )
2685
2686            # Add an unexplored transition and then explore it
2687            if not isNew and now.isUnknown(destination):
2688                # Connecting to an existing unexplored region
2689                now.addTransition(
2690                    here,
2691                    transitionName,
2692                    destination,
2693                    rev,
2694                    tags=tags,
2695                    annotations=annotations,
2696                    requires=requires,
2697                    revTags=revTags,
2698                    revAnnotations=revAnnotations,
2699                    revRequires=revRequires
2700                )
2701            else:
2702                # Connecting to a new decision or one that's not
2703                # unexplored
2704                now.addUnexploredEdge(
2705                    here,
2706                    transitionName,
2707                    # auto unexplored name
2708                    reciprocal=rev,
2709                    tags=tags,
2710                    annotations=annotations,
2711                    requires=requires,
2712                    revTags=revTags,
2713                    revAnnotations=revAnnotations,
2714                    revRequires=revRequires
2715                )
2716
2717
2718            # Explore the unknown we just created
2719            if isNew or now.isUnknown(destination):
2720                # A new destination: create it
2721                self.exploration.explore(
2722                    transitionName,
2723                    destination,
2724                    [],
2725                    rev # No need to worry here about collisions
2726                )
2727                createdSubRoom = True
2728            else:
2729                # An existing destination: return to it
2730                self.exploration.returnTo(
2731                    transitionName,
2732                    destination,
2733                    rev
2734                )
2735
2736        # Overwrite requirements, tags, and annotations
2737        # based on any new info. TODO: Warn if new info is
2738        # mismatched with old info?
2739        newGraph = self.exploration.currentGraph()
2740        newPos = self.exploration.currentPosition()
2741        if requires is not None:
2742            self.exploration.updateRequirementNow(
2743                here,
2744                subRoomName,
2745                requires
2746            )
2747        newGraph.tagTransition(here, subRoomName, tags)
2748        newGraph.annotateTransition(here, subRoomName, annotations)
2749
2750        # If there's a reciprocal, apply any specified tags,
2751        # annotations, and/or requirements to it.
2752        reciprocal = newGraph.getReciprocal(here, subRoomName)
2753        if reciprocal is not None:
2754            newGraph.tagTransition(newPos, reciprocal, revTags)
2755            newGraph.annotateTransition(
2756                newPos,
2757                reciprocal,
2758                revAnnotations
2759            )
2760            if revRequires is not None:
2761                newGraph.setTransitionRequirement(
2762                    newPos,
2763                    reciprocal,
2764                    postRevReq
2765                )
2766
2767    def takeActionInRoom(
2768        self,
2769        name: Optional[core.Transition] = None,
2770        gain: Optional[str] = None,
2771        forwardReq: Optional[core.Requirement] = None,
2772        extraGain: Optional[core.Requirement] = None,
2773        fTags: Optional[Set[core.Tag]] = None,
2774        rTags: Optional[Set[core.Tag]] = None,
2775        untaken: bool = False
2776    ) -> core.Transition:
2777        """
2778        Adds an action to the current room, and takes it. The exploration to
2779        modify and the parse format to use are required. If a name for the
2780        action is not provided, a unique name will be generated. If the
2781        action results in gaining an item, the item gained should be passed
2782        as a string (will be parsed using `ParseFormat.parseItem`).
2783        Forward/backward requirements and tags may be provided, but passing
2784        anything other than None for the backward requirement or tags will
2785        result in a `JournalParseError`.
2786
2787        If `untaken` is set to True (default is False) then the action will
2788        be created, but will not be taken.
2789
2790        Returns the name of the transition, which is either the specified
2791        name or a unique name created automatically.
2792        """
2793        # Get current info
2794        here = self.exploration.currentPosition()
2795        now = self.exploration.currentGraph()
2796
2797        # Assign a unique action name if none was provided
2798        wantsUnique = False
2799        if name is None:
2800            wantsUnique = True
2801            name = f"action@{len(exploration)}"
2802
2803        # Accumulate powers/tokens gained
2804        gainedStuff = []
2805        # Parse item gained if there is one, and add it to the action name
2806        # as well
2807        if gain is not None:
2808            gainedStuff.append(parseFormat.parseItem(gain))
2809            name += gain
2810
2811        # Reverse requirements are translated into extra powers/tokens gained
2812        # (but may only be a disjunction of power/token requirements).
2813        # TODO: Allow using ReqNot to instantiate power-removal/token-cost
2814        # effects!!!
2815        if extraGain is not None:
2816            gainedStuff.extend(extraGain.asGainList())
2817
2818        if len(gainedStuff) > 0:
2819            effects = core.effects(gain=gainedStuff)
2820        else:
2821            effects = core.effects() # no effects
2822
2823        # Ensure that action name is unique
2824        if wantsUnique:
2825            # Find all transitions that start with this name which have a
2826            # '.' in their name.
2827            already = [
2828                transition
2829                for transition in now.destinationsFrom(here)
2830                if transition.startswith(name) and '.' in transition
2831            ]
2832
2833            # Collect just the numerical parts after the dots
2834            nums = []
2835            for prev in already:
2836                try:
2837                    nums.append(int(prev.split('.')[-1]))
2838                except ValueError:
2839                    pass
2840
2841            # If there aren't any (or aren't any with a .number part), make
2842            # the name unique by adding '.1'
2843            if len(nums) == 0:
2844                name = name + '.1'
2845            else:
2846                # If there are nums, pick a higher one
2847                name = name + '.' + str(max(nums) + 1)
2848
2849        # TODO: Handle repeatable actions with effects, and other effect
2850        # types...
2851
2852        if rTags:
2853            raise JournalParseError(
2854                f"Cannot apply reverse tags {rTags} to action '{name}' in"
2855                f" room {here}: Actions have no reciprocal."
2856            )
2857
2858        # Create and/or take the action
2859        if untaken:
2860            now.addAction(
2861                here,
2862                name,
2863                forwardReq, # might be None
2864                effects
2865            )
2866        else:
2867            exploration.takeAction(
2868                name,
2869                forwardReq, # might be None
2870                effects
2871            )
2872
2873        # Apply tags to the action transition
2874        if fTags is not None:
2875            now = exploration.currentGraph()
2876            now.tagTransition(here, name, fTags)
2877
2878        # Return the action name
2879        return name
2880
2881    def observeRoomEntrance(
2882        self,
2883        transitionTaken: core.Transition,
2884        roomName: core.Decision,
2885        revName: Optional[core.Transition] = None,
2886        oneway: bool = False,
2887        fReq: Optional[core.Requirement] = None,
2888        rReq: Optional[core.Requirement] = None,
2889        fTags: Optional[Set[core.Tag]] = None,
2890        rTags: Optional[Set[core.Tag]] = None
2891    ):
2892        """
2893        Records entry into a new room via a specific transition from the
2894        current position, creating a new unexplored node if necessary
2895        and then exploring it, or returning to or retracing an existing
2896        decision/transition.
2897        """
2898
2899        # TODO: HERE
2900
2901#                    # An otherway marker can be used as an entrance to
2902#                    # indicate that the connection is one-way. Note that for
2903#                    # a one-way connection, we may have a requirement
2904#                    # specifying that the reverse connection exists but
2905#                    # can't be traversed yet. In cases where there is no
2906#                    # requirement, we *still* add a reciprocal edge to the
2907#                    # graph, but mark it as `ReqImpossible`. This is because
2908#                    # we want the rooms' adjacency to be visible from both
2909#                    # sides, and some of our graph algorithms have to respect
2910#                    # requirements anyways. Cases where a reciprocal edge
2911#                    # will be absent are one-way teleporters where there's
2912#                    # actually no sealed connection indicator in the
2913#                    # destination room. TODO: Syntax for those?
2914#
2915#                    # Get transition name
2916#                    transitionName = content
2917#
2918#                    # If this is not the start of the exploration or a
2919#                    # reset after an ending, check for a previous transition
2920#                    # entering this decision from the same previous
2921#                    # room/transition.
2922#                    prevReciprocal = None
2923#                    prevDestination = None
2924#                    if enterFrom is not None and now is not None:
2925#                        fromDecision, fromTransition = enterFrom
2926#                        prevReciprocal = now.getReciprocal(
2927#                            fromDecision,
2928#                            fromTransition
2929#                        )
2930#                        prevDestination = now.getDestination(
2931#                            fromDecision,
2932#                            fromTransition
2933#                        )
2934#                        if prevDestination is None:
2935#                            raise JournalParseError(
2936#                                f"Transition {fromTransition} from"
2937#                                f" {fromDecision} was named as exploration"
2938#                                f" point but has not been created!"
2939#                            )
2940#
2941#                        # If there is a previous reciprocal edge marked, and
2942#                        # it doesn't match the entering reciprocal edge,
2943#                        # that's an inconsistency, unless that edge was
2944#                        # coming from an unknown node.
2945#                        if (
2946#                            not now.isUnknown(prevDestination)
2947#                        and prevReciprocal != transitionName
2948#                        ): # prevReciprocal of None won't be
2949#                            warnings.warn(
2950#                                (
2951#                                    f"Explicit incoming transition from"
2952#                                    f" {fromDecision}:{fromTransition}"
2953#                                    f" entering {roomName} via"
2954#                                    f" {transitionName} does not match"
2955#                                    f" previous entrance point for that"
2956#                                    f" transition, which was"
2957#                                    f" {prevReciprocal}. The reciprocal edge"
2958#                                    f" will NOT be updated."
2959#                                ),
2960#                                JournalParseWarning
2961#                            )
2962#
2963#                        # Similarly, if there is an outgoing transition in
2964#                        # the destination room whose name matches the
2965#                        # declared reciprocal but whose destination isn't
2966#                        # unknown and isn't he current location, that's an
2967#                        # inconsistency
2968#                        prevRevDestination = now.getDestination(
2969#                            roomName,
2970#                            transitionName
2971#                        )
2972#                        if (
2973#                            prevRevDestination is not None
2974#                        and not now.isUnknown(prevRevDestination)
2975#                        and prevRevDestination != fromDecision
2976#                        ):
2977#                            warnings.warn(
2978#                                (
2979#                                    f"Explicit incoming transition from"
2980#                                    f" {fromDecision}:{fromTransition}"
2981#                                    f" entering {roomName} via"
2982#                                    f" {transitionName} does not match"
2983#                                    f" previous destination for"
2984#                                    f" {transitionName} in that room, which was"
2985#                                    f" {prevRevDestination}. The reciprocal edge"
2986#                                    f" will NOT be updated."
2987#                                    # TODO: What will happen?
2988#                                ),
2989#                                JournalParseWarning
2990#                            )
2991#
2992#                    seenEntrance = True
2993#                    handledEntry = True
2994#                    if enterFrom is None or now is None:
2995#                        # No incoming transition info
2996#                        if len(exploration) == 0:
2997#                            # Start of the exploration
2998#                            exploration.start(roomName, [])
2999#                            # with an explicit entrance.
3000#                            exploration.currentGraph().addUnexploredEdge(
3001#                                roomName,
3002#                                transitionName,
3003#                                tags=fTags,
3004#                                revTags=rTags,
3005#                                requires=forwardReq,
3006#                                revRequires=backReq
3007#                            )
3008#                        else:
3009#                            # Continuing after an ending MUST NOT involve an
3010#                            # explicit entrance, because the transition is a
3011#                            # warp. To annotate a warp where the character
3012#                            # enters back into the game using a traversable
3013#                            # transition (and e.g., transition effects
3014#                            # apply), include a block noting their presence
3015#                            # on the other side of that doorway followed by
3016#                            # an explicit transition into the room where
3017#                            # control is available, with a 'forced' tag. If
3018#                            # the other side is unknown, just use an
3019#                            # unexplored entry as the first entry in the
3020#                            # block after the ending.
3021#                            raise JournalParseError(
3022#                                f"On line #{lineNumber}, an explicit"
3023#                                f" entrance is not allowed because the"
3024#                                f" previous block ended with an ending."
3025#                                f" Block is:\n{journalBlock}"
3026#                            )
3027#                    else:
3028#                        # Implicitly, prevDestination must not be None here,
3029#                        # since a JournalParseError would have been raised
3030#                        # if enterFrom was not None and we didn't get a
3031#                        # prevDestination. But it might be an unknown area.
3032#                        prevDestination = cast(core.Decision, prevDestination)
3033#
3034#                        # Extract room & transition we're entering from
3035#                        fromRoom, fromTransition = enterFrom
3036#
3037#                        # If we've seen this room before, check for an old
3038#                        # transition destination, since we might implicitly
3039#                        # be entering a sub-room.
3040#                        if now is not None and roomName in now:
3041#                            if now.isUnknown(prevDestination):
3042#                                # The room already exists, but the
3043#                                # transition we're taking to enter it is not
3044#                                # one we've used before. If the entry point
3045#                                # is not a known transition, unless the
3046#                                # journaler has explicitly tagged the
3047#                                # reciprocal transition with 'discovered', we
3048#                                # assume entrance is to a new sub-room, since
3049#                                # otherwise the transition should have been
3050#                                # known ahead of time.
3051#                                # TODO: Does this mean we have to search for
3052#                                # matching names in other sub-room parts
3053#                                # when doing in-room transitions... ?
3054#                                exploration.returnTo(
3055#                                    fromTransition,
3056#                                    roomName,
3057#                                    transitionName
3058#                                )
3059#                            else:
3060#                                # We already know where this transition
3061#                                # leads
3062#                                exploration.retrace(fromTransition)
3063#                        else:
3064#                            # We're entering this room for the first time.
3065#                            exploration.explore(
3066#                                fromTransition,
3067#                                roomName,
3068#                                [],
3069#                                transitionName
3070#                            )
3071#                        # Apply forward tags to the outgoing transition
3072#                        # that's named, and reverse tags to the incoming
3073#                        # transition we just followed
3074#                        now = exploration.currentGraph() # graph was updated
3075#                        here = exploration.currentPosition()
3076#                        now.tagTransition(here, transitionName, fTags)
3077#                        now.tagTransition(fromRoom, fromTransition, rTags)
3078
3079
3080def updateExplorationFromEntry(
3081    exploration: core.Exploration,
3082    parseFormat: ParseFormat,
3083    journalBlock: str,
3084    enterFrom: Optional[Tuple[core.Decision, core.Transition]] = None,
3085) -> Optional[Tuple[core.Decision, core.Transition]]:
3086    """
3087    Given an exploration object, a parsing format dictionary, and a
3088    multi-line string which is a journal entry block, updates the
3089    exploration to reflect the entries in the block. Except for the
3090    first block of a journal, or continuing blocks after an ending,
3091    where `enterFrom` must be None, a tuple specifying the room and
3092    transition taken to enter the block must be provided so we know where
3093    to anchor the new activity.
3094
3095    This function returns a tuple specifying the room and transition in
3096    that room taken to exit from the block, which can be used as the
3097    `enterFrom` value for the next block. It returns none if the block
3098    ends with an 'ending' entry.
3099    """
3100    # Set up state variables
3101
3102    # Tracks the room name, once one has been declared
3103    roomName: Optional[core.Decision] = None
3104    roomTags: Set[core.Tag] = set()
3105
3106    # Whether we've seen an entrance/exit yet
3107    seenEntrance = False
3108
3109    # The room & transition used to exit
3110    exitRoom = None
3111    exitTransition = None
3112
3113    # This tracks the current note text, since notes can continue across
3114    # multiple lines
3115    currentNote: Optional[Tuple[
3116        Union[core.Decision, Tuple[core.Decision, core.Transition]], # target
3117        bool, # was this note indented?
3118        str # note text
3119    ]] = None
3120
3121    # Tracks a pending progress step, since things like a oneway can be
3122    # used for either within-room progress OR room-to-room transitions.
3123    pendingProgress: Optional[Tuple[
3124        core.Transition, # transition name to create
3125        Union[bool, str], # is it one-way; 'hidden' for a hidden one-way?
3126        Optional[core.Requirement], # requirement for the transition
3127        Optional[core.Requirement], # reciprocal requirement
3128        Optional[Set[core.Tag]], # tags to apply
3129        Optional[Set[core.Tag]], # reciprocal tags
3130        Optional[List[core.Annotation]], # annotations to apply
3131        Optional[List[core.Annotation]] # reciprocal annotations
3132    ]] = None
3133
3134    # This tracks the current entries in an inter-room abbreviated path,
3135    # since we first have to accumulate all of them and then do
3136    # pathfinding to figure out a concrete inter-room path.
3137    interRoomPath: List[Union[Type[InterRoomEllipsis], core.Decision]] = []
3138
3139    # Standardize newlines just in case
3140    journalBlock = journalBlock\
3141        .replace('\r\n', '\n')\
3142        .replace('\n\r', '\n')\
3143        .replace('\r', '\n')
3144
3145    # Line splitting variables
3146    lineNumber = 0 # first iteration will increment to 1 before use
3147    blockIndex = 0 # Character index into the block tracking progress
3148    blockLen = len(journalBlock) # So we know when to stop
3149    lineIncrement = 1 # How many lines we've processed
3150
3151    # Tracks presence of an end entry, which must be final in the block
3152    # except for notes or tags.
3153    ended = False
3154
3155    # Parse each line separately, but collect multiple lines for
3156    # multi-line detours
3157    while blockIndex < blockLen:
3158        lineNumber += lineIncrement
3159        lineIncrement = 1
3160        try:
3161            # Find the next newline
3162            nextNL = journalBlock.index('\n', blockIndex)
3163            line = journalBlock[blockIndex:nextNL]
3164            blockIndex = nextNL + 1
3165        except ValueError:
3166            # If there isn't one, rest of the block is the next line
3167            line = journalBlock[blockIndex:]
3168            blockIndex = blockLen
3169
3170        print("LL", lineNumber, line)
3171
3172        # Check for and split off anonymous room content
3173        line, anonymousContent = parseFormat.splitAnonymousRoom(line)
3174        if (
3175            anonymousContent is None
3176        and parseFormat.startsAnonymousRoom(line)
3177        ):
3178            endIndex = parseFormat.anonymousRoomEnd(
3179                journalBlock,
3180                blockIndex
3181            )
3182            if endIndex is None:
3183                raise JournalParseError(
3184                    f"Anonymous room started on line {lineNumber}"
3185                    f" was never closed in block:\n{journalBlock}"
3186                )
3187            anonymousContent = journalBlock[nextNL + 1:endIndex].strip()
3188            # TODO: Is this correct?
3189            lineIncrement = anonymousContent.count('\n') + 1
3190            # Skip to end of line where anonymous room ends
3191            blockIndex = journalBlock.index('\n', endIndex + 1)
3192
3193            # Trim the start of the anonymous room from the line end
3194            line = line.rstrip()[:-1]
3195
3196        # Skip blank lines
3197        if not line.strip():
3198            continue
3199
3200        # Check for indentation (mostly ignored, but important for
3201        # comments).
3202        indented = line[0] == ' '
3203
3204        # Strip indentation going forward
3205        line = line.strip()
3206
3207        # Detect entry type and separate content
3208        eType, eContent = parseFormat.determineEntryType(line)
3209
3210        print("EE", lineNumber, eType, eContent)
3211
3212        if exitTransition is not None and eType != 'note':
3213            raise JournalParseError(
3214                f"Entry after room exit on line {lineNumber} in"
3215                f" block:\n{journalBlock}"
3216            )
3217
3218        if eType != 'detour' and anonymousContent is not None:
3219            raise JournalParseError(
3220                f"Entry #{lineNumber} with type {eType} does not"
3221                f" support anonymous room content. Block"
3222                f" is:\n{journalBlock}"
3223            )
3224
3225        # Handle note creation
3226        if currentNote is not None and eType != 'note':
3227            # This ends a note, so we can apply the pending note and
3228            # reset it.
3229            target, _, noteText = currentNote
3230            currentNote = None
3231            # Apply our annotation to the room or transition it targets
3232            if isinstance(target, str):
3233                exploration.currentGraph().annotateDecision(target, noteText)
3234            else:
3235                room, transition = target
3236                exploration.currentGraph().annotateTransition(
3237                    room,
3238                    transition,
3239                    noteText
3240                )
3241        elif eType == 'note':
3242            # whole line is a note; handle new vs. continuing note
3243            if currentNote is None:
3244                # Start a new note
3245                currentNote = (
3246                    exploration.currentPosition(),
3247                    indented,
3248                    eContent
3249                )
3250            else:
3251                # Previous note exists, use indentation to decide if
3252                # we're continuing or starting a new note
3253                target, wasIndented, noteText = currentNote
3254                if indented != wasIndented:
3255                    # Then we apply the old note and create a new note at
3256                    # the room level
3257                    if isinstance(target, str):
3258                        exploration.currentGraph().annotateDecision(
3259                            target,
3260                            noteText
3261                        )
3262                    else:
3263                        room, transition = target
3264                        exploration.currentGraph().annotateTransition(
3265                            room,
3266                            transition,
3267                            noteText
3268                        )
3269                    currentNote = (
3270                        exploration.currentPosition(),
3271                        indented,
3272                        f"(step #{len(exploration)}) " + eContent
3273                    )
3274                else:
3275                    # Else indentation matches so add to previous note
3276                    currentNote = (
3277                        target,
3278                        wasIndented,
3279                        noteText + '\n' + eContent
3280                    )
3281            # In (only) this case, we've handled the entire line
3282            continue
3283
3284        # Handle a pending progress step if there is one
3285        if pendingProgress is not None:
3286            # Any kind of entry except a note (which we would have hit
3287            # above and continued) indicates that a progress marker is
3288            # in-room progress rather than being a room exit.
3289            makeProgressInRoom(exploration, parseFormat, *pendingProgress)
3290
3291            # Clean out pendingProgress
3292            pendingProgress = None
3293
3294        # Check for valid eType if pre-room
3295        if roomName is None and eType not in ('room', 'progress'):
3296            raise JournalParseError(
3297                f"Invalid entry #{lineNumber}: Entry type '{eType}' not"
3298                f" allowed before room name. Block is:\n{journalBlock}"
3299            )
3300
3301        # Check for valid eType if post-room
3302        if ended and eType not in ('note', 'tag'):
3303            raise JournalParseError(
3304                f"Invalid entry #{lineNumber}: Entry type '{eType}' not"
3305                f" allowed after an ending. Block is:\n{journalBlock}"
3306            )
3307
3308        # Parse a line-end note if there is one
3309        # Note that note content will be handled after we handle main
3310        # entry stuff
3311        content, note = parseFormat.splitFinalNote(eContent)
3312
3313        # Parse a line-end tags section if there is one
3314        content, fTags, rTags = parseFormat.splitTags(content)
3315
3316        # Parse a line-end requirements section if there is one
3317        content, forwardReq, backReq = parseFormat.splitRequirement(content)
3318
3319        # Strip any remaining whitespace from the edges of our content
3320        content = content.strip()
3321
3322        # Get current graph
3323        now = exploration.getCurrentGraph()
3324
3325        # This will trigger on the first line in the room, and handles
3326        # the actual room creation in the graph
3327        handledEntry = False # did we handle the entry in this block?
3328        if roomName is not None and not seenEntrance:
3329            # We're looking for an entrance and if we see anything else
3330            # except a tag, we'll assume that the entrance is implicit,
3331            # and give an error if we don't have an implicit entrance
3332            # set up. If the entrance is explicit, we'll give a warning
3333            # if it doesn't match the previous entrance for the same
3334            # prior-room exit from last time.
3335            if eType in ('entrance', 'otherway'):
3336                # An explicit entrance; must match previous associated
3337                # entrance if there was one.
3338
3339                # An otherway marker can be used as an entrance to
3340                # indicate that the connection is one-way. Note that for
3341                # a one-way connection, we may have a requirement
3342                # specifying that the reverse connection exists but
3343                # can't be traversed yet. In cases where there is no
3344                # requirement, we *still* add a reciprocal edge to the
3345                # graph, but mark it as `ReqImpossible`. This is because
3346                # we want the rooms' adjacency to be visible from both
3347                # sides, and some of our graph algorithms have to respect
3348                # requirements anyways. Cases where a reciprocal edge
3349                # will be absent are one-way teleporters where there's
3350                # actually no sealed connection indicator in the
3351                # destination room. TODO: Syntax for those?
3352
3353                # Get transition name
3354                transitionName = content
3355
3356                # If this is not the start of the exploration or a
3357                # reset after an ending, check for a previous transition
3358                # entering this decision from the same previous
3359                # room/transition.
3360                prevReciprocal = None
3361                prevDestination = None
3362                if enterFrom is not None and now is not None:
3363                    fromDecision, fromTransition = enterFrom
3364                    prevReciprocal = now.getReciprocal(
3365                        fromDecision,
3366                        fromTransition
3367                    )
3368                    prevDestination = now.getDestination(
3369                        fromDecision,
3370                        fromTransition
3371                    )
3372                    if prevDestination is None:
3373                        raise JournalParseError(
3374                            f"Transition {fromTransition} from"
3375                            f" {fromDecision} was named as exploration"
3376                            f" point but has not been created!"
3377                        )
3378
3379                    # If there is a previous reciprocal edge marked, and
3380                    # it doesn't match the entering reciprocal edge,
3381                    # that's an inconsistency, unless that edge was
3382                    # coming from an unknown node.
3383                    if (
3384                        not now.isUnknown(prevDestination)
3385                    and prevReciprocal != transitionName
3386                    ): # prevReciprocal of None won't be
3387                        warnings.warn(
3388                            (
3389                                f"Explicit incoming transition from"
3390                                f" {fromDecision}:{fromTransition}"
3391                                f" entering {roomName} via"
3392                                f" {transitionName} does not match"
3393                                f" previous entrance point for that"
3394                                f" transition, which was"
3395                                f" {prevReciprocal}. The reciprocal edge"
3396                                f" will NOT be updated."
3397                            ),
3398                            JournalParseWarning
3399                        )
3400
3401                    # Similarly, if there is an outgoing transition in
3402                    # the destination room whose name matches the
3403                    # declared reciprocal but whose destination isn't
3404                    # unknown and isn't he current location, that's an
3405                    # inconsistency
3406                    prevRevDestination = now.getDestination(
3407                        roomName,
3408                        transitionName
3409                    )
3410                    if (
3411                        prevRevDestination is not None
3412                    and not now.isUnknown(prevRevDestination)
3413                    and prevRevDestination != fromDecision
3414                    ):
3415                        warnings.warn(
3416                            (
3417                                f"Explicit incoming transition from"
3418                                f" {fromDecision}:{fromTransition}"
3419                                f" entering {roomName} via"
3420                                f" {transitionName} does not match"
3421                                f" previous destination for"
3422                                f" {transitionName} in that room, which was"
3423                                f" {prevRevDestination}. The reciprocal edge"
3424                                f" will NOT be updated."
3425                                # TODO: What will happen?
3426                            ),
3427                            JournalParseWarning
3428                        )
3429
3430                seenEntrance = True
3431                handledEntry = True
3432                if enterFrom is None or now is None:
3433                    # No incoming transition info
3434                    if len(exploration) == 0:
3435                        # Start of the exploration
3436                        exploration.start(roomName, [])
3437                        # with an explicit entrance.
3438                        exploration.currentGraph().addUnexploredEdge(
3439                            roomName,
3440                            transitionName,
3441                            tags=fTags,
3442                            revTags=rTags,
3443                            requires=forwardReq,
3444                            revRequires=backReq
3445                        )
3446                    else:
3447                        # Continuing after an ending MUST NOT involve an
3448                        # explicit entrance, because the transition is a
3449                        # warp. To annotate a warp where the character
3450                        # enters back into the game using a traversable
3451                        # transition (and e.g., transition effects
3452                        # apply), include a block noting their presence
3453                        # on the other side of that doorway followed by
3454                        # an explicit transition into the room where
3455                        # control is available, with a 'forced' tag. If
3456                        # the other side is unknown, just use an
3457                        # unexplored entry as the first entry in the
3458                        # block after the ending.
3459                        raise JournalParseError(
3460                            f"On line #{lineNumber}, an explicit"
3461                            f" entrance is not allowed because the"
3462                            f" previous block ended with an ending."
3463                            f" Block is:\n{journalBlock}"
3464                        )
3465                else:
3466                    # Implicitly, prevDestination must not be None here,
3467                    # since a JournalParseError would have been raised
3468                    # if enterFrom was not None and we didn't get a
3469                    # prevDestination. But it might be an unknown area.
3470                    prevDestination = cast(core.Decision, prevDestination)
3471
3472                    # Extract room & transition we're entering from
3473                    fromRoom, fromTransition = enterFrom
3474
3475                    # If we've seen this room before, check for an old
3476                    # transition destination, since we might implicitly
3477                    # be entering a sub-room.
3478                    if now is not None and roomName in now:
3479                        if now.isUnknown(prevDestination):
3480                            # The room already exists, but the
3481                            # transition we're taking to enter it is not
3482                            # one we've used before. If the entry point
3483                            # is not a known transition, unless the
3484                            # journaler has explicitly tagged the
3485                            # reciprocal transition with 'discovered', we
3486                            # assume entrance is to a new sub-room, since
3487                            # otherwise the transition should have been
3488                            # known ahead of time.
3489                            # TODO: Does this mean we have to search for
3490                            # matching names in other sub-room parts
3491                            # when doing in-room transitions... ?
3492                            exploration.returnTo(
3493                                fromTransition,
3494                                roomName,
3495                                transitionName
3496                            )
3497                        else:
3498                            # We already know where this transition
3499                            # leads
3500                            exploration.retrace(fromTransition)
3501                    else:
3502                        # We're entering this room for the first time.
3503                        exploration.explore(
3504                            fromTransition,
3505                            roomName,
3506                            [],
3507                            transitionName
3508                        )
3509                    # Apply forward tags to the outgoing transition
3510                    # that's named, and reverse tags to the incoming
3511                    # transition we just followed
3512                    now = exploration.currentGraph() # graph was updated
3513                    here = exploration.currentPosition()
3514                    now.tagTransition(here, transitionName, fTags)
3515                    now.tagTransition(fromRoom, fromTransition, rTags)
3516
3517            elif eType == 'tag':
3518                roomTags |= set(content.split())
3519                if fTags or rTags:
3520                    raise JournalParseError(
3521                        f"Found tags on tag entry on line #{lineNumber}"
3522                        f" of block:\n{journalBlock}"
3523                    )
3524                # don't do anything else here since it's a tag;
3525                # seenEntrance remains False
3526                handledEntry = True
3527
3528            else:
3529                # For any other entry type, it counts as an implicit
3530                # entrance. We need to follow that transition, or if an
3531                # appropriate link does not already exist, raise an
3532                # error.
3533                seenEntrance = True
3534                # handledEntry remains False in this case
3535
3536                # Check that the entry point for this room can be
3537                # deduced, and deduce it so that we can figure out which
3538                # sub-room we're actually entering...
3539                if enterFrom is None:
3540                    if len(exploration) == 0:
3541                        # At the start of the exploration, there's often
3542                        # no specific transition we come from, which is
3543                        # fine.
3544                        exploration.start(roomName, [])
3545                    else:
3546                        # Continuation after an ending
3547                        exploration.warp(roomName, 'restart')
3548                else:
3549                    fromDecision, fromTransition = enterFrom
3550                    prevReciprocal = None
3551                    if now is not None:
3552                        prevReciprocal = now.getReciprocal(
3553                            fromDecision,
3554                            fromTransition
3555                        )
3556                    if prevReciprocal is None:
3557                        raise JournalParseError(
3558                            f"Implicit transition into room {roomName}"
3559                            f" is invalid because no reciprocal"
3560                            f" transition has been established for exit"
3561                            f" {fromTransition} in previous room"
3562                            f" {fromDecision}."
3563                        )
3564
3565                    # In this case, we retrace the transition, and if
3566                    # that fails because of a ValueError (e.g., because
3567                    # that transition doesn't exist yet or leads to an
3568                    # unknown node) then we'll raise the error as a
3569                    # JournalParseError.
3570                    try:
3571                        exploration.retrace(fromTransition)
3572                    except ValueError as e:
3573                        raise JournalParseError(
3574                            f"Implicit transition into room {roomName}"
3575                            f" is invalid because:\n{e.args[0]}"
3576                        )
3577
3578                    # Note: no tags get applied here, because this is an
3579                    # implicit transition, so there's no room to apply
3580                    # new tags. An explicit transition could be used
3581                    # instead to update transition properties.
3582
3583        # Previous block may have updated the current graph
3584        now = exploration.getCurrentGraph()
3585
3586        # At this point, if we've seen an entrance we're in the right
3587        # room, so we should apply accumulated room tags
3588        if seenEntrance and roomTags:
3589            if now is None:
3590                raise RuntimeError(
3591                    "Inconsistency: seenEntrance is True but the current"
3592                    " graph is None."
3593                )
3594
3595            here = exploration.currentPosition()
3596            now.tagDecision(here, roomTags)
3597            roomTags = set() # reset room tags
3598
3599        # Handle all entry types not handled above (like note)
3600        if handledEntry:
3601            # We skip this if/else but still do end-of-loop cleanup
3602            pass
3603
3604        elif eType == 'note':
3605            raise RuntimeError("Saw 'note' eType in lower handling block.")
3606
3607        elif eType == 'room':
3608            if roomName is not None:
3609                raise ValueError(
3610                    f"Multiple room names detected on line {lineNumber}"
3611                    f" in block:\n{journalBlock}"
3612                )
3613
3614            # Setting the room name changes the loop state
3615            roomName = content
3616
3617            # These will be applied later
3618            roomTags = fTags
3619
3620            if rTags:
3621                raise JournalParseError(
3622                    f"Reverse tags cannot be applied to a room"
3623                    f" (found tags {rTags} for room '{roomName}')."
3624                )
3625
3626        elif eType == 'entrance':
3627            # would be handled above if seenEntrance was false
3628            raise JournalParseError(
3629                f"Multiple entrances on line {lineNumber} in"
3630                f" block:\n{journalBlock}"
3631            )
3632
3633        elif eType == 'exit':
3634            # We note the exit transition and will use that as our
3635            # return value. This also will cause an error on the next
3636            # iteration if there are further non-note entries in the
3637            # journal block
3638            exitRoom = exploration.currentPosition()
3639            exitTransition = content
3640
3641            # At this point we add an unexplored edge for this exit,
3642            # assuming it's not one we've seen before. Note that this
3643            # does not create a new exploration step (that will happen
3644            # later).
3645            knownDestination = None
3646            if now is not None:
3647                knownDestination = now.getDestination(
3648                    exitRoom,
3649                    exitTransition
3650                )
3651
3652                if knownDestination is None:
3653                    now.addUnexploredEdge(
3654                        exitRoom,
3655                        exitTransition,
3656                        tags=fTags,
3657                        revTags=rTags,
3658                        requires=forwardReq,
3659                        revRequires=backReq
3660                    )
3661
3662                else:
3663                    # Otherwise just apply any tags to the transition
3664                    now.tagTransition(exitRoom, exitTransition, fTags)
3665                    existingReciprocal = now.getReciprocal(
3666                        exitRoom,
3667                        exitTransition
3668                    )
3669                    if existingReciprocal is not None:
3670                        now.tagTransition(
3671                            knownDestination,
3672                            existingReciprocal,
3673                            rTags
3674                        )
3675
3676        elif eType in (
3677            'blocked',
3678            'otherway',
3679            'unexplored',
3680            'unexploredOneway',
3681        ):
3682            # Simply add the listed transition to our current room,
3683            # leading to an unknown destination, without creating a new
3684            # exploration step
3685            transition = content
3686            here = exploration.currentPosition()
3687
3688            # If there isn't a listed requirement, infer ReqImpossible
3689            # where appropriate
3690            if forwardReq is None and eType in ('blocked', 'otherway'):
3691                forwardReq = core.ReqImpossible()
3692            if backReq is None and eType in ('blocked', 'unexploredOneway'):
3693                backReq = core.ReqImpossible()
3694
3695            # TODO: What if we've annotated a known source for this
3696            # link?
3697
3698            if now is None:
3699                raise JournalParseError(
3700                    f"On line {lineNumber}: Cannot create an unexplored"
3701                    f" transition before we've created the starting"
3702                    f" graph. Block is:\n{journalBlock}"
3703                )
3704
3705            now.addUnexploredEdge(
3706                here,
3707                transition,
3708                tags=fTags,
3709                revTags=rTags,
3710                requires=forwardReq,
3711                revRequires=backReq
3712            )
3713
3714        elif eType in ('pickup', 'unclaimed', 'action'):
3715            # We both add an action to the current room, and then take
3716            # that action, or if the type is unclaimed, we don't take
3717            # the action.
3718
3719            if eType == 'unclaimed' and content[0] == '?':
3720                fTags.add('unknown')
3721
3722            name: Optional[str] = None # auto by default
3723            gains: Optional[str] = None
3724            if eType == 'action':
3725                name = content
3726                # TODO: Generalize action effects; also handle toggles,
3727                # repeatability, etc.
3728            else:
3729                gains = content
3730
3731            actionName = takeActionInRoom(
3732                exploration,
3733                parseFormat,
3734                name,
3735                gains,
3736                forwardReq,
3737                backReq,
3738                fTags,
3739                rTags,
3740                eType == 'unclaimed' # whether to leave it untaken
3741            )
3742
3743            # Limit scope to this case
3744            del name
3745            del gains
3746
3747        elif eType == 'progress':
3748            # If the room name hasn't been specified yet, this indicates
3749            # a room that we traverse en route. If the room name has
3750            # been specified, this is movement to a new sub-room.
3751            if roomName is None:
3752                # Here we need to accumulate the named route, since the
3753                # navigation of sub-rooms has to be figured out by
3754                # pathfinding, but that's only possible once we know
3755                # *all* of the listed rooms. Note that the parse
3756                # format's 'runback' symbol may be used as a room name
3757                # to indicate that some of the route should be
3758                # auto-completed.
3759                if content == parseFormat.formatDict['runback']:
3760                    interRoomPath.append(InterRoomEllipsis)
3761                else:
3762                    interRoomPath.append(content)
3763            else:
3764                # This is progress to a new sub-room. If we've been
3765                # to that sub-room from the current sub-room before, we
3766                # retrace the connection, and if not, we first add an
3767                # unexplored connection and then explore it.
3768                makeProgressInRoom(
3769                    exploration,
3770                    parseFormat,
3771                    content,
3772                    False,
3773                    forwardReq,
3774                    backReq,
3775                    fTags,
3776                    rTags
3777                    # annotations handled separately
3778                )
3779
3780        elif eType == 'frontier':
3781            pass
3782            # TODO: HERE
3783
3784        elif eType == 'frontierEnd':
3785            pass
3786            # TODO: HERE
3787
3788        elif eType == 'oops':
3789            # This removes the specified transition from the graph,
3790            # creating a new exploration step to do so. It tags that
3791            # transition as an oops in the previous graph, because the
3792            # transition won't exist to be tagged in the new graph. If the
3793            # transition led to a non-frontier unknown node, that entire
3794            # node is removed; otherwise just the single transition is
3795            # removed, along with its reciprocal.
3796            if now is None:
3797                raise JournalParseError(
3798                    f"On line {lineNumber}: Cannot mark an oops before"
3799                    f" we've created the starting graph. Block"
3800                    f" is:\n{journalBlock}"
3801                )
3802
3803            prev = now # remember the previous graph
3804            # TODO
3805            now = exploration.currentGraph()
3806            here = exploration.currentPosition()
3807            print("OOP", now.destinationsFrom(here))
3808            exploration.wait('oops') # create new step w/ no changes
3809            now = exploration.currentGraph()
3810            here = exploration.currentPosition()
3811            accidental = now.getDestination(here, content)
3812            if accidental is None:
3813                raise JournalParseError(
3814                    f"Cannot erase transition '{content}' because it"
3815                    f" does not exist at decision {here}."
3816                )
3817
3818            # If it's an unknown (the usual case) then we remove the
3819            # entire node
3820            if now.isUnknown(accidental):
3821                now.remove_node(accidental)
3822            else:
3823                # Otherwise re move the edge and its reciprocal
3824                reciprocal = now.getReciprocal(here, content)
3825                now.remove_edge(here, accidental, content)
3826                if reciprocal is not None:
3827                    now.remove_edge(accidental, here, reciprocal)
3828
3829            # Tag the transition as an oops in the step before it gets
3830            # removed:
3831            prev.tagTransition(here, content, 'oops')
3832
3833        elif eType in ('oneway', 'hiddenOneway'):
3834            # In these cases, we create a pending progress value, since
3835            # it's possible to use 'oneway' as the exit from a room in
3836            # which case it's not in-room progress but rather a room
3837            # transition.
3838            pendingProgress = (
3839                content,
3840                True if eType == 'oneway' else 'hidden',
3841                forwardReq,
3842                backReq,
3843                fTags,
3844                rTags,
3845                None, # No annotations need be applied now
3846                None
3847            )
3848
3849        elif eType == 'detour':
3850            if anonymousContent is None:
3851                raise JournalParseError(
3852                    f"Detour on line #{lineNumber} is missing an"
3853                    f" anonymous room definition. Block"
3854                    f" is:\n{journalBlock}"
3855                )
3856            # TODO: Support detours to existing rooms w/out anonymous
3857            # content...
3858            if now is None:
3859                raise JournalParseError(
3860                    f"On line {lineNumber}: Cannot create a detour"
3861                    f" before we've created the starting graph. Block"
3862                    f" is:\n{journalBlock}"
3863                )
3864
3865            # First, we create an unexplored transition and then use it
3866            # to enter the anonymous room...
3867            here = exploration.currentPosition()
3868            now.addUnexploredEdge(
3869                here,
3870                content,
3871                tags=fTags,
3872                revTags=rTags,
3873                requires=forwardReq,
3874                revRequires=backReq
3875            )
3876
3877            if roomName is None:
3878                raise JournalParseError(
3879                    f"Detour on line #{lineNumber} occurred before room"
3880                    f" name was known. Block is:\n{journalBlock}"
3881                )
3882
3883            # Get a new unique anonymous name
3884            anonName = parseFormat.anonName(roomName, content)
3885
3886            # Actually enter our detour room
3887            exploration.explore(
3888                content,
3889                anonName,
3890                [], # No connections yet
3891                content + '-return'
3892            )
3893
3894            # Tag the new room as anonymous
3895            now = exploration.currentGraph()
3896            now.tagDecision(anonName, 'anonymous')
3897
3898            # Remember transitions needed to get out of room
3899            thread: List[core.Transition] = []
3900
3901            # Parse in-room activity and create steps for it
3902            anonLines = anonymousContent.splitlines()
3903            for anonLine in anonLines:
3904                anonLine = anonLine.strip()
3905                try:
3906                    anonType, anonContent = parseFormat.determineEntryType(
3907                        anonLine
3908                    )
3909                except JournalParseError:
3910                    # One liner that doesn't parse -> treat as tag(s)
3911                    anonType = 'tag'
3912                    anonContent = anonLine.strip()
3913                    if len(anonLines) > 1:
3914                        raise JournalParseError(
3915                            f"Detour on line #{lineNumber} has multiple"
3916                            f" lines but one cannot be parsed as an"
3917                            f" entry:\n{anonLine}\nBlock"
3918                            f" is:\n{journalBlock}"
3919                        )
3920
3921                # Parse final notes, tags, and/or requirements
3922                if anonType != 'note':
3923                    anonContent, note = parseFormat.splitFinalNote(
3924                        anonContent
3925                    )
3926                    anonContent, fTags, rTags = parseFormat.splitTags(
3927                        anonContent
3928                    )
3929                    (
3930                        anonContent,
3931                        forwardReq,
3932                        backReq
3933                    ) = parseFormat.splitRequirement(anonContent)
3934
3935                if anonType == 'note':
3936                    here = exploration.currentPosition()
3937                    now.annotateDecision(here, anonContent)
3938                    # We don't handle multi-line notes in anon rooms
3939
3940                elif anonType == 'tag':
3941                    tags = set(anonContent.split())
3942                    here = exploration.currentPosition()
3943                    now.tagDecision(here, tags)
3944                    if note is not None:
3945                        now.annotateDecision(here, note)
3946
3947                elif anonType == 'progress':
3948                    makeProgressInRoom(
3949                        exploration,
3950                        parseFormat,
3951                        anonContent,
3952                        False,
3953                        forwardReq,
3954                        backReq,
3955                        fTags,
3956                        rTags,
3957                        [ note ] if note is not None else None
3958                        # No reverse annotations
3959                    )
3960                    # We don't handle multi-line notes in anon rooms
3961
3962                    # Remember the way back
3963                    # TODO: HERE Is this still accurate?
3964                    thread.append(anonContent + '-return')
3965
3966                elif anonType in ('pickup', 'unclaimed', 'action'):
3967
3968                    if (
3969                        anonType == 'unclaimed'
3970                    and anonContent.startswith('?')
3971                    ):
3972                        fTags.add('unknown')
3973
3974                    # Note: these are both type Optional[str], but since
3975                    # they exist in another case, they can't be
3976                    # explicitly typed that way here. See:
3977                    # https://github.com/python/mypy/issues/1174
3978                    name = None
3979                    gains = None
3980                    if anonType == 'action':
3981                        name = anonContent
3982                    else:
3983                        gains = anonContent
3984
3985                    actionName = takeActionInRoom(
3986                        exploration,
3987                        parseFormat,
3988                        name,
3989                        gains,
3990                        forwardReq,
3991                        backReq,
3992                        fTags,
3993                        rTags,
3994                        anonType == 'unclaimed' # leave it untaken or not?
3995                    )
3996
3997                    # Limit scope
3998                    del name
3999                    del gains
4000
4001                elif anonType == 'challenge':
4002                    here = exploration.currentPosition()
4003                    now.annotateDecision(
4004                        here,
4005                        "challenge: " + anonContent
4006                    )
4007
4008                elif anonType in ('blocked', 'otherway'):
4009                    here = exploration.currentPosition()
4010
4011                    # Mark as blocked even when no explicit requirement
4012                    # has been provided
4013                    if forwardReq is None:
4014                        forwardReq = core.ReqImpossible()
4015                    if backReq is None and anonType == 'blocked':
4016                        backReq = core.ReqImpossible()
4017
4018                    now.addUnexploredEdge(
4019                        here,
4020                        anonContent,
4021                        tags=fTags,
4022                        revTags=rTags,
4023                        requires=forwardReq,
4024                        revRequires=backReq
4025                    )
4026
4027                else:
4028                    # TODO: Any more entry types we need to support in
4029                    # anonymous rooms?
4030                    raise JournalParseError(
4031                        f"Detour on line #{lineNumber} includes an"
4032                        f" entry of type '{anonType}' which is not"
4033                        f" allowed in an anonymous room. Block"
4034                        f" is:\n{journalBlock}"
4035                    )
4036
4037            # If we made progress, backtrack to the start of the room
4038            for backwards in thread:
4039                exploration.retrace(backwards)
4040
4041            # Now we exit back to the original room
4042            exploration.retrace(content + '-return')
4043
4044        elif eType == 'unify': # TODO: HERE
4045            pass
4046
4047        elif eType == 'obviate': # TODO: HERE
4048            # This represents a connection to somewhere we've been
4049            # before which is recognized but not traversed.
4050            # Note that when you want to use this to replace a mis-named
4051            # unexplored connection (which you now realize actually goes
4052            # to an existing sub-room, not a new one) you should just
4053            # oops that connection first, and then obviate to the actual
4054            # destination.
4055            if now is None:
4056                raise JournalParseError(
4057                    f"On line {lineNumber}: Cannot obviate a transition"
4058                    f" before we've created the starting graph. Block"
4059                    f" is:\n{journalBlock}"
4060                )
4061
4062            here = exploration.currentPosition()
4063
4064            # Two options: if the content lists a room:entrance combo in
4065            # brackets after a transition name, then it represents the
4066            # other side of a door from another room. If, on the other
4067            # hand, it just has a transition name, it represents a
4068            # sub-room name.
4069            content, otherSide = parseFormat.splitAnonymousRoom(content)
4070
4071            if otherSide is None:
4072                # Must be in-room progress
4073                # We create (but don't explore) a transition to that
4074                # sub-room.
4075                baseRoom = parseFormat.baseRoomName(here)
4076                currentSubPart = parseFormat.roomPartName(here)
4077                if currentSubPart is None:
4078                    currentSubPart = parseFormat.formatDict["progress"]
4079                fromDecision = parseFormat.subRoomName(
4080                    baseRoomName,
4081                    content
4082                )
4083
4084                existingReciprocalDestination = now.getDestination(
4085                    fromDecision,
4086                    currentSubPart
4087                )
4088                # If the place we're linking to doesn't have a link back
4089                # to us, then we just create a completely new link.
4090                # TODO
4091            else:
4092                # Here the content specifies an outgoing transition name
4093                # and otherSide specifies the other side, so we don't
4094                # have to search for anything
4095                transitionName = content
4096
4097                # Split decision name and transition name
4098                fromDecision, incoming = parseFormat.parseSpecificTransition(
4099                    otherSide
4100                )
4101                dest = now.getDestination(fromDecision, incoming)
4102
4103                # Check destination exists and is unknown
4104                if dest is None:
4105                    # TODO: Look for alternate sub-room?
4106                    raise JournalParseError(
4107                        f"Obviate entry #{lineNumber} for transition"
4108                        f" {content} has invalid reciprocal transition"
4109                        f" {otherSide}. (Did you forget to specify the"
4110                        f" sub-room?)"
4111                    )
4112                elif not now.isUnknown(dest):
4113                    raise JournalParseError(
4114                        f"Obviate entry #{lineNumber} for transition"
4115                        f" {content} has invalid reciprocal transition"
4116                        f" {otherSide}: that transition's destination"
4117                        f" is already known."
4118                    )
4119
4120            # Now that we know which edge we're obviating, do that
4121            # Note that while the other end is always an existing
4122            # transition to an unexplored destination, our end might be
4123            # novel, so we use replaceUnexplored from the other side
4124            # which allows it to do the work of creating the new
4125            # outgoing transition.
4126            now.replaceUnexplored(
4127                fromDecision,
4128                incoming,
4129                here,
4130                transitionName,
4131                requirement=backReq, # flipped
4132                revRequires=forwardReq,
4133                tags=rTags, # also flipped
4134                revTags=fTags,
4135            )
4136
4137        elif eType == 'challenge':
4138            # For now, these are just annotations
4139            if now is None:
4140                raise JournalParseError(
4141                    f"On line {lineNumber}: Cannot annotate a challenge"
4142                    f" before we've created the starting graph. Block"
4143                    f" is:\n{journalBlock}"
4144                )
4145
4146            here = exploration.currentPosition()
4147            now.annotateDecision(here, f"{eType}: " + content)
4148
4149        elif eType in ('warp', 'death'):
4150            # These warp the player without creating a connection
4151            if forwardReq or backReq:
4152                raise JournalParseError(
4153                    f"'{eType}' entry #{lineNumber} cannot include"
4154                    f" requirements. Block is:\n{journalBlock}"
4155                )
4156            if fTags or rTags:
4157                raise JournalParseError(
4158                    f"'{eType}' entry #{lineNumber} cannot include"
4159                    f" tags. Block is:\n{journalBlock}"
4160                )
4161
4162            try:
4163                exploration.warp(
4164                    content,
4165                    'death' if eType == 'death' else ''
4166                )
4167                # TODO: Death effects?!?
4168                # TODO: We could rewind until we're in a room marked
4169                # 'save' and pick up that position and even state
4170                # automatically ?!? But for save-anywhere games, we'd
4171                # need to have some way of marking a save (could be an
4172                # entry type that creates a special wait?).
4173                # There could even be a way to clone the old graph for
4174                # death, since things like tags applied would presumably
4175                # not be? Or maybe some would and some wouldn't?
4176            except KeyError:
4177                raise JournalParseError(
4178                    f"'{eType}' entry #{lineNumber} specifies"
4179                    f" non-existent destination '{content}'. Block"
4180                    f" is:\n{journalBlock}"
4181                )
4182
4183        elif eType == 'runback':
4184            # For now, we just warp there and back
4185            # TODO: Actually trace the path of the runback...
4186            # TODO: Allow for an action to be taken at the destination
4187            # (like farming health, flipping a switch, etc.)
4188            if forwardReq or backReq:
4189                raise JournalParseError(
4190                    f"Runback on line #{lineNumber} cannot include"
4191                    f" requirements. Block is:\n{journalBlock}"
4192                )
4193            if fTags or rTags:
4194                raise JournalParseError(
4195                    f"Runback on line #{lineNumber} cannot include tags."
4196                    f" Block is:\n{journalBlock}"
4197                )
4198
4199            # Remember where we are
4200            here = exploration.currentPosition()
4201
4202            # Warp back to the runback point
4203            try:
4204                exploration.warp(content, 'runaway')
4205            except KeyError:
4206                raise JournalParseError(
4207                    f"Runback on line #{lineNumber} specifies"
4208                    f" non-existent destination '{content}'. Block"
4209                    f" is:\n{journalBlock}"
4210                )
4211
4212            # Then warp back to the current decision
4213            exploration.warp(here, 'runback')
4214
4215        elif eType == 'traverse':
4216            # For now, we just warp there
4217            # TODO: Actually trace the path of the runback...
4218            if forwardReq or backReq:
4219                raise JournalParseError(
4220                    f"Traversal on line #{lineNumber} cannot include"
4221                    f" requirements. Block is:\n{journalBlock}"
4222                )
4223            if fTags or rTags:
4224                raise JournalParseError(
4225                    f"Traversal on line #{lineNumber} cannot include tags."
4226                    f" Block is:\n{journalBlock}"
4227                )
4228
4229            if now is None:
4230                raise JournalParseError(
4231                    f"Cannot traverse sub-rooms on line #{lineNumber}"
4232                    f" before exploration is started. Block"
4233                    f" is:\n{journalBlock}"
4234                )
4235
4236            # Warp to the destination
4237            here = exploration.currentPosition()
4238            destination = parseFormat.getSubRoom(now, here, content)
4239            if destination is None:
4240                raise JournalParseError(
4241                    f"Traversal on line #{lineNumber} specifies"
4242                    f" non-existent sub-room destination '{content}' in"
4243                    f" room '{parseFormat.baseRoomName(here)}'. Block"
4244                    f" is:\n{journalBlock}"
4245                )
4246            else:
4247                exploration.warp(destination, 'traversal')
4248
4249        elif eType == 'ending':
4250            if now is None:
4251                raise JournalParseError(
4252                    f"On line {lineNumber}: Cannot annotate an ending"
4253                    f" before we've created the starting graph. Block"
4254                    f" is:\n{journalBlock}"
4255                )
4256
4257            if backReq:
4258                raise JournalParseError(
4259                    f"Ending on line #{lineNumber} cannot include"
4260                    f" reverse requirements. Block is:\n{journalBlock}"
4261                )
4262
4263            # Create ending
4264            here = exploration.currentPosition()
4265            # Reverse tags are applied to the ending room itself
4266            now.addEnding(
4267                here,
4268                content,
4269                tags=fTags,
4270                endTags=rTags,
4271                requires=forwardReq
4272            )
4273            # Transition to the ending
4274            print("ED RT", here, content, len(exploration))
4275            exploration.retrace('_e:' + content)
4276            print("ED RT", len(exploration))
4277            ended = True
4278
4279        elif eType == 'tag':
4280            tagsToApply = set(content.split())
4281            if fTags or rTags:
4282                raise JournalParseError(
4283                    f"Found tags on tag entry on line #{lineNumber}"
4284                    f" of block:\n{journalBlock}"
4285                )
4286
4287            if now is None:
4288                raise JournalParseError(
4289                    f"On line {lineNumber}: Cannot add a tag before"
4290                    f" we've created the starting graph. Block"
4291                    f" is:\n{journalBlock}"
4292                )
4293
4294            here = exploration.currentPosition()
4295            now.tagDecision(here, tagsToApply)
4296
4297        else:
4298            raise NotImplementedError(
4299                f"Unhandled entry type '{eType}' (fix"
4300                f" updateExplorationFromEntry)."
4301            )
4302
4303        # Note: at this point, currentNote must be None. If there is an
4304        # end-of-line note, set up currentNote to apply that to whatever
4305        # is on this line.
4306        if note is not None:
4307            if eType in (
4308                'entrance',
4309                'exit',
4310                'blocked',
4311                'otherway',
4312                'unexplored',
4313                'unexploredOneway',
4314                'progress'
4315                'oneway',
4316                'hiddenOneway',
4317                'detour'
4318            ):
4319                # Annotate a specific transition
4320                target = (exploration.currentPosition(), content)
4321
4322            elif eType in (
4323                'pickup',
4324                'unclaimed',
4325                'action',
4326            ):
4327                # Action name might be auto-generated
4328                target = (
4329                    exploration.currentPosition(),
4330                    actionName
4331                )
4332
4333            else:
4334                # Default: annotate current room
4335                target = exploration.currentPosition()
4336
4337            # Set current note value for accumulation
4338            currentNote = (
4339                target,
4340                True, # all post-entry notes count as indented
4341                f"(step #{len(exploration)}) " + note
4342            )
4343
4344    # If we ended, return None
4345    if ended:
4346        return None
4347    elif exitRoom is None or exitTransition is None:
4348        raise JournalParseError(
4349            f"Missing exit room and/or transition ({exitRoom},"
4350            f" {exitTransition}) at end of journal"
4351            f" block:\n{journalBlock}"
4352        )
4353
4354    return exitRoom, exitTransition
JournalEntryType = typing.Literal['room', 'entrance', 'exit', 'blocked', 'unexplored', 'unexploredOneway', 'pickup', 'unclaimed', 'randomDrop', 'progress', 'frontier', 'frontierEnd', 'action', 'challenge', 'oops', 'oneway', 'hiddenOneway', 'otherway', 'detour', 'unify', 'obviate', 'warp', 'death', 'runback', 'traverse', 'ending', 'note', 'tag']

One of the types of entries that can be present in a journal. Each journal line is either an entry or a continuation of a previous entry. The available types are:

  • 'room': Used for room names & detour rooms.
  • 'entrance': Indicates an entrance (must come first in a room).
  • 'exit': Indicates an exit taken (must be last in a room).
  • 'blocked': Indicates a blocked route.
  • 'unexplored': Indicates an unexplored exit.
  • 'unexploredOneway': Indicates an unexplored exit which is known to be one-directional outgoing. Use the 'oneway' or 'hiddenOneway' markers instead for in-room one-way transitions, and use 'otherway' for one-directional entrances.
  • 'pickup': Indicates an item pickup.
  • 'unclaimed': Indicates an unclaimed but visible pickup.
  • 'randomDrop': Indicates an item picked up via a random drop, which isn't necessarily tied to the particular room where it occurred. TODO: This!
  • 'progress': Indicates progress within a room (engenders a sub-room). Also used before the room name in a block to indicate rooms passed through while retracing steps. The content is the name of the sub-room entered, listing an existing sub-room will create a new connection to that sub-room from the current sub-room if necessary. This marker can also be used as a sub-room name to refer to the default (unnamed) sub-room.
  • 'frontier': Indicates a new frontier has opened up, which creates a new unknown node tagged 'frontier' to represent that frontier and connects it to the current node, as well as creating a new known node tagged 'frontier' also connected to the current node. While a frontier is open in a certain room, every new sub-room created will be connected to both of these nodes. Any requirements or other transition properties specified when the frontier is defined will be copied to each of the created transitions. If a frontier has been closed, it can be re-opened.
  • 'frontierEnd': Indicates that a specific frontier is no longer open, which removes the frontier's unknown node and prevents further sub-rooms from being connected to it. If the frontier is later re-opened, a new unknown node will be generated and re-connected to each of the sub-rooms previously connected to that frontier; transitions to the re-opened unexplored node will copy transition properties specified when the frontier is reopened since their old transition properties will be lost, and these will also be used for new connections to the known frontier node. Old connections to the known node of the frontier will not be updated.
  • 'action': Indicates an action taken in a room, which does not create a sub-room. The effects of the action are not noted in the journal, but an accompanying ground-truth map would have them, and later journal entries may imply them.
  • 'challenge': Indicates a challenge of some sort. A entry tagged with 'failed' immediately afterwards indicates a challenge outcome.
  • 'oops': Indicates mistaken actions in rooms or transitions.
  • 'oneway': Indicates a one-way connection inside of a room, which we assume is visible as such from both sides. Also used at the end of a block for outgoing connections that are visibly one-way.
  • 'hiddenOneway': Indicates a one-way connection in a room that's not visible as such from the entry side. To mark a hidden one-way between rooms, simply use a normal exit marker and a one-way entrance marker in the next room.
  • 'otherway': Indicates incoming one-ways; also used as an entrance marker for the first entry in a block to denote that the entrance one just came through cannot be accessed in reverse. Whether this is expected or a surprise depends on the exit annotation for the preceding block.
  • 'detour': Indicates a detour (a quick visit to a one-entrance room that doesn't get a full entry), or a quick out-and-in for the current room via a particular exit.
  • 'unify': Indicates the realization that one's current position is actually a previously-known sub-room, with the effect of merging those two sub-rooms.
  • 'obviate': Indicates when a previously-unexplored between-room transition gets explored from the other side, without crossing the transition, or when a link back to a known sub-room is observed without actually crossing that link.
  • 'warp': Indicates a warp not due to a death. Again a particular room is named as the destination. Although the player moves, no connection is made in the graph, since it's assumed that this is a one-time move and/or a repeatable power where the start and/or destination are variable. If there's something like a teleporter with two fixed endpoints, just use a normal transition. On the other hand, if there's a multi-entrance/exit teleporter system, represent this using a room for "inside the system" that has multiple connections to each of the destinations throughout the graph.
  • 'death': Indicates a death taken. The content will specify which room the player ends up in (usually a save room); depends on the game and particular mechanics (like save-anywhere).
  • 'runback': Indicates that a detour to the named room was made, after which the player returned to the current location in the current room. The exact path taken is not specified; it is assumed that nothing of note happens along the way (if something did happen, the journal should include a room entry and event where it did, so a runback would not be used). This is used for things like running back to a save point before continuing exploration where you left off. TODO: Figure out rules for that path? TODO: What about e.g., running somewhere to flip a switch? We could allow a single-line anon-room-style action?
  • 'traverse': Indicates unspecified traversal through sub-rooms in the current room to an existing sub-room. TODO: Pathfinding rules for this?
  • 'ending': Indicates a game ending state was reached. Progress after this implies a save was loaded, and the assumption is hat there is no actual link between the rooms before and afterwards. This should only appear as the final entry of a journal block (excepting notes/tags). If exploration continues in the same room, a new block for that room should be made.
  • 'note': A comment. May also appear at the end of another entry.
  • 'tag': a tag or tags which will be added to the room or transition depending on which line they appear on. Tag a room or sub-room by putting tag delimiters around space-separated tag words as an entry in that room or sub-room, and tag transitions by including tag delimiters around tag words at the end of the line defining the transition.
JournalInfoType = typing.Literal['subroom', 'anonSep', 'unknownItem', 'tokenQuantity', 'requirement', 'reciprocalSeparator', 'transitionAtDecision']

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 a sub-room as part of a room name. The available values are:

  • 'subroom': present in a room name to indicate the rest of the name identifies a sub-room. Used to mark some connections as 'internal' even when the journal has separate entries.
  • 'anonSep': Used to join together a room base name and an exit name to form the name of an anonymous room.
  • '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).
  • 'tokenQuantity': This is used to separate a token name from a token quantity when defining items picked up. Note that the parsing for requirements is not as flexible, and always uses '' for this, so to avoid confusion it's preferable to leave this at ''.
  • 'requirement': used to indicate what is required when something is blocked or temporarily one-way, or when traversing a connection that would be blocked if not for the current game state.
  • '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.
JournalMarkerType = typing.Union[typing.Literal['room', 'entrance', 'exit', 'blocked', 'unexplored', 'unexploredOneway', 'pickup', 'unclaimed', 'randomDrop', 'progress', 'frontier', 'frontierEnd', 'action', 'challenge', 'oops', 'oneway', 'hiddenOneway', 'otherway', 'detour', 'unify', 'obviate', 'warp', 'death', 'runback', 'traverse', 'ending', 'note', 'tag'], typing.Literal['subroom', 'anonSep', 'unknownItem', 'tokenQuantity', 'requirement', 'reciprocalSeparator', 'transitionAtDecision']]

Any journal marker type.

DelimiterMarkerType = typing.Literal['room', 'requirement', 'tag']

The marker types which need delimiter marker values.

Format = typing.Dict[typing.Union[typing.Literal['room', 'entrance', 'exit', 'blocked', 'unexplored', 'unexploredOneway', 'pickup', 'unclaimed', 'randomDrop', 'progress', 'frontier', 'frontierEnd', 'action', 'challenge', 'oops', 'oneway', 'hiddenOneway', 'otherway', 'detour', 'unify', 'obviate', 'warp', 'death', 'runback', 'traverse', 'ending', 'note', 'tag'], typing.Literal['subroom', 'anonSep', 'unknownItem', 'tokenQuantity', 'requirement', 'reciprocalSeparator', 'transitionAtDecision']], str]

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

DEFAULT_FORMAT: Dict[Union[Literal['room', 'entrance', 'exit', 'blocked', 'unexplored', 'unexploredOneway', 'pickup', 'unclaimed', 'randomDrop', 'progress', 'frontier', 'frontierEnd', 'action', 'challenge', 'oops', 'oneway', 'hiddenOneway', 'otherway', 'detour', 'unify', 'obviate', 'warp', 'death', 'runback', 'traverse', 'ending', 'note', 'tag'], Literal['subroom', 'anonSep', 'unknownItem', 'tokenQuantity', 'requirement', 'reciprocalSeparator', 'transitionAtDecision']], str] = {'room': '[]', 'entrance': '<', 'exit': '>', 'oneway': '->', 'otherway': 'x<', 'blocked': 'x', 'unexplored': '?', 'unexploredOneway': '?>', 'detour': '><', 'unify': '`', 'obviate': '``', 'oops': '@', 'progress': '-', 'frontier': '--', 'frontierEnd': '-/', 'action': '*', 'hiddenOneway': '>>', 'challenge': '#', 'pickup': '.', 'unclaimed': ':', 'randomDrop': '$', 'warp': '~~', 'death': '!', 'runback': '...', 'traverse': '---', 'ending': '$$', 'note': '"', 'tag': '{}', 'subroom': '%', 'anonSep': '$', 'unknownItem': '?', 'tokenQuantity': '*', 'requirement': '()', 'reciprocalSeparator': '/', 'transitionAtDecision': ':'}

The default Format dictionary.

DELIMITERS = {'{}', '()', '[]'}

Marker values which are treated as delimiters.

class ParseFormat:
304class ParseFormat:
305    """
306    A ParseFormat manages the mapping from markers to entry types and
307    vice versa.
308    """
309    def __init__(self, formatDict: Format = DEFAULT_FORMAT):
310        """
311        Sets up the parsing format. Requires a `Format` dictionary to
312        define the specifics. Raises a `ValueError` unless the keys of
313        the `Format` dictionary exactly match the `JournalMarkerType`
314        values.
315        """
316        self.formatDict = formatDict
317
318        # Check that formatDict doesn't have any extra keys
319        markerTypes = get_args(JournalEntryType) + get_args(JournalInfoType)
320        for key in formatDict:
321            if key not in markerTypes:
322                raise ValueError(
323                    f"Format dict has key '{key}' which is not a"
324                    f" recognized entry or info type."
325                )
326
327        # Check completeness of formatDict
328        for mtype in markerTypes:
329            if mtype not in formatDict:
330                raise ValueError(
331                    f"Format dict is missing an entry for marker type"
332                    f" '{mtype}'."
333                )
334
335        # Check that delimiters are assigned appropriately:
336        needDelimeters = get_args(DelimiterMarkerType)
337        for needsDelimiter in needDelimeters:
338            if formatDict[needsDelimiter] not in DELIMITERS:
339                raise ValueError(
340                    f"Format dict entry for '{needsDelimiter}' must be"
341                    f" a delimiter ('[]', '()', or '{{}}')."
342                )
343
344        # Check for misplaced delimiters
345        for name in formatDict:
346            if (
347                name not in needDelimeters
348            and formatDict[name] in DELIMITERS
349            ):
350                raise ValueError(
351                    f"Format dict entry for '{name}' may not be a"
352                    f" delimiter ('[]', '()', or '{{}}')."
353                )
354
355        # Build reverse dictionary from markers to entry types (But
356        # exclude info types from this)
357        self.entryMap: Dict[str, JournalEntryType] = {}
358        entryTypes = set(get_args(JournalEntryType))
359
360        # Inspect each association
361        for name, fullMarker in formatDict.items():
362            if name not in entryTypes:
363                continue
364
365            # Marker is only the first char of a delimiter
366            if fullMarker in DELIMITERS:
367                marker = fullMarker[0]
368            else:
369                marker = fullMarker
370
371            # Duplicates not allowed
372            if marker in self.entryMap:
373                raise ValueError(
374                    f"Format dict entry for '{name}' duplicates"
375                    f" previous format dict entry for"
376                    f" '{self.entryMap[marker]}'."
377                )
378
379            # Map markers to entry types
380            self.entryMap[marker] = cast(JournalEntryType, name)
381
382        # These are used to avoid recompiling the RE for
383        # end-of-anonymous-room markers. See anonymousRoomEnd.
384        self.roomEnd = None
385        self.anonEndPattern = None
386
387    def markers(self) -> List[str]:
388        """
389        Returns the list of all entry-type markers (but not info
390        markers), sorted from longest to shortest to help avoid
391        ambiguities when matching. Note that '()', '[]', and '{}'
392        markers are interpreted as delimiters, and should only be used
393        for 'room', 'requirement', and/or 'tag' entries.
394        """
395        return sorted(
396            (
397                m if m not in DELIMITERS else m[0]
398                for (et, m) in self.formatDict.items()
399                if et in get_args(JournalEntryType)
400            ),
401            key=lambda m: -len(m)
402        )
403
404    def markerFor(self, markerType: JournalMarkerType) -> str:
405        """
406        Returns the marker for the specified entry or info type.
407        """
408        return self.formatDict[markerType]
409
410    def determineEntryType(self, entry: str) -> Tuple[JournalEntryType, str]:
411        """
412        Given a single line from a journal, returns a tuple containing
413        the entry type for that line, and a string containing the entry
414        content (which is just the line minus the entry-type-marker).
415        """
416        bits = entry.strip().split()
417        if bits[0] in self.entryMap:
418            eType = self.entryMap[bits[0]]
419            eContent = entry[len(bits[0]):].lstrip()
420        else:
421            first = bits[0]
422            prefix = None
423            # Try from longest to shortest to defeat ambiguity
424            for marker in self.markers():
425                if first.startswith(marker):
426                    prefix = marker
427                    eContent = entry[len(marker):]
428                    break
429
430            if prefix is None:
431                raise JournalParseError(
432                    f"Entry does not begin with a recognized entry"
433                    f" marker:\n{entry}"
434                )
435            else:
436                eType = self.entryMap[prefix]
437
438        if eType in get_args(DelimiterMarkerType):
439            # Punch out the closing delimiter from the middle of the
440            # content, since things like requirements or tags might be
441            # after it, and the rest of the code doesn't want to have to
442            # worry about them (we already removed the starting
443            # delimiter).
444            marker = self.formatDict[eType]
445            matching = eContent.find(marker[-1])
446            if matching > -1:
447                eContent = eContent[:matching] + eContent[matching + 1:]
448            else:
449                warnings.warn(
450                    (
451                        f"Delimiter-style marker '{marker}' is missing"
452                        f" closing part in entry:\n{entry}"
453                    ),
454                    JournalParseWarning
455                )
456
457        return eType, eContent
458
459    def parseSpecificTransition(
460        self,
461        content: str
462    ) -> Tuple[core.Decision, core.Transition]:
463        """
464        Splits a decision:transition pair to the decision and transition
465        part, using a custom separator if one is defined.
466        """
467        sep = self.formatDict['transitionAtDecision']
468        n = content.count(sep)
469        if n == 0:
470            raise JournalParseError(
471                f"Cannot split '{content}' into a decision name and a"
472                f" transition name (no separator '{sep}' found)."
473            )
474        elif n > 1:
475            raise JournalParseError(
476                f"Cannot split '{content}' into a decision name and a"
477                f" transition name (too many ({n}) '{sep}' separators"
478                f" found)."
479            )
480        else:
481            return cast(
482                Tuple[core.Decision, core.Transition],
483                tuple(content.split(sep))
484            )
485
486    def splitFinalNote(self, content: str) -> Tuple[str, Optional[str]]:
487        """
488        Given a string defining entry content, splits it into true
489        content and another string containing an annotation attached to
490        the end of the content. Any text after the 'note' marker on a
491        line is part of an annotation, rather than part of normal
492        content. If there is no 'note' marker on the line, then the
493        second element of the return value will be `None`. Any trailing
494        whitespace will be stripped from the content (but not the note).
495
496        A single space will be stripped from the beginning of the note
497        if there is one.
498        """
499        marker = self.formatDict['note']
500        if marker in content:
501            first = content.index(marker)
502            before = content[:first].rstrip()
503            after = content[first + 1:]
504            if after.startswith(' '):
505                after = after[1:]
506            return (before, after)
507        else:
508            return (content.rstrip(), None)
509
510    def splitDelimitedSuffix(
511        self,
512        content: str,
513        delimiters: str,
514    ) -> Tuple[str, Optional[str]]:
515        """
516        Given a string defining entry content, splits it into true
517        content and another string containing a part surrounded by the
518        specified delimiters (must be a length-2 string). The line must
519        end with the ending delimiter (after stripping whitespace) or
520        else the second part of the return value will be `None`.
521
522        If the delimiters argument is not a length-2 string or both
523        characters are the same, a `ValueError` will be raised. If
524        mismatched delimiters are encountered, a `JournalParseError` will
525        be raised.
526
527        Whitespace space inside the delimiters will be stripped, as will
528        whitespace at the end of the content if a delimited part is found.
529
530        Examples:
531
532        >>> from exploration import journal as j
533        >>> pf = j.ParseFormat()
534        >>> pf.splitDelimitedSuffix('abc (def)', '()')
535        ('abc', 'def')
536        >>> pf.splitDelimitedSuffix('abc def', '()')
537        ('abc def', None)
538        >>> pf.splitDelimitedSuffix('abc [def]', '()')
539        ('abc [def]', None)
540        >>> pf.splitDelimitedSuffix('abc [d(e)f]', '()')
541        ('abc [d(e)f]', None)
542        >>> pf.splitDelimitedSuffix(' abc d ( ef )', '()')
543        (' abc d', 'ef')
544        >>> pf.splitDelimitedSuffix(' abc d ( ef ) ', '[]')
545        (' abc d ( ef ) ', None)
546        >>> pf.splitDelimitedSuffix(' abc ((def))', '()')
547        (' abc', '(def)')
548        >>> pf.splitDelimitedSuffix(' (abc)', '()')
549        ('', 'abc')
550        >>> pf.splitDelimitedSuffix(' a(bc )def)', '()')
551        Traceback (most recent call last):
552        ...
553        exploration.journal.JournalParseError...
554        >>> pf.splitDelimitedSuffix(' abc def', 'd')
555        Traceback (most recent call last):
556        ...
557        ValueError...
558        >>> pf.splitDelimitedSuffix(' abc .def.', '..')
559        Traceback (most recent call last):
560        ...
561        ValueError...
562        """
563        if len(delimiters) != 2:
564            raise ValueError(
565                f"Delimiters must a length-2 string specifying a"
566                f" starting and ending delimiter (got"
567                f" {repr(delimiters)})."
568            )
569        begin = delimiters[0]
570        end = delimiters[1]
571        if begin == end:
572            raise ValueError(
573                f"Delimiters must be distinct (got {repr(delimiters)})."
574            )
575        if not content.rstrip().endswith(end) or begin not in content:
576            # No requirement present
577            return (content, None)
578        else:
579            # March back cancelling delimiters until we find the
580            # matching one
581            left = 1
582            findIn = content.rstrip()
583            for index in range(len(findIn) - 2, -1, -1):
584                if findIn[index] == end:
585                    left += 1
586                elif findIn[index] == begin:
587                    left -= 1
588                    if left == 0:
589                        break
590
591            if left > 0:
592                raise JournalParseError(
593                    f"Unmatched '{end}' in content:\n{content}"
594                )
595
596            return (content[:index].rstrip(), findIn[index + 1:-1].strip())
597
598    def splitDirections(
599        self,
600        content: str
601    ) -> Tuple[Optional[str], Optional[str]]:
602        """
603        Splits a piece of text using the 'reciprocalSeparator' into two
604        pieces. If there is no separator, the second piece will be
605        `None`; if either side of the separator is blank, that side will
606        be `None`, and if there is more than one separator, a
607        `JournalParseError` will be raised. Whitespace will be stripped
608        from both sides of each result.
609
610        Examples:
611
612        >>> pf = ParseFormat()
613        >>> pf.splitDirections('abc / def')
614        ('abc', 'def')
615        >>> pf.splitDirections('abc def ')
616        ('abc def', None)
617        >>> pf.splitDirections('abc def /')
618        ('abc def', None)
619        >>> pf.splitDirections('/abc def')
620        (None, 'abc def')
621        >>> pf.splitDirections('a/b/c') # doctest: +IGNORE_EXCEPTION_DETAIL
622        Traceback (most recent call last):
623          ...
624        JournalParseError: ...
625        """
626        sep = self.formatDict['reciprocalSeparator']
627        count = content.count(sep)
628        if count > 1:
629            raise JournalParseError(
630                f"Too many split points ('{sep}') in content:"
631                f" '{content}' (only one is allowed)."
632            )
633
634        elif count == 1:
635            before, after = content.split(sep)
636            before = before.strip()
637            after = after.strip()
638            return (before or None, after or None)
639
640        else: # no split points
641            stripped = content.strip()
642            if stripped:
643                return stripped, None
644            else:
645                return None, None
646
647    def splitRequirement(
648        self,
649        content: str
650    ) -> Tuple[str, Optional[core.Requirement], Optional[core.Requirement]]:
651        """
652        Splits a requirement suffix from main content, returning a
653        triple containing the main content and up to two requirements.
654        The first requirement is the forward-direction requirement, and
655        the second is the reverse-direction requirement. One or both may
656        be None to indicate that no requirement in that direction was
657        included. Raises a `JournalParseError` if something goes wrong
658        with the parsing.
659        """
660        main, req = self.splitDelimitedSuffix(
661            content,
662            self.formatDict['requirement']
663        )
664        print("SPR", main, req)
665
666        # If there wasn't any requirement:
667        if req is None:
668            return (main, None, None)
669
670        # Split into forward/reverse parts
671        fwd, rev = self.splitDirections(req)
672
673        try:
674            result = (
675                main,
676                core.Requirement.parse(fwd) if fwd is not None else None,
677                core.Requirement.parse(rev) if rev is not None else None
678            )
679        except ValueError as e:
680            raise JournalParseError(*e.args)
681
682        return result
683
684    def splitTags(self, content: str) -> Tuple[str, Set[str], Set[str]]:
685        """
686        Works like `splitRequirement` but for tags. The tags are split
687        into words and turned into a set, which will be empty if no tags
688        are present.
689        """
690        base, tags = self.splitDelimitedSuffix(
691            content,
692            self.formatDict['tag']
693        )
694        if tags is None:
695            return (base, set(), set())
696
697        # Split into forward/reverse parts
698        fwd, rev = self.splitDirections(tags)
699
700        return (
701            base,
702            set(fwd.split()) if fwd is not None else set(),
703            set(rev.split()) if rev is not None else set()
704        )
705
706    def startsAnonymousRoom(self, line: str) -> bool:
707        """
708        Returns true if the given line from a journal block starts a
709        multi-line anonymous room. Use `ParseFormat.anonymousRoomEnd` to
710        figure out where the end of the anonymous room is.
711        """
712        return line.rstrip().endswith(self.formatDict['room'][0])
713
714    def anonymousRoomEnd(self, block, startFrom):
715        """
716        Given a journal block (a multi-line string) and a starting index
717        that's somewhere inside a multi-line anonymous room, returns the
718        index within the entire journal block of the end of the room
719        (the ending delimiter character). That ending delimiter must
720        appear alone on a line.
721
722        Returns None if no ending marker can be found.
723        """
724        # Recompile our regex only if needed
725        if self.formatDict['room'][-1] != self.roomEnd:
726            self.roomEnd = self.formatDict['room'][-1]
727            self.anonEndPattern = re.compile(rf'^\s*{self.roomEnd}\s*$')
728
729        # Look for our end marker, alone on a line, with or without
730        # whitespace on either side:
731        nextEnd = self.anonEndPattern.search(block, startFrom)
732        if nextEnd is None:
733            return None
734
735        # Find the actual ending marker ignoring whitespace that might
736        # have been part of the match
737        return block.index(self.roomEnd, nextEnd.start())
738
739    def splitAnonymousRoom(
740        self,
741        content: str
742    ) -> Tuple[str, Union[str, None]]:
743        """
744        Works like `splitRequirement` but for anonymous rooms. If an
745        anonymous room is present, the second element of the result will
746        be a one-line string containing room content, which in theory
747        should be a single event (multiple events would require a
748        multi-line room, which is handled by
749        `ParseFormat.startsAnonymousRoom` and
750        `ParseFormat.anonymousRoomEnd`). If the anonymous room is the
751        only thing on the line, it won't be counted, since that's a
752        normal room name.
753        """
754        leftovers, anonRoom = self.splitDelimitedSuffix(
755            content,
756            self.formatDict['room']
757        )
758        if not leftovers.strip():
759            # Return original content: an anonymous room cannot be the
760            # only thing on a line (that's a room label).
761            return (content, None)
762        else:
763            return (leftovers, anonRoom)
764
765    def subRoomName(
766        self,
767        roomName: core.Decision,
768        subName: core.Decision
769    ) -> core.Decision:
770        """
771        Returns a new room name that includes the provided sub-name to
772        distinguish it from other parts of the same room. If the subName
773        matches the progress marker for this parse format, then just the
774        base name is returned.
775
776        Examples:
777
778        >>> fmt = ParseFormat()
779        >>> fmt.subRoomName('a', 'b')
780        'a%b'
781        >>> fmt.subRoomName('a%b', 'c')
782        'a%b%c'
783        >>> fmt.formatDict['progress'] == '-'
784        True
785        >>> fmt.subRoomName('a', '-')
786        'a'
787        """
788        if subName == self.formatDict['progress']:
789            return roomName
790        else:
791            return roomName + self.formatDict['subroom'] + subName
792
793    def baseRoomName(self, fullName: core.Decision) -> core.Decision:
794        """
795        Returns the base room name for a room name that may contain
796        one or more sub-room part(s).
797
798        >>> fmt = ParseFormat()
799        >>> fmt.baseRoomName('a%b%c')
800        'a'
801        >>> fmt.baseRoomName('a')
802        'a'
803        """
804        marker = self.formatDict['subroom']
805        if marker in fullName:
806            return fullName[:fullName.index(marker)]
807        else:
808            return fullName
809
810    def roomPartName(
811        self,
812        fullName: core.Decision
813    ) -> Optional[core.Decision]:
814        """
815        Returns the room part name for a room name that may contain
816        one or more sub-room part(s). If multiple sub-name parts are
817        present, they all included together in one string. Returns None
818        if there is no room part name included in the given full name.
819
820        Example:
821
822        >>> fmt = ParseFormat()
823        >>> fmt.roomPartName('a%b')
824        'b'
825        >>> fmt.roomPartName('a%b%c')
826        'b%c'
827        >>> fmt.roomPartName('a%')
828        ''
829        >>> fmt.roomPartName('a')
830        None
831        """
832        marker = self.formatDict['subroom']
833        if marker in fullName:
834            return fullName[fullName.index(marker) + 1:]
835        else:
836            return None
837
838    def roomMinusPart(
839        self,
840        fullName: core.Decision,
841        partName: core.Decision
842    ) -> core.Decision:
843        """
844        Returns the room name, minus the specified sub-room indicator.
845        Raises a `JournalParseError` if the full room name does not end
846        in the given sub-room indicator.
847        Examples:
848
849        >>> fmt = ParseFormat()
850        >>> fmt.roomMinusPart('a%b', 'b')
851        'a'
852        >>> fmt.roomMinusPart('a%b%c', 'c')
853        'a%b'
854        >>> fmt.roomMinusPart('a%b%c', 'b') # doctest: +IGNORE_EXCEPTION_DETAIL
855        Traceback (most recent call last):
856          ...
857        JournalParseError: ...
858        """
859        marker = self.formatDict['subroom']
860        if not fullName.endswith(marker + partName):
861            raise JournalParseError(
862                f"Cannot remove sub-room part '{partName}' from room"
863                f" '{fullName}' because it does not end with that part."
864            )
865
866        return fullName[:-(len(partName) + 1)]
867
868    def allSubRooms(
869        self,
870        graph: core.DecisionGraph,
871        roomName: core.Decision
872    ) -> Set[core.Decision]:
873        """
874        The journal format organizes decisions into groups called
875        "rooms" within which "sub-rooms" indicate specific parts where a
876        decision is needed. This function returns a set of
877        `core.Decision`s naming each decision that's part of the named
878        room in the given graph. If the name contains a sub-room part,
879        that part is ignored. The parse format is used to understand how
880        sub-rooms are named. Note that unknown nodes will NOT be
881        included, even if the connections to them are tagged with
882        'internal', which is used to tag within-room transitions.
883
884        Note that this function requires checking each room in the entire
885        graph, since there could be disconnected components of a room.
886        """
887        base = self.baseRoomName(roomName)
888        return {
889            node
890            for node in graph.nodes
891            if self.baseRoomName(node) == base
892            and not graph.isUnknown(node)
893        }
894
895    def getEntranceDestination(
896        self,
897        graph: core.DecisionGraph,
898        room: core.Decision,
899        entrance: core.Transition
900    ) -> Optional[core.Decision]:
901        """
902        Given a graph and a room being entered, as well as the entrance
903        name in that room (i.e., the name of the reciprocal of the
904        transition being used to enter the room), returns the name of
905        the specific sub-room being entered, based on the known site for
906        that entrance, or returns None if that entrance hasn't been used
907        in any sub-room of the specified room. If the room has a
908        sub-room part in it, that will be ignored.
909
910        Before searching the entire graph, we do check whether the given
911        transition exists in the target (possibly sub-) room.
912        """
913        easy = graph.getDestination(room, entrance)
914        if easy is not None:
915            return easy
916
917        check = self.allSubRooms(graph, room)
918        for sub in check:
919            hope = graph.getDestination(sub, entrance)
920            if hope is not None:
921                return hope
922
923        return None
924
925    def getSubRoom(
926        self,
927        graph: core.DecisionGraph,
928        roomName: core.Decision,
929        subPart: core.Decision
930    ) -> Optional[core.Decision]:
931        """
932        Given a graph and a room name, plus a sub-room name, returns the
933        name of the existing sub-room that's part of the same room as
934        the target room but has the specified sub-room name part.
935        Returns None if no such room has been defined already.
936        """
937        base = self.baseRoomName(roomName)
938        lookingFor = self.subRoomName(base, subPart)
939        if lookingFor in graph:
940            return lookingFor
941        else:
942            return None
943
944    def parseItem(
945        self,
946        item: str
947    ) -> Union[core.Power, Tuple[core.Token, int]]:
948        """
949        Parses an item, which is either a power (just a string) or a
950        token-type:number pair (returned as a tuple with the number
951        converted to an integer). The 'tokenQuantity' format value
952        determines the separator which indicates a token instead of a
953        power.
954        """
955        sep = self.formatDict['tokenQuantity']
956        if sep in item:
957            # It's a token w/ an associated count
958            parts = item.split(sep)
959            if len(parts) != 2:
960                raise JournalParseError(
961                    f"Item '{item}' has a '{sep}' but doesn't separate"
962                    f" into a token type and a count."
963                )
964            typ, count = parts
965            try:
966                num = int(count)
967            except ValueError:
968                raise JournalParseError(
969                    f"Item '{item}' has invalid token count '{count}'."
970                )
971
972            return (typ, num)
973        else:
974            # It's just a power
975            return item
976
977    def anonName(self, room: core.Decision, exit: core.Transition):
978        """
979        Returns the anonymous room name for an anonymous room that's
980        connected to the specified room via the specified transition.
981        Example:
982
983        >>> pf = ParseFormat()
984        >>> pf.anonName('MidHall', 'Bottom')
985        'MidHall$Bottom'
986        """
987        return room + self.formatDict['anonSep'] + exit

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

ParseFormat( formatDict: Dict[Union[Literal['room', 'entrance', 'exit', 'blocked', 'unexplored', 'unexploredOneway', 'pickup', 'unclaimed', 'randomDrop', 'progress', 'frontier', 'frontierEnd', 'action', 'challenge', 'oops', 'oneway', 'hiddenOneway', 'otherway', 'detour', 'unify', 'obviate', 'warp', 'death', 'runback', 'traverse', 'ending', 'note', 'tag'], Literal['subroom', 'anonSep', 'unknownItem', 'tokenQuantity', 'requirement', 'reciprocalSeparator', 'transitionAtDecision']], str] = {'room': '[]', 'entrance': '<', 'exit': '>', 'oneway': '->', 'otherway': 'x<', 'blocked': 'x', 'unexplored': '?', 'unexploredOneway': '?>', 'detour': '><', 'unify': '`', 'obviate': '``', 'oops': '@', 'progress': '-', 'frontier': '--', 'frontierEnd': '-/', 'action': '*', 'hiddenOneway': '>>', 'challenge': '#', 'pickup': '.', 'unclaimed': ':', 'randomDrop': '$', 'warp': '~~', 'death': '!', 'runback': '...', 'traverse': '---', 'ending': '$$', 'note': '"', 'tag': '{}', 'subroom': '%', 'anonSep': '$', 'unknownItem': '?', 'tokenQuantity': '*', 'requirement': '()', 'reciprocalSeparator': '/', 'transitionAtDecision': ':'})
309    def __init__(self, formatDict: Format = DEFAULT_FORMAT):
310        """
311        Sets up the parsing format. Requires a `Format` dictionary to
312        define the specifics. Raises a `ValueError` unless the keys of
313        the `Format` dictionary exactly match the `JournalMarkerType`
314        values.
315        """
316        self.formatDict = formatDict
317
318        # Check that formatDict doesn't have any extra keys
319        markerTypes = get_args(JournalEntryType) + get_args(JournalInfoType)
320        for key in formatDict:
321            if key not in markerTypes:
322                raise ValueError(
323                    f"Format dict has key '{key}' which is not a"
324                    f" recognized entry or info type."
325                )
326
327        # Check completeness of formatDict
328        for mtype in markerTypes:
329            if mtype not in formatDict:
330                raise ValueError(
331                    f"Format dict is missing an entry for marker type"
332                    f" '{mtype}'."
333                )
334
335        # Check that delimiters are assigned appropriately:
336        needDelimeters = get_args(DelimiterMarkerType)
337        for needsDelimiter in needDelimeters:
338            if formatDict[needsDelimiter] not in DELIMITERS:
339                raise ValueError(
340                    f"Format dict entry for '{needsDelimiter}' must be"
341                    f" a delimiter ('[]', '()', or '{{}}')."
342                )
343
344        # Check for misplaced delimiters
345        for name in formatDict:
346            if (
347                name not in needDelimeters
348            and formatDict[name] in DELIMITERS
349            ):
350                raise ValueError(
351                    f"Format dict entry for '{name}' may not be a"
352                    f" delimiter ('[]', '()', or '{{}}')."
353                )
354
355        # Build reverse dictionary from markers to entry types (But
356        # exclude info types from this)
357        self.entryMap: Dict[str, JournalEntryType] = {}
358        entryTypes = set(get_args(JournalEntryType))
359
360        # Inspect each association
361        for name, fullMarker in formatDict.items():
362            if name not in entryTypes:
363                continue
364
365            # Marker is only the first char of a delimiter
366            if fullMarker in DELIMITERS:
367                marker = fullMarker[0]
368            else:
369                marker = fullMarker
370
371            # Duplicates not allowed
372            if marker in self.entryMap:
373                raise ValueError(
374                    f"Format dict entry for '{name}' duplicates"
375                    f" previous format dict entry for"
376                    f" '{self.entryMap[marker]}'."
377                )
378
379            # Map markers to entry types
380            self.entryMap[marker] = cast(JournalEntryType, name)
381
382        # These are used to avoid recompiling the RE for
383        # end-of-anonymous-room markers. See anonymousRoomEnd.
384        self.roomEnd = None
385        self.anonEndPattern = None

Sets up the parsing format. Requires a Format dictionary to define the specifics. Raises a ValueError unless the keys of the Format dictionary exactly match the JournalMarkerType values.

def markers(self) -> List[str]:
387    def markers(self) -> List[str]:
388        """
389        Returns the list of all entry-type markers (but not info
390        markers), sorted from longest to shortest to help avoid
391        ambiguities when matching. Note that '()', '[]', and '{}'
392        markers are interpreted as delimiters, and should only be used
393        for 'room', 'requirement', and/or 'tag' entries.
394        """
395        return sorted(
396            (
397                m if m not in DELIMITERS else m[0]
398                for (et, m) in self.formatDict.items()
399                if et in get_args(JournalEntryType)
400            ),
401            key=lambda m: -len(m)
402        )

Returns the list of all entry-type markers (but not info markers), sorted from longest to shortest to help avoid ambiguities when matching. Note that '()', '[]', and '{}' markers are interpreted as delimiters, and should only be used for 'room', 'requirement', and/or 'tag' entries.

def markerFor( self, markerType: Union[Literal['room', 'entrance', 'exit', 'blocked', 'unexplored', 'unexploredOneway', 'pickup', 'unclaimed', 'randomDrop', 'progress', 'frontier', 'frontierEnd', 'action', 'challenge', 'oops', 'oneway', 'hiddenOneway', 'otherway', 'detour', 'unify', 'obviate', 'warp', 'death', 'runback', 'traverse', 'ending', 'note', 'tag'], Literal['subroom', 'anonSep', 'unknownItem', 'tokenQuantity', 'requirement', 'reciprocalSeparator', 'transitionAtDecision']]) -> str:
404    def markerFor(self, markerType: JournalMarkerType) -> str:
405        """
406        Returns the marker for the specified entry or info type.
407        """
408        return self.formatDict[markerType]

Returns the marker for the specified entry or info type.

def determineEntryType( self, entry: str) -> Tuple[Literal['room', 'entrance', 'exit', 'blocked', 'unexplored', 'unexploredOneway', 'pickup', 'unclaimed', 'randomDrop', 'progress', 'frontier', 'frontierEnd', 'action', 'challenge', 'oops', 'oneway', 'hiddenOneway', 'otherway', 'detour', 'unify', 'obviate', 'warp', 'death', 'runback', 'traverse', 'ending', 'note', 'tag'], str]:
410    def determineEntryType(self, entry: str) -> Tuple[JournalEntryType, str]:
411        """
412        Given a single line from a journal, returns a tuple containing
413        the entry type for that line, and a string containing the entry
414        content (which is just the line minus the entry-type-marker).
415        """
416        bits = entry.strip().split()
417        if bits[0] in self.entryMap:
418            eType = self.entryMap[bits[0]]
419            eContent = entry[len(bits[0]):].lstrip()
420        else:
421            first = bits[0]
422            prefix = None
423            # Try from longest to shortest to defeat ambiguity
424            for marker in self.markers():
425                if first.startswith(marker):
426                    prefix = marker
427                    eContent = entry[len(marker):]
428                    break
429
430            if prefix is None:
431                raise JournalParseError(
432                    f"Entry does not begin with a recognized entry"
433                    f" marker:\n{entry}"
434                )
435            else:
436                eType = self.entryMap[prefix]
437
438        if eType in get_args(DelimiterMarkerType):
439            # Punch out the closing delimiter from the middle of the
440            # content, since things like requirements or tags might be
441            # after it, and the rest of the code doesn't want to have to
442            # worry about them (we already removed the starting
443            # delimiter).
444            marker = self.formatDict[eType]
445            matching = eContent.find(marker[-1])
446            if matching > -1:
447                eContent = eContent[:matching] + eContent[matching + 1:]
448            else:
449                warnings.warn(
450                    (
451                        f"Delimiter-style marker '{marker}' is missing"
452                        f" closing part in entry:\n{entry}"
453                    ),
454                    JournalParseWarning
455                )
456
457        return eType, eContent

Given a single line from a journal, returns a tuple containing the entry type for that line, and a string containing the entry content (which is just the line minus the entry-type-marker).

def parseSpecificTransition(self, content: str) -> Tuple[str, str]:
459    def parseSpecificTransition(
460        self,
461        content: str
462    ) -> Tuple[core.Decision, core.Transition]:
463        """
464        Splits a decision:transition pair to the decision and transition
465        part, using a custom separator if one is defined.
466        """
467        sep = self.formatDict['transitionAtDecision']
468        n = content.count(sep)
469        if n == 0:
470            raise JournalParseError(
471                f"Cannot split '{content}' into a decision name and a"
472                f" transition name (no separator '{sep}' found)."
473            )
474        elif n > 1:
475            raise JournalParseError(
476                f"Cannot split '{content}' into a decision name and a"
477                f" transition name (too many ({n}) '{sep}' separators"
478                f" found)."
479            )
480        else:
481            return cast(
482                Tuple[core.Decision, core.Transition],
483                tuple(content.split(sep))
484            )

Splits a decision:transition pair to the decision and transition part, using a custom separator if one is defined.

def splitFinalNote(self, content: str) -> Tuple[str, Optional[str]]:
486    def splitFinalNote(self, content: str) -> Tuple[str, Optional[str]]:
487        """
488        Given a string defining entry content, splits it into true
489        content and another string containing an annotation attached to
490        the end of the content. Any text after the 'note' marker on a
491        line is part of an annotation, rather than part of normal
492        content. If there is no 'note' marker on the line, then the
493        second element of the return value will be `None`. Any trailing
494        whitespace will be stripped from the content (but not the note).
495
496        A single space will be stripped from the beginning of the note
497        if there is one.
498        """
499        marker = self.formatDict['note']
500        if marker in content:
501            first = content.index(marker)
502            before = content[:first].rstrip()
503            after = content[first + 1:]
504            if after.startswith(' '):
505                after = after[1:]
506            return (before, after)
507        else:
508            return (content.rstrip(), None)

Given a string defining entry content, splits it into true content and another string containing an annotation attached to the end of the content. Any text after the 'note' marker on a line is part of an annotation, rather than part of normal content. If there is no 'note' marker on the line, then the second element of the return value will be None. Any trailing whitespace will be stripped from the content (but not the note).

A single space will be stripped from the beginning of the note if there is one.

def splitDelimitedSuffix(self, content: str, delimiters: str) -> Tuple[str, Optional[str]]:
510    def splitDelimitedSuffix(
511        self,
512        content: str,
513        delimiters: str,
514    ) -> Tuple[str, Optional[str]]:
515        """
516        Given a string defining entry content, splits it into true
517        content and another string containing a part surrounded by the
518        specified delimiters (must be a length-2 string). The line must
519        end with the ending delimiter (after stripping whitespace) or
520        else the second part of the return value will be `None`.
521
522        If the delimiters argument is not a length-2 string or both
523        characters are the same, a `ValueError` will be raised. If
524        mismatched delimiters are encountered, a `JournalParseError` will
525        be raised.
526
527        Whitespace space inside the delimiters will be stripped, as will
528        whitespace at the end of the content if a delimited part is found.
529
530        Examples:
531
532        >>> from exploration import journal as j
533        >>> pf = j.ParseFormat()
534        >>> pf.splitDelimitedSuffix('abc (def)', '()')
535        ('abc', 'def')
536        >>> pf.splitDelimitedSuffix('abc def', '()')
537        ('abc def', None)
538        >>> pf.splitDelimitedSuffix('abc [def]', '()')
539        ('abc [def]', None)
540        >>> pf.splitDelimitedSuffix('abc [d(e)f]', '()')
541        ('abc [d(e)f]', None)
542        >>> pf.splitDelimitedSuffix(' abc d ( ef )', '()')
543        (' abc d', 'ef')
544        >>> pf.splitDelimitedSuffix(' abc d ( ef ) ', '[]')
545        (' abc d ( ef ) ', None)
546        >>> pf.splitDelimitedSuffix(' abc ((def))', '()')
547        (' abc', '(def)')
548        >>> pf.splitDelimitedSuffix(' (abc)', '()')
549        ('', 'abc')
550        >>> pf.splitDelimitedSuffix(' a(bc )def)', '()')
551        Traceback (most recent call last):
552        ...
553        exploration.journal.JournalParseError...
554        >>> pf.splitDelimitedSuffix(' abc def', 'd')
555        Traceback (most recent call last):
556        ...
557        ValueError...
558        >>> pf.splitDelimitedSuffix(' abc .def.', '..')
559        Traceback (most recent call last):
560        ...
561        ValueError...
562        """
563        if len(delimiters) != 2:
564            raise ValueError(
565                f"Delimiters must a length-2 string specifying a"
566                f" starting and ending delimiter (got"
567                f" {repr(delimiters)})."
568            )
569        begin = delimiters[0]
570        end = delimiters[1]
571        if begin == end:
572            raise ValueError(
573                f"Delimiters must be distinct (got {repr(delimiters)})."
574            )
575        if not content.rstrip().endswith(end) or begin not in content:
576            # No requirement present
577            return (content, None)
578        else:
579            # March back cancelling delimiters until we find the
580            # matching one
581            left = 1
582            findIn = content.rstrip()
583            for index in range(len(findIn) - 2, -1, -1):
584                if findIn[index] == end:
585                    left += 1
586                elif findIn[index] == begin:
587                    left -= 1
588                    if left == 0:
589                        break
590
591            if left > 0:
592                raise JournalParseError(
593                    f"Unmatched '{end}' in content:\n{content}"
594                )
595
596            return (content[:index].rstrip(), findIn[index + 1:-1].strip())

Given a string defining entry content, splits it into true content and another string containing a part surrounded by the specified delimiters (must be a length-2 string). The line must end with the ending delimiter (after stripping whitespace) or else the second part of the return value will be None.

If the delimiters argument is not a length-2 string or both characters are the same, a ValueError will be raised. If mismatched delimiters are encountered, a JournalParseError will be raised.

Whitespace space inside the delimiters will be stripped, as will whitespace at the end of the content if a delimited part is found.

Examples:

>>> from exploration import journal as j
>>> pf = j.ParseFormat()
>>> pf.splitDelimitedSuffix('abc (def)', '()')
('abc', 'def')
>>> pf.splitDelimitedSuffix('abc def', '()')
('abc def', None)
>>> pf.splitDelimitedSuffix('abc [def]', '()')
('abc [def]', None)
>>> pf.splitDelimitedSuffix('abc [d(e)f]', '()')
('abc [d(e)f]', None)
>>> pf.splitDelimitedSuffix(' abc d ( ef )', '()')
(' abc d', 'ef')
>>> pf.splitDelimitedSuffix(' abc d ( ef ) ', '[]')
(' abc d ( ef ) ', None)
>>> pf.splitDelimitedSuffix(' abc ((def))', '()')
(' abc', '(def)')
>>> pf.splitDelimitedSuffix(' (abc)', '()')
('', 'abc')
>>> pf.splitDelimitedSuffix(' a(bc )def)', '()')
Traceback (most recent call last):
...
exploration.journal.JournalParseError...
>>> pf.splitDelimitedSuffix(' abc def', 'd')
Traceback (most recent call last):
...
ValueError...
>>> pf.splitDelimitedSuffix(' abc .def.', '..')
Traceback (most recent call last):
...
ValueError...
def splitDirections(self, content: str) -> Tuple[Optional[str], Optional[str]]:
598    def splitDirections(
599        self,
600        content: str
601    ) -> Tuple[Optional[str], Optional[str]]:
602        """
603        Splits a piece of text using the 'reciprocalSeparator' into two
604        pieces. If there is no separator, the second piece will be
605        `None`; if either side of the separator is blank, that side will
606        be `None`, and if there is more than one separator, a
607        `JournalParseError` will be raised. Whitespace will be stripped
608        from both sides of each result.
609
610        Examples:
611
612        >>> pf = ParseFormat()
613        >>> pf.splitDirections('abc / def')
614        ('abc', 'def')
615        >>> pf.splitDirections('abc def ')
616        ('abc def', None)
617        >>> pf.splitDirections('abc def /')
618        ('abc def', None)
619        >>> pf.splitDirections('/abc def')
620        (None, 'abc def')
621        >>> pf.splitDirections('a/b/c') # doctest: +IGNORE_EXCEPTION_DETAIL
622        Traceback (most recent call last):
623          ...
624        JournalParseError: ...
625        """
626        sep = self.formatDict['reciprocalSeparator']
627        count = content.count(sep)
628        if count > 1:
629            raise JournalParseError(
630                f"Too many split points ('{sep}') in content:"
631                f" '{content}' (only one is allowed)."
632            )
633
634        elif count == 1:
635            before, after = content.split(sep)
636            before = before.strip()
637            after = after.strip()
638            return (before or None, after or None)
639
640        else: # no split points
641            stripped = content.strip()
642            if stripped:
643                return stripped, None
644            else:
645                return None, None

Splits a piece of text using the 'reciprocalSeparator' into two pieces. If there is no separator, the second piece will be None; if either side of the separator is blank, that side will be None, and if there is more than one separator, a JournalParseError will be raised. Whitespace will be stripped from both sides of each result.

Examples:

>>> pf = ParseFormat()
>>> pf.splitDirections('abc / def')
('abc', 'def')
>>> pf.splitDirections('abc def ')
('abc def', None)
>>> pf.splitDirections('abc def /')
('abc def', None)
>>> pf.splitDirections('/abc def')
(None, 'abc def')
>>> pf.splitDirections('a/b/c') # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
  ...
JournalParseError: ...
def splitRequirement( self, content: str) -> Tuple[str, Optional[exploration.core.Requirement], Optional[exploration.core.Requirement]]:
647    def splitRequirement(
648        self,
649        content: str
650    ) -> Tuple[str, Optional[core.Requirement], Optional[core.Requirement]]:
651        """
652        Splits a requirement suffix from main content, returning a
653        triple containing the main content and up to two requirements.
654        The first requirement is the forward-direction requirement, and
655        the second is the reverse-direction requirement. One or both may
656        be None to indicate that no requirement in that direction was
657        included. Raises a `JournalParseError` if something goes wrong
658        with the parsing.
659        """
660        main, req = self.splitDelimitedSuffix(
661            content,
662            self.formatDict['requirement']
663        )
664        print("SPR", main, req)
665
666        # If there wasn't any requirement:
667        if req is None:
668            return (main, None, None)
669
670        # Split into forward/reverse parts
671        fwd, rev = self.splitDirections(req)
672
673        try:
674            result = (
675                main,
676                core.Requirement.parse(fwd) if fwd is not None else None,
677                core.Requirement.parse(rev) if rev is not None else None
678            )
679        except ValueError as e:
680            raise JournalParseError(*e.args)
681
682        return result

Splits a requirement suffix from main content, returning a triple containing the main content and up to two requirements. The first requirement is the forward-direction requirement, and the second is the reverse-direction requirement. One or both may be None to indicate that no requirement in that direction was included. Raises a JournalParseError if something goes wrong with the parsing.

def splitTags(self, content: str) -> Tuple[str, Set[str], Set[str]]:
684    def splitTags(self, content: str) -> Tuple[str, Set[str], Set[str]]:
685        """
686        Works like `splitRequirement` but for tags. The tags are split
687        into words and turned into a set, which will be empty if no tags
688        are present.
689        """
690        base, tags = self.splitDelimitedSuffix(
691            content,
692            self.formatDict['tag']
693        )
694        if tags is None:
695            return (base, set(), set())
696
697        # Split into forward/reverse parts
698        fwd, rev = self.splitDirections(tags)
699
700        return (
701            base,
702            set(fwd.split()) if fwd is not None else set(),
703            set(rev.split()) if rev is not None else set()
704        )

Works like splitRequirement but for tags. The tags are split into words and turned into a set, which will be empty if no tags are present.

def startsAnonymousRoom(self, line: str) -> bool:
706    def startsAnonymousRoom(self, line: str) -> bool:
707        """
708        Returns true if the given line from a journal block starts a
709        multi-line anonymous room. Use `ParseFormat.anonymousRoomEnd` to
710        figure out where the end of the anonymous room is.
711        """
712        return line.rstrip().endswith(self.formatDict['room'][0])

Returns true if the given line from a journal block starts a multi-line anonymous room. Use ParseFormat.anonymousRoomEnd to figure out where the end of the anonymous room is.

def anonymousRoomEnd(self, block, startFrom)
714    def anonymousRoomEnd(self, block, startFrom):
715        """
716        Given a journal block (a multi-line string) and a starting index
717        that's somewhere inside a multi-line anonymous room, returns the
718        index within the entire journal block of the end of the room
719        (the ending delimiter character). That ending delimiter must
720        appear alone on a line.
721
722        Returns None if no ending marker can be found.
723        """
724        # Recompile our regex only if needed
725        if self.formatDict['room'][-1] != self.roomEnd:
726            self.roomEnd = self.formatDict['room'][-1]
727            self.anonEndPattern = re.compile(rf'^\s*{self.roomEnd}\s*$')
728
729        # Look for our end marker, alone on a line, with or without
730        # whitespace on either side:
731        nextEnd = self.anonEndPattern.search(block, startFrom)
732        if nextEnd is None:
733            return None
734
735        # Find the actual ending marker ignoring whitespace that might
736        # have been part of the match
737        return block.index(self.roomEnd, nextEnd.start())

Given a journal block (a multi-line string) and a starting index that's somewhere inside a multi-line anonymous room, returns the index within the entire journal block of the end of the room (the ending delimiter character). That ending delimiter must appear alone on a line.

Returns None if no ending marker can be found.

def splitAnonymousRoom(self, content: str) -> Tuple[str, Optional[str]]:
739    def splitAnonymousRoom(
740        self,
741        content: str
742    ) -> Tuple[str, Union[str, None]]:
743        """
744        Works like `splitRequirement` but for anonymous rooms. If an
745        anonymous room is present, the second element of the result will
746        be a one-line string containing room content, which in theory
747        should be a single event (multiple events would require a
748        multi-line room, which is handled by
749        `ParseFormat.startsAnonymousRoom` and
750        `ParseFormat.anonymousRoomEnd`). If the anonymous room is the
751        only thing on the line, it won't be counted, since that's a
752        normal room name.
753        """
754        leftovers, anonRoom = self.splitDelimitedSuffix(
755            content,
756            self.formatDict['room']
757        )
758        if not leftovers.strip():
759            # Return original content: an anonymous room cannot be the
760            # only thing on a line (that's a room label).
761            return (content, None)
762        else:
763            return (leftovers, anonRoom)

Works like splitRequirement but for anonymous rooms. If an anonymous room is present, the second element of the result will be a one-line string containing room content, which in theory should be a single event (multiple events would require a multi-line room, which is handled by ParseFormat.startsAnonymousRoom and ParseFormat.anonymousRoomEnd). If the anonymous room is the only thing on the line, it won't be counted, since that's a normal room name.

def subRoomName(self, roomName: str, subName: str) -> str:
765    def subRoomName(
766        self,
767        roomName: core.Decision,
768        subName: core.Decision
769    ) -> core.Decision:
770        """
771        Returns a new room name that includes the provided sub-name to
772        distinguish it from other parts of the same room. If the subName
773        matches the progress marker for this parse format, then just the
774        base name is returned.
775
776        Examples:
777
778        >>> fmt = ParseFormat()
779        >>> fmt.subRoomName('a', 'b')
780        'a%b'
781        >>> fmt.subRoomName('a%b', 'c')
782        'a%b%c'
783        >>> fmt.formatDict['progress'] == '-'
784        True
785        >>> fmt.subRoomName('a', '-')
786        'a'
787        """
788        if subName == self.formatDict['progress']:
789            return roomName
790        else:
791            return roomName + self.formatDict['subroom'] + subName

Returns a new room name that includes the provided sub-name to distinguish it from other parts of the same room. If the subName matches the progress marker for this parse format, then just the base name is returned.

Examples:

>>> fmt = ParseFormat()
>>> fmt.subRoomName('a', 'b')
'a%b'
>>> fmt.subRoomName('a%b', 'c')
'a%b%c'
>>> fmt.formatDict['progress'] == '-'
True
>>> fmt.subRoomName('a', '-')
'a'
def baseRoomName(self, fullName: str) -> str:
793    def baseRoomName(self, fullName: core.Decision) -> core.Decision:
794        """
795        Returns the base room name for a room name that may contain
796        one or more sub-room part(s).
797
798        >>> fmt = ParseFormat()
799        >>> fmt.baseRoomName('a%b%c')
800        'a'
801        >>> fmt.baseRoomName('a')
802        'a'
803        """
804        marker = self.formatDict['subroom']
805        if marker in fullName:
806            return fullName[:fullName.index(marker)]
807        else:
808            return fullName

Returns the base room name for a room name that may contain one or more sub-room part(s).

>>> fmt = ParseFormat()
>>> fmt.baseRoomName('a%b%c')
'a'
>>> fmt.baseRoomName('a')
'a'
def roomPartName(self, fullName: str) -> Optional[str]:
810    def roomPartName(
811        self,
812        fullName: core.Decision
813    ) -> Optional[core.Decision]:
814        """
815        Returns the room part name for a room name that may contain
816        one or more sub-room part(s). If multiple sub-name parts are
817        present, they all included together in one string. Returns None
818        if there is no room part name included in the given full name.
819
820        Example:
821
822        >>> fmt = ParseFormat()
823        >>> fmt.roomPartName('a%b')
824        'b'
825        >>> fmt.roomPartName('a%b%c')
826        'b%c'
827        >>> fmt.roomPartName('a%')
828        ''
829        >>> fmt.roomPartName('a')
830        None
831        """
832        marker = self.formatDict['subroom']
833        if marker in fullName:
834            return fullName[fullName.index(marker) + 1:]
835        else:
836            return None

Returns the room part name for a room name that may contain one or more sub-room part(s). If multiple sub-name parts are present, they all included together in one string. Returns None if there is no room part name included in the given full name.

Example:

>>> fmt = ParseFormat()
>>> fmt.roomPartName('a%b')
'b'
>>> fmt.roomPartName('a%b%c')
'b%c'
>>> fmt.roomPartName('a%')
''
>>> fmt.roomPartName('a')
None
def roomMinusPart(self, fullName: str, partName: str) -> str:
838    def roomMinusPart(
839        self,
840        fullName: core.Decision,
841        partName: core.Decision
842    ) -> core.Decision:
843        """
844        Returns the room name, minus the specified sub-room indicator.
845        Raises a `JournalParseError` if the full room name does not end
846        in the given sub-room indicator.
847        Examples:
848
849        >>> fmt = ParseFormat()
850        >>> fmt.roomMinusPart('a%b', 'b')
851        'a'
852        >>> fmt.roomMinusPart('a%b%c', 'c')
853        'a%b'
854        >>> fmt.roomMinusPart('a%b%c', 'b') # doctest: +IGNORE_EXCEPTION_DETAIL
855        Traceback (most recent call last):
856          ...
857        JournalParseError: ...
858        """
859        marker = self.formatDict['subroom']
860        if not fullName.endswith(marker + partName):
861            raise JournalParseError(
862                f"Cannot remove sub-room part '{partName}' from room"
863                f" '{fullName}' because it does not end with that part."
864            )
865
866        return fullName[:-(len(partName) + 1)]

Returns the room name, minus the specified sub-room indicator. Raises a JournalParseError if the full room name does not end in the given sub-room indicator. Examples:

>>> fmt = ParseFormat()
>>> fmt.roomMinusPart('a%b', 'b')
'a'
>>> fmt.roomMinusPart('a%b%c', 'c')
'a%b'
>>> fmt.roomMinusPart('a%b%c', 'b') # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
  ...
JournalParseError: ...
def allSubRooms(self, graph: exploration.core.DecisionGraph, roomName: str) -> Set[str]:
868    def allSubRooms(
869        self,
870        graph: core.DecisionGraph,
871        roomName: core.Decision
872    ) -> Set[core.Decision]:
873        """
874        The journal format organizes decisions into groups called
875        "rooms" within which "sub-rooms" indicate specific parts where a
876        decision is needed. This function returns a set of
877        `core.Decision`s naming each decision that's part of the named
878        room in the given graph. If the name contains a sub-room part,
879        that part is ignored. The parse format is used to understand how
880        sub-rooms are named. Note that unknown nodes will NOT be
881        included, even if the connections to them are tagged with
882        'internal', which is used to tag within-room transitions.
883
884        Note that this function requires checking each room in the entire
885        graph, since there could be disconnected components of a room.
886        """
887        base = self.baseRoomName(roomName)
888        return {
889            node
890            for node in graph.nodes
891            if self.baseRoomName(node) == base
892            and not graph.isUnknown(node)
893        }

The journal format organizes decisions into groups called "rooms" within which "sub-rooms" indicate specific parts where a decision is needed. This function returns a set of core.Decisions naming each decision that's part of the named room in the given graph. If the name contains a sub-room part, that part is ignored. The parse format is used to understand how sub-rooms are named. Note that unknown nodes will NOT be included, even if the connections to them are tagged with 'internal', which is used to tag within-room transitions.

Note that this function requires checking each room in the entire graph, since there could be disconnected components of a room.

def getEntranceDestination( self, graph: exploration.core.DecisionGraph, room: str, entrance: str) -> Optional[str]:
895    def getEntranceDestination(
896        self,
897        graph: core.DecisionGraph,
898        room: core.Decision,
899        entrance: core.Transition
900    ) -> Optional[core.Decision]:
901        """
902        Given a graph and a room being entered, as well as the entrance
903        name in that room (i.e., the name of the reciprocal of the
904        transition being used to enter the room), returns the name of
905        the specific sub-room being entered, based on the known site for
906        that entrance, or returns None if that entrance hasn't been used
907        in any sub-room of the specified room. If the room has a
908        sub-room part in it, that will be ignored.
909
910        Before searching the entire graph, we do check whether the given
911        transition exists in the target (possibly sub-) room.
912        """
913        easy = graph.getDestination(room, entrance)
914        if easy is not None:
915            return easy
916
917        check = self.allSubRooms(graph, room)
918        for sub in check:
919            hope = graph.getDestination(sub, entrance)
920            if hope is not None:
921                return hope
922
923        return None

Given a graph and a room being entered, as well as the entrance name in that room (i.e., the name of the reciprocal of the transition being used to enter the room), returns the name of the specific sub-room being entered, based on the known site for that entrance, or returns None if that entrance hasn't been used in any sub-room of the specified room. If the room has a sub-room part in it, that will be ignored.

Before searching the entire graph, we do check whether the given transition exists in the target (possibly sub-) room.

def getSubRoom( self, graph: exploration.core.DecisionGraph, roomName: str, subPart: str) -> Optional[str]:
925    def getSubRoom(
926        self,
927        graph: core.DecisionGraph,
928        roomName: core.Decision,
929        subPart: core.Decision
930    ) -> Optional[core.Decision]:
931        """
932        Given a graph and a room name, plus a sub-room name, returns the
933        name of the existing sub-room that's part of the same room as
934        the target room but has the specified sub-room name part.
935        Returns None if no such room has been defined already.
936        """
937        base = self.baseRoomName(roomName)
938        lookingFor = self.subRoomName(base, subPart)
939        if lookingFor in graph:
940            return lookingFor
941        else:
942            return None

Given a graph and a room name, plus a sub-room name, returns the name of the existing sub-room that's part of the same room as the target room but has the specified sub-room name part. Returns None if no such room has been defined already.

def parseItem(self, item: str) -> Union[str, Tuple[str, int]]:
944    def parseItem(
945        self,
946        item: str
947    ) -> Union[core.Power, Tuple[core.Token, int]]:
948        """
949        Parses an item, which is either a power (just a string) or a
950        token-type:number pair (returned as a tuple with the number
951        converted to an integer). The 'tokenQuantity' format value
952        determines the separator which indicates a token instead of a
953        power.
954        """
955        sep = self.formatDict['tokenQuantity']
956        if sep in item:
957            # It's a token w/ an associated count
958            parts = item.split(sep)
959            if len(parts) != 2:
960                raise JournalParseError(
961                    f"Item '{item}' has a '{sep}' but doesn't separate"
962                    f" into a token type and a count."
963                )
964            typ, count = parts
965            try:
966                num = int(count)
967            except ValueError:
968                raise JournalParseError(
969                    f"Item '{item}' has invalid token count '{count}'."
970                )
971
972            return (typ, num)
973        else:
974            # It's just a power
975            return item

Parses an item, which is either a power (just a string) or a token-type:number pair (returned as a tuple with the number converted to an integer). The 'tokenQuantity' format value determines the separator which indicates a token instead of a power.

def anonName(self, room: str, exit: str)
977    def anonName(self, room: core.Decision, exit: core.Transition):
978        """
979        Returns the anonymous room name for an anonymous room that's
980        connected to the specified room via the specified transition.
981        Example:
982
983        >>> pf = ParseFormat()
984        >>> pf.anonName('MidHall', 'Bottom')
985        'MidHall$Bottom'
986        """
987        return room + self.formatDict['anonSep'] + exit

Returns the anonymous room name for an anonymous room that's connected to the specified room via the specified transition. Example:

>>> pf = ParseFormat()
>>> pf.anonName('MidHall', 'Bottom')
'MidHall$Bottom'
class JournalParseError(builtins.ValueError):
994class JournalParseError(ValueError):
995    """
996    Represents a error encountered when parsing a journal.
997    """
998    pass

Represents a error encountered when parsing a journal.

Inherited Members
builtins.ValueError
ValueError
builtins.BaseException
with_traceback
args
class JournalParseWarning(builtins.Warning):
1001class JournalParseWarning(Warning):
1002    """
1003    Represents a warning encountered when parsing a journal.
1004    """
1005    pass

Represents a warning encountered when parsing a journal.

Inherited Members
builtins.Warning
Warning
builtins.BaseException
with_traceback
args
class InterRoomEllipsis:
1008class InterRoomEllipsis:
1009    """
1010    Represents part of an inter-room path which has been omitted from a
1011    journal and which should therefore be inferred.
1012    """
1013    pass

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

InterRoomEllipsis()
class JournalObserver:
1020class JournalObserver:
1021    """
1022    Keeps track of extra state needed when parsing a journal in order to
1023    produce a `core.Exploration` object. The methods of this class act
1024    as an API for constructing explorations that have several special
1025    properties (for example, some transitions are tagged 'internal' and
1026    decision names are standardized so that a pattern of "rooms" emerges
1027    above the decision level). The API is designed to allow journal
1028    entries (which represent specific observations/events during an
1029    exploration) to be directly accumulated into an exploration object,
1030    including some ambiguous entries which cannot be directly
1031    interpreted until further entries are observed. The basic usage is
1032    as follows:
1033
1034    1. Create a `JournalObserver`, optionally specifying a custom
1035        `ParseFormat`.
1036    2. Repeatedly either:
1037        * Call `observe*` API methods corresponding to specific entries
1038            observed or...
1039        * Call `JournalObserver.observe` to parse one or more
1040            journal blocks from a string and call the appropriate
1041            methods automatically.
1042    3. Call `JournalObserver.applyState` to clear any remaining
1043        un-finalized state.
1044    4. Call `JournalObserver.getExploration` to retrieve the
1045        `core.Exploration` object that's been created.
1046
1047    Notes:
1048
1049    - `JournalObserver.getExploration` may be called at any time to get
1050        the exploration object constructed so far, and that that object
1051        (unless it's `None`) will always be the same object (which gets
1052        modified as entries are observed). Modifying this object
1053        directly is possible for making changes not available via the
1054        API, but must be done carefully, as there are important
1055        conventions around things like decision names that must be
1056        respected if the API functions need to keep working.
1057    - To get the latest graph, simply use the
1058        `core.Exploration.currentGraph` method of the
1059        `JournalObserver.getExploration` result.
1060    - If you don't call `JournalObserver.applyState` some entries may
1061        not have affected the exploration yet, because they're ambiguous
1062        and further entries need to be observed (or `applyState` needs
1063        to be called) to resolve that ambiguity.
1064
1065    ## Example
1066
1067    >>> obs = JournalObserver()
1068    >>> obs.getExploration() is None
1069    True
1070    >>> # We start by using the observe* methods...
1071    >>> obs.observeRoom("Start") # no effect until entrance is observed
1072    >>> obs.getExploration() is None
1073    True
1074    >>> obs.observeProgress("bottom") # New sub-room within current room
1075    >>> e = obs.getExploration()
1076    >>> len(e) # base state + first movement
1077    2
1078    >>> e.positionAtStep(0)
1079    'Start'
1080    >>> e.positionAtStep(1)
1081    'Start%bottom'
1082    >>> e.transitionAtStep(0)
1083    'bottom'
1084    >>> obs.observeOneway("R") # no effect yet (might be one-way progress)
1085    >>> len(e)
1086    2
1087    >>> obs.observeRoom("Second") # Need to know entrance
1088    >>> len(e) # oneway is now understood to be an inter-room transition
1089    2
1090    >>> obs.observeProgress("bad") # Need to see an entrance first!
1091    Traceback (most recent call last):
1092    ...
1093    exploration.journal.JournalParseError...
1094    >>> obs.observeEntrance("L")
1095    >>> len(e) # Now full transition can be mapped
1096    3
1097    >>> e.positionAtStep(2)
1098    'Second'
1099    >>> e.transitionAtStep(1)
1100    'R'
1101    >>> e.currentGraph().getTransitionRequirement('Second', 'L')
1102    ReqImpossible()
1103    >>> # Now we demonstrate the use of "observe"
1104    >>> obs.observe("x< T (tall)\\n? R\\n> B\\n\\n[Third]\\nx< T")
1105    >>> len(e)
1106    4
1107    >>> m2 = e.graphAtStep(2) # Updates were applied without adding a step
1108    >>> m2.getDestination('Second', 'T')
1109    '_u.1'
1110    >>> m2.getTransitionRequirement('Second', 'T')
1111    ReqPower('tall')
1112    >>> m2.getDestination('Second', 'R')
1113    '_u.2'
1114    >>> m2.getDestination('Second', 'B')
1115    '_u.3'
1116    >>> m = e.currentGraph()
1117    >>> m == e.graphAtStep(3)
1118    >>> m.getDestination('Second', 'B')
1119    'Third'
1120    >>> m.getDestination('Third', 'T')
1121    'Second'
1122    >>> m.getTransitionRequirement('Third', 'T') # Due to 'x<' for entrance
1123    ReqImpossible()
1124    """
1125    parseFormat: ParseFormat = ParseFormat()
1126    """
1127    The parse format used to parse entries supplied as text. This also
1128    ends up controlling some of the decision and transition naming
1129    conventions that are followed, so it is not safe to change it
1130    mid-journal; it should be set once before observation begins, and
1131    may be accessed but should not be changed.
1132    """
1133
1134    exploration: core.Exploration
1135    """
1136    This is the exploration object being built via journal observations.
1137    Note that the exploration object may be empty (i.e., have length 0)
1138    even after the first few entries have been observed because in some
1139    cases entries are ambiguous and are not translated into exploration
1140    steps until a further entry resolves that ambiguity.
1141    """
1142
1143    def __init__(self, parseFormat: Optional[ParseFormat] = None):
1144        """
1145        Sets up the observer. If a parse format is supplied, that will
1146        be used instead of the default parse format, which is just the
1147        result of creating a `ParseFormat` with default arguments.
1148        """
1149        if parseFormat is not None:
1150            self.parseFormat = parseFormat
1151
1152        # Create  blank exploration
1153        self.exploration = core.Exploration()
1154
1155        # State variables
1156
1157        # Tracks the current room name and tags for the room, once a
1158        # room has been declared
1159        self.currentRoomName: Optional[core.Decision] = None
1160        self.currentRoomTags: Set[core.Tag] = set()
1161
1162        # Whether we've seen an entrance/exit yet in the current room
1163        self.seenRoomEntrance = False
1164
1165        # The room & transition used to exit
1166        self.previousRoom: Optional[core.Decision] = None
1167        self.previousTransition: Optional[core.Transition] = None
1168
1169        # The room & transition identified as our next source/transition
1170        self.exitTransition = None
1171
1172        # This tracks the current note text, since notes can continue
1173        # across multiple lines
1174        self.currentNote: Optional[Tuple[
1175            Union[
1176                core.Decision,
1177                Tuple[core.Decision, core.Transition]
1178            ], # target
1179            bool, # was this note indented?
1180            str # note text
1181        ]] = None
1182
1183        # Tracks a pending progress step, since things like a oneway can
1184        # be used for either within-room progress OR room-to-room
1185        # transitions.
1186        self.pendingProgress: Optional[Tuple[
1187            core.Decision, # destination of progress (maybe just sub-part)
1188            Optional[core.Transition], # transition name (None -> auto)
1189            Union[bool, str], # is it one-way; 'hidden' for a hidden one-way?
1190            Optional[core.Requirement], # requirement for the transition
1191            Optional[core.Requirement], # reciprocal requirement
1192            Optional[Set[core.Tag]], # tags to apply
1193            Optional[Set[core.Tag]], # reciprocal tags
1194            Optional[List[core.Annotation]], # annotations to apply
1195            Optional[List[core.Annotation]] # reciprocal annotations
1196        ]] = None
1197
1198        # This tracks the current entries in an inter-room abbreviated
1199        # path, since we first have to accumulate all of them and then
1200        # do pathfinding to figure out a concrete inter-room path.
1201        self.interRoomPath: List[
1202            Union[Type[InterRoomEllipsis], core.Decision]
1203        ] = []
1204
1205        # Tracks presence of an end entry, which must be final in the
1206        # block it occurs in except for notes or tags.
1207        self.blockEnded = False
1208
1209    def observe(self, journalText: str) -> None:
1210        """
1211        Ingests one or more journal blocks in text format (as a
1212        multi-line string) and updates the exploration being built by
1213        this observer, as well as updating internal state. Note that
1214        without later calling `applyState`, some parts of the observed
1215        entries may remain saved as internal state that hasn't yet been
1216        disambiguated and applied to the exploration. jor example, a
1217        final one-way transition could indicate in-room one-way
1218        progress, or a one-way transition to another room, and this is
1219        disambiguated by observing whether the next entry is another
1220        entry in the same block or a blank line to indicate the end of a
1221        block.
1222
1223        This method can be called multiple times to process a longer
1224        journal incrementally including line-by-line. If you give it an
1225        empty string, that will count as the end of a journal block (or
1226        a continuation of space between blocks).
1227
1228        ## Example:
1229
1230        >>> obs = JournalObserver()
1231        >>> obs.observe('''\\
1232        ... [Room1]
1233        ... < Top " Comment
1234        ... x nope (power|tokens*3)
1235        ... ? unexplored
1236        ... -> sub_room " This is a one-way transition
1237        ... -> - " The default sub-room is named '-'
1238        ... > Bottom
1239        ...
1240        ... [Room2]
1241        ... < Top
1242        ... * switch " Took an action in this room
1243        ... ? Left
1244        ... > Right {blue}
1245        ...
1246        ... [Room3]
1247        ... < Left
1248        ... # Miniboss " Faced a challenge
1249        ... . power " Get a power
1250        ... >< Right [
1251        ...    - ledge (tall)
1252        ...    . treasure
1253        ... ] " Detour to an anonymous room
1254        ... > Left
1255        ...
1256        ... - Room2 " Visited along the way
1257        ... [Room1]
1258        ... - nope " Entrance may be omitted if implied
1259        ... > Right
1260        ... ''')
1261        >>> e = obs.getExploration()
1262        >>> len(e)
1263        12
1264        >>> m = e.currentGraph()
1265        >>> len(m)
1266        11
1267        >>> def showDestinations(m, r):
1268        ...     d = m.destinationsFrom(r)
1269        ...     for outgoing in d:
1270        ...         req = m.getTransitionRequirement(r, outgoing)
1271        ...         if req is None:
1272        ...             req = ''
1273        ...         else:
1274        ...             req = ' (' + repr(req) + ')'
1275        ...         print(outgoing, d[outgoing] + req)
1276        ...
1277        >>> showDestinations(m, "Room1")
1278        Top _u.0
1279        nope Room1%nope ReqAny(ReqPower("power"), ReqTokens("tokens", 3))
1280        unexplored _u.1
1281        sub_room Room1%sub_room
1282        sub_room.1 Room1%sub_room ReqImpossible()
1283        Bottom: Room2
1284        >>> showDestinations(m, "Room1%nope")
1285        - Room1 ReqAny(ReqPower("power"), ReqTokens("tokens", 3))
1286        Right _u.3
1287        >>> showDestinations(m, "Room1%sub_room")
1288        - Room1 ReqImpossible()
1289        -.1 Room1
1290        >>> showDestinations(m, "Room2")
1291        Top Room1
1292        action@5 Room2
1293        Left _u.2
1294        Right: Room3
1295        >>> m.transitionTags("Room3", "Right")
1296        {'blue'}
1297        >>> showDestinations(m, "Room3")
1298        Left Room2
1299        action@7 Room3
1300        Right Room3$Right
1301        >>> showDestinations(m, "Room3$Right")
1302        ledge Room3$Right%ledge ReqPower("tall")
1303        return Room3
1304        >>> showDestinations(m, "Room3$Right%ledge")
1305        - Room3$Right
1306        action@9 Room3$Right%ledge
1307        >>> m.decisionAnnotations("Room3")
1308        ['challenge: Miniboss']
1309        >>> e.currentPosition()
1310        'Room1%nope'
1311
1312        Note that there are plenty of other annotations not shown in
1313        this example; see `DEFAULT_FORMAT` for the default mapping from
1314        journal entry types to markers, and see `JournalEntryType` for
1315        the explanation for each entry type.
1316
1317        Most entries start with a marker followed by a single space, and
1318        everything after that is the content of the entry. A few
1319        different modifiers are removed from the right-hand side of
1320        entries first:
1321
1322        - Notes starting with `"` by default and going to the end of the
1323            line, possibly continued on other lines that are indented
1324            and start with the note marker.
1325        - Tags surrounded by `{` and `}` by default and separated from
1326            each other by commas and optional spaces. These are applied
1327            to the current room (if alone on a line) or to the decision
1328            or transition implicated in the line they're at the end of.
1329        - Requirements surrounded by `(` and `)` by default, with `/`
1330            used to separate forward/reverse requirements. These are
1331            applied to the transition implicated by the rest of the
1332            line, and are not allowed on lines that don't imply a
1333            transition. The contents are parsed into a requirement using
1334            `core.Requirement.parse`. Warnings may be issued for
1335            requirements specified on transitions that are taken which
1336            are not met at the time.
1337        - For detours and a few other select entry types, anonymous room
1338            or transition info may be surrounded by `[` and `]` at the
1339            end of the line. For detours, there may be multiple lines
1340            between `[` and `]` as shown in the example above.
1341        """
1342        # Normalize newlines
1343        journalText = journalText\
1344            .replace('\r\n', '\n')\
1345            .replace('\n\r', '\n')\
1346            .replace('\r', '\n')
1347
1348        # Line splitting variables
1349        lineNumber = 0 # first iteration will increment to 1 before use
1350        cursor = 0 # Character index into the block tracking progress
1351        journalLen = len(journalText) # So we know when to stop
1352        lineIncrement = 1 # How many lines we've processed
1353        thisBlock = '' # Lines in this block of the journal
1354
1355        # Shortcut variable
1356        pf = self.parseFormat
1357
1358        # Parse each line separately, but collect multiple lines for
1359        # multi-line entries such as detours
1360        while cursor < journalLen:
1361            lineNumber += lineIncrement
1362            lineIncrement = 1
1363            try:
1364                # Find the next newline
1365                nextNL = journalText.index('\n', cursor)
1366                fullLine = journalText[cursor:nextNL]
1367                cursor = nextNL + 1
1368            except ValueError:
1369                # If there isn't one, rest of the journal is the next line
1370                fullLine = journalText[cursor:]
1371                cursor = journalLen
1372
1373            thisBlock += fullLine + '\n'
1374
1375            # TODO: DEBUG
1376            print("LL", lineNumber, fullLine)
1377
1378            # Check for and split off anonymous room content
1379            line, anonymousContent = pf.splitAnonymousRoom(fullLine)
1380            if (
1381                anonymousContent is None
1382            and pf.startsAnonymousRoom(fullLine)
1383            ):
1384                endIndex = pf.anonymousRoomEnd(journalText, cursor)
1385                if endIndex is None:
1386                    raise JournalParseError(
1387                        f"Anonymous room started on line {lineNumber}"
1388                        f" was never closed in block:\n{thisBlock}\n..."
1389                    )
1390                anonymousContent = journalText[nextNL + 1:endIndex].strip()
1391                thisBlock += anonymousContent + '\n'
1392                # TODO: Is this correct?
1393                lineIncrement = anonymousContent.count('\n') + 1
1394                # Skip to end of line where anonymous room ends
1395                cursor = journalText.index('\n', endIndex + 1)
1396
1397                # Trim the start of the anonymous room from the line end
1398                line = line.rstrip()[:-1]
1399
1400            # Blank lines end one block and start another
1401            if not line.strip():
1402                thisBlock = ''
1403                lineNumber = 0
1404                self.previousRoom = self.exploration.currentPosition()
1405                self.previousTransition = self.exitTransition
1406                self.exitTransition = None
1407                self.currentRoomName = None
1408                self.blockEnded = False
1409                # TODO: More inter-block state here...!
1410                continue
1411
1412            # Check for indentation (mostly ignored, but important for
1413            # comments).
1414            indented = line[0] == ' '
1415
1416            # Strip indentation going forward
1417            line = line.strip()
1418
1419            # Detect entry type and separate content
1420            eType, eContent = pf.determineEntryType(line)
1421
1422            # TODO: DEBUG
1423            print("EE", lineNumber, eType, eContent)
1424
1425            if self.exitTransition is not None and eType != 'note':
1426                raise JournalParseError(
1427                    f"Entry after room exit on line {lineNumber} in"
1428                    f" block:\n{thisBlock}"
1429                )
1430
1431            if (
1432                eType not in ('detour', 'obviate')
1433            and anonymousContent is not None
1434            ):
1435                raise JournalParseError(
1436                    f"Entry on line #{lineNumber} with type {eType}"
1437                    f" does not support anonymous room content. Block"
1438                    f" is:\n{thisBlock}"
1439                )
1440
1441            # Handle note creation
1442            if self.currentNote is not None and eType != 'note':
1443                # This ends a note, so we can apply the pending note and
1444                # reset it.
1445                self.applyCurrentNote()
1446            elif eType == 'note':
1447                self.observeNote(eContent, indented=indented)
1448                # In (only) this case, we've handled the entire line
1449                continue
1450
1451            # Handle a pending progress step if there is one
1452            if self.pendingProgress is not None:
1453                # Any kind of entry except a note (which we would have
1454                # hit above and continued) indicates that a progress
1455                # marker is in-room progress rather than being a room
1456                # exit.
1457                self.makeProgressInRoom(*self.pendingProgress)
1458
1459                # Clean out pendingProgress
1460                self.pendingProgress = None
1461
1462            # Check for valid eType if pre-room
1463            if (
1464                self.currentRoomName is None
1465            and eType not in ('room', 'progress')
1466            ):
1467                raise JournalParseError(
1468                    f"Invalid entry on line #{lineNumber}: Entry type"
1469                    f" '{eType}' not allowed before room name. Block"
1470                    f" is:\n{thisBlock}"
1471                )
1472
1473            # Check for valid eType if post-room
1474            if self.blockEnded and eType not in ('note', 'tag'):
1475                raise JournalParseError(
1476                    f"Invalid entry on line #{lineNumber}: Entry type"
1477                    f" '{eType}' not allowed after an block ends. Block"
1478                    f" is:\n{thisBlock}"
1479                )
1480
1481            # Parse a line-end note if there is one
1482            # Note that note content will be handled after we handle main
1483            # entry stuff
1484            content, note = pf.splitFinalNote(eContent)
1485
1486            # Parse a line-end tags section if there is one
1487            content, fTags, rTags = pf.splitTags(content)
1488
1489            # Parse a line-end requirements section if there is one
1490            content, forwardReq, backReq = pf.splitRequirement(content)
1491
1492            # Strip any remaining whitespace from the edges of our content
1493            content = content.strip()
1494
1495            # Get current graph
1496            now = self.exploration.getCurrentGraph()
1497
1498            # This will trigger on the first line in the room, and handles
1499            # the actual room creation in the graph
1500            handledEntry = False # did we handle the entry in this block?
1501            if (
1502                self.currentRoomName is not None
1503            and not self.seenRoomEntrance
1504            ):
1505                # We're looking for an entrance and if we see anything else
1506                # except a tag, we'll assume that the entrance is implicit,
1507                # and give an error if we don't have an implicit entrance
1508                # set up. If the entrance is explicit, we'll give a warning
1509                # if it doesn't match the previous entrance for the same
1510                # prior-room exit from last time.
1511                if eType in ('entrance', 'otherway'):
1512                    # An explicit entrance; must match previous associated
1513                    # entrance if there was one.
1514                    self.observeRoomEntrance(
1515                        taken, # TODO: transition taken?
1516                        newRoom, # TODO: new room name?
1517                        content,
1518                        eType == 'otherway',
1519                        fReq=forwardReq,
1520                        rReq=backReq,
1521                        fTags=fTags,
1522                        rTags=rTags
1523                    )
1524
1525                elif eType == 'tag':
1526                    roomTags |= set(content.split())
1527                    if fTags or rTags:
1528                        raise JournalParseError(
1529                            f"Found tags on tag entry on line #{lineNumber}"
1530                            f" of block:\n{journalBlock}"
1531                        )
1532                    # don't do anything else here since it's a tag;
1533                    # seenEntrance remains False
1534                    handledEntry = True
1535
1536                else:
1537                    # For any other entry type, it counts as an implicit
1538                    # entrance. We need to follow that transition, or if an
1539                    # appropriate link does not already exist, raise an
1540                    # error.
1541                    seenEntrance = True
1542                    # handledEntry remains False in this case
1543
1544                    # Check that the entry point for this room can be
1545                    # deduced, and deduce it so that we can figure out which
1546                    # sub-room we're actually entering...
1547                    if enterFrom is None:
1548                        if len(exploration) == 0:
1549                            # At the start of the exploration, there's often
1550                            # no specific transition we come from, which is
1551                            # fine.
1552                            exploration.start(roomName, [])
1553                        else:
1554                            # Continuation after an ending
1555                            exploration.warp(roomName, 'restart')
1556                    else:
1557                        fromDecision, fromTransition = enterFrom
1558                        prevReciprocal = None
1559                        if now is not None:
1560                            prevReciprocal = now.getReciprocal(
1561                                fromDecision,
1562                                fromTransition
1563                            )
1564                        if prevReciprocal is None:
1565                            raise JournalParseError(
1566                                f"Implicit transition into room {roomName}"
1567                                f" is invalid because no reciprocal"
1568                                f" transition has been established for exit"
1569                                f" {fromTransition} in previous room"
1570                                f" {fromDecision}."
1571                            )
1572
1573                        # In this case, we retrace the transition, and if
1574                        # that fails because of a ValueError (e.g., because
1575                        # that transition doesn't exist yet or leads to an
1576                        # unknown node) then we'll raise the error as a
1577                        # JournalParseError.
1578                        try:
1579                            exploration.retrace(fromTransition)
1580                        except ValueError as e:
1581                            raise JournalParseError(
1582                                f"Implicit transition into room {roomName}"
1583                                f" is invalid because:\n{e.args[0]}"
1584                            )
1585
1586                        # Note: no tags get applied here, because this is an
1587                        # implicit transition, so there's no room to apply
1588                        # new tags. An explicit transition could be used
1589                        # instead to update transition properties.
1590
1591            # Previous block may have updated the current graph
1592            now = exploration.getCurrentGraph()
1593
1594            # At this point, if we've seen an entrance we're in the right
1595            # room, so we should apply accumulated room tags
1596            if seenEntrance and roomTags:
1597                if now is None:
1598                    raise RuntimeError(
1599                        "Inconsistency: seenEntrance is True but the current"
1600                        " graph is None."
1601                    )
1602
1603                here = exploration.currentPosition()
1604                now.tagDecision(here, roomTags)
1605                roomTags = set() # reset room tags
1606
1607            # Handle all entry types not handled above (like note)
1608            if handledEntry:
1609                # We skip this if/else but still do end-of-loop cleanup
1610                pass
1611
1612            elif eType == 'note':
1613                raise RuntimeError("Saw 'note' eType in lower handling block.")
1614
1615            elif eType == 'room':
1616                if roomName is not None:
1617                    raise ValueError(
1618                        f"Multiple room names detected on line {lineNumber}"
1619                        f" in block:\n{journalBlock}"
1620                    )
1621
1622                # Setting the room name changes the loop state
1623                roomName = content
1624
1625                # These will be applied later
1626                roomTags = fTags
1627
1628                if rTags:
1629                    raise JournalParseError(
1630                        f"Reverse tags cannot be applied to a room"
1631                        f" (found tags {rTags} for room '{roomName}')."
1632                    )
1633
1634            elif eType == 'entrance':
1635                # would be handled above if seenEntrance was false
1636                raise JournalParseError(
1637                    f"Multiple entrances on line {lineNumber} in"
1638                    f" block:\n{journalBlock}"
1639                )
1640
1641            elif eType == 'exit':
1642                # We note the exit transition and will use that as our
1643                # return value. This also will cause an error on the next
1644                # iteration if there are further non-note entries in the
1645                # journal block
1646                exitRoom = exploration.currentPosition()
1647                exitTransition = content
1648
1649                # At this point we add an unexplored edge for this exit,
1650                # assuming it's not one we've seen before. Note that this
1651                # does not create a new exploration step (that will happen
1652                # later).
1653                knownDestination = None
1654                if now is not None:
1655                    knownDestination = now.getDestination(
1656                        exitRoom,
1657                        exitTransition
1658                    )
1659
1660                    if knownDestination is None:
1661                        now.addUnexploredEdge(
1662                            exitRoom,
1663                            exitTransition,
1664                            tags=fTags,
1665                            revTags=rTags,
1666                            requires=forwardReq,
1667                            revRequires=backReq
1668                        )
1669
1670                    else:
1671                        # Otherwise just apply any tags to the transition
1672                        now.tagTransition(exitRoom, exitTransition, fTags)
1673                        existingReciprocal = now.getReciprocal(
1674                            exitRoom,
1675                            exitTransition
1676                        )
1677                        if existingReciprocal is not None:
1678                            now.tagTransition(
1679                                knownDestination,
1680                                existingReciprocal,
1681                                rTags
1682                            )
1683
1684            elif eType in (
1685                'blocked',
1686                'otherway',
1687                'unexplored',
1688                'unexploredOneway',
1689            ):
1690                # Simply add the listed transition to our current room,
1691                # leading to an unknown destination, without creating a new
1692                # exploration step
1693                transition = content
1694                here = exploration.currentPosition()
1695
1696                # If there isn't a listed requirement, infer ReqImpossible
1697                # where appropriate
1698                if forwardReq is None and eType in ('blocked', 'otherway'):
1699                    forwardReq = core.ReqImpossible()
1700                if backReq is None and eType in ('blocked', 'unexploredOneway'):
1701                    backReq = core.ReqImpossible()
1702
1703                # TODO: What if we've annotated a known source for this
1704                # link?
1705
1706                if now is None:
1707                    raise JournalParseError(
1708                        f"On line {lineNumber}: Cannot create an unexplored"
1709                        f" transition before we've created the starting"
1710                        f" graph. Block is:\n{journalBlock}"
1711                    )
1712
1713                now.addUnexploredEdge(
1714                    here,
1715                    transition,
1716                    tags=fTags,
1717                    revTags=rTags,
1718                    requires=forwardReq,
1719                    revRequires=backReq
1720                )
1721
1722            elif eType in ('pickup', 'unclaimed', 'action'):
1723                # We both add an action to the current room, and then take
1724                # that action, or if the type is unclaimed, we don't take
1725                # the action.
1726
1727                if eType == 'unclaimed' and content[0] == '?':
1728                    fTags.add('unknown')
1729
1730                name: Optional[str] = None # auto by default
1731                gains: Optional[str] = None
1732                if eType == 'action':
1733                    name = content
1734                    # TODO: Generalize action effects; also handle toggles,
1735                    # repeatability, etc.
1736                else:
1737                    gains = content
1738
1739                actionName = takeActionInRoom(
1740                    exploration,
1741                    parseFormat,
1742                    name,
1743                    gains,
1744                    forwardReq,
1745                    backReq,
1746                    fTags,
1747                    rTags,
1748                    eType == 'unclaimed' # whether to leave it untaken
1749                )
1750
1751                # Limit scope to this case
1752                del name
1753                del gains
1754
1755            elif eType == 'progress':
1756                # If the room name hasn't been specified yet, this indicates
1757                # a room that we traverse en route. If the room name has
1758                # been specified, this is movement to a new sub-room.
1759                if roomName is None:
1760                    # Here we need to accumulate the named route, since the
1761                    # navigation of sub-rooms has to be figured out by
1762                    # pathfinding, but that's only possible once we know
1763                    # *all* of the listed rooms. Note that the parse
1764                    # format's 'runback' symbol may be used as a room name
1765                    # to indicate that some of the route should be
1766                    # auto-completed.
1767                    if content == parseFormat.formatDict['runback']:
1768                        interRoomPath.append(InterRoomEllipsis)
1769                    else:
1770                        interRoomPath.append(content)
1771                else:
1772                    # This is progress to a new sub-room. If we've been
1773                    # to that sub-room from the current sub-room before, we
1774                    # retrace the connection, and if not, we first add an
1775                    # unexplored connection and then explore it.
1776                    makeProgressInRoom(
1777                        exploration,
1778                        parseFormat,
1779                        content,
1780                        False,
1781                        forwardReq,
1782                        backReq,
1783                        fTags,
1784                        rTags
1785                        # annotations handled separately
1786                    )
1787
1788            elif eType == 'frontier':
1789                pass
1790                # TODO: HERE
1791
1792            elif eType == 'frontierEnd':
1793                pass
1794                # TODO: HERE
1795
1796            elif eType == 'oops':
1797                # This removes the specified transition from the graph,
1798                # creating a new exploration step to do so. It tags that
1799                # transition as an oops in the previous graph, because
1800                # the transition won't exist to be tagged in the new
1801                # graph. If the transition led to a non-frontier unknown
1802                # node, that entire node is removed; otherwise just the
1803                # single transition is removed, along with its
1804                # reciprocal.
1805                if now is None:
1806                    raise JournalParseError(
1807                        f"On line {lineNumber}: Cannot mark an oops before"
1808                        f" we've created the starting graph. Block"
1809                        f" is:\n{journalBlock}"
1810                    )
1811
1812                prev = now # remember the previous graph
1813                # TODO
1814                now = exploration.currentGraph()
1815                here = exploration.currentPosition()
1816                print("OOP", now.destinationsFrom(here))
1817                exploration.wait('oops') # create new step w/ no changes
1818                now = exploration.currentGraph()
1819                here = exploration.currentPosition()
1820                accidental = now.getDestination(here, content)
1821                if accidental is None:
1822                    raise JournalParseError(
1823                        f"Cannot erase transition '{content}' because it"
1824                        f" does not exist at decision {here}."
1825                    )
1826
1827                # If it's an unknown (the usual case) then we remove the
1828                # entire node
1829                if now.isUnknown(accidental):
1830                    now.remove_node(accidental)
1831                else:
1832                    # Otherwise re move the edge and its reciprocal
1833                    reciprocal = now.getReciprocal(here, content)
1834                    now.remove_edge(here, accidental, content)
1835                    if reciprocal is not None:
1836                        now.remove_edge(accidental, here, reciprocal)
1837
1838                # Tag the transition as an oops in the step before it gets
1839                # removed:
1840                prev.tagTransition(here, content, 'oops')
1841
1842            elif eType in ('oneway', 'hiddenOneway'):
1843                # In these cases, we create a pending progress value, since
1844                # it's possible to use 'oneway' as the exit from a room in
1845                # which case it's not in-room progress but rather a room
1846                # transition.
1847                pendingProgress = (
1848                    content,
1849                    True if eType == 'oneway' else 'hidden',
1850                    forwardReq,
1851                    backReq,
1852                    fTags,
1853                    rTags,
1854                    None, # No annotations need be applied now
1855                    None
1856                )
1857
1858            elif eType == 'detour':
1859                if anonymousContent is None:
1860                    raise JournalParseError(
1861                        f"Detour on line #{lineNumber} is missing an"
1862                        f" anonymous room definition. Block"
1863                        f" is:\n{journalBlock}"
1864                    )
1865                # TODO: Support detours to existing rooms w/out anonymous
1866                # content...
1867                if now is None:
1868                    raise JournalParseError(
1869                        f"On line {lineNumber}: Cannot create a detour"
1870                        f" before we've created the starting graph. Block"
1871                        f" is:\n{journalBlock}"
1872                    )
1873
1874                # First, we create an unexplored transition and then use it
1875                # to enter the anonymous room...
1876                here = exploration.currentPosition()
1877                now.addUnexploredEdge(
1878                    here,
1879                    content,
1880                    tags=fTags,
1881                    revTags=rTags,
1882                    requires=forwardReq,
1883                    revRequires=backReq
1884                )
1885
1886                if roomName is None:
1887                    raise JournalParseError(
1888                        f"Detour on line #{lineNumber} occurred before room"
1889                        f" name was known. Block is:\n{journalBlock}"
1890                    )
1891
1892                # Get a new unique anonymous name
1893                anonName = parseFormat.anonName(roomName, content)
1894
1895                # Actually enter our detour room
1896                exploration.explore(
1897                    content,
1898                    anonName,
1899                    [], # No connections yet
1900                    content + '-return'
1901                )
1902
1903                # Tag the new room as anonymous
1904                now = exploration.currentGraph()
1905                now.tagDecision(anonName, 'anonymous')
1906
1907                # Remember transitions needed to get out of room
1908                thread: List[core.Transition] = []
1909
1910                # Parse in-room activity and create steps for it
1911                anonLines = anonymousContent.splitlines()
1912                for anonLine in anonLines:
1913                    anonLine = anonLine.strip()
1914                    try:
1915                        anonType, anonContent = parseFormat.determineEntryType(
1916                            anonLine
1917                        )
1918                    except JournalParseError:
1919                        # One liner that doesn't parse -> treat as tag(s)
1920                        anonType = 'tag'
1921                        anonContent = anonLine.strip()
1922                        if len(anonLines) > 1:
1923                            raise JournalParseError(
1924                                f"Detour on line #{lineNumber} has multiple"
1925                                f" lines but one cannot be parsed as an"
1926                                f" entry:\n{anonLine}\nBlock"
1927                                f" is:\n{journalBlock}"
1928                            )
1929
1930                    # Parse final notes, tags, and/or requirements
1931                    if anonType != 'note':
1932                        anonContent, note = parseFormat.splitFinalNote(
1933                            anonContent
1934                        )
1935                        anonContent, fTags, rTags = parseFormat.splitTags(
1936                            anonContent
1937                        )
1938                        (
1939                            anonContent,
1940                            forwardReq,
1941                            backReq
1942                        ) = parseFormat.splitRequirement(anonContent)
1943
1944                    if anonType == 'note':
1945                        here = exploration.currentPosition()
1946                        now.annotateDecision(here, anonContent)
1947                        # We don't handle multi-line notes in anon rooms
1948
1949                    elif anonType == 'tag':
1950                        tags = set(anonContent.split())
1951                        here = exploration.currentPosition()
1952                        now.tagDecision(here, tags)
1953                        if note is not None:
1954                            now.annotateDecision(here, note)
1955
1956                    elif anonType == 'progress':
1957                        makeProgressInRoom(
1958                            exploration,
1959                            parseFormat,
1960                            anonContent,
1961                            False,
1962                            forwardReq,
1963                            backReq,
1964                            fTags,
1965                            rTags,
1966                            [ note ] if note is not None else None
1967                            # No reverse annotations
1968                        )
1969                        # We don't handle multi-line notes in anon rooms
1970
1971                        # Remember the way back
1972                        # TODO: HERE Is this still accurate?
1973                        thread.append(anonContent + '-return')
1974
1975                    elif anonType in ('pickup', 'unclaimed', 'action'):
1976
1977                        if (
1978                            anonType == 'unclaimed'
1979                        and anonContent.startswith('?')
1980                        ):
1981                            fTags.add('unknown')
1982
1983                        # Note: these are both type Optional[str], but since
1984                        # they exist in another case, they can't be
1985                        # explicitly typed that way here. See:
1986                        # https://github.com/python/mypy/issues/1174
1987                        name = None
1988                        gains = None
1989                        if anonType == 'action':
1990                            name = anonContent
1991                        else:
1992                            gains = anonContent
1993
1994                        actionName = takeActionInRoom(
1995                            exploration,
1996                            parseFormat,
1997                            name,
1998                            gains,
1999                            forwardReq,
2000                            backReq,
2001                            fTags,
2002                            rTags,
2003                            anonType == 'unclaimed' # leave it untaken or not?
2004                        )
2005
2006                        # Limit scope
2007                        del name
2008                        del gains
2009
2010                    elif anonType == 'challenge':
2011                        here = exploration.currentPosition()
2012                        now.annotateDecision(
2013                            here,
2014                            "challenge: " + anonContent
2015                        )
2016
2017                    elif anonType in ('blocked', 'otherway'):
2018                        here = exploration.currentPosition()
2019
2020                        # Mark as blocked even when no explicit requirement
2021                        # has been provided
2022                        if forwardReq is None:
2023                            forwardReq = core.ReqImpossible()
2024                        if backReq is None and anonType == 'blocked':
2025                            backReq = core.ReqImpossible()
2026
2027                        now.addUnexploredEdge(
2028                            here,
2029                            anonContent,
2030                            tags=fTags,
2031                            revTags=rTags,
2032                            requires=forwardReq,
2033                            revRequires=backReq
2034                        )
2035
2036                    else:
2037                        # TODO: Any more entry types we need to support in
2038                        # anonymous rooms?
2039                        raise JournalParseError(
2040                            f"Detour on line #{lineNumber} includes an"
2041                            f" entry of type '{anonType}' which is not"
2042                            f" allowed in an anonymous room. Block"
2043                            f" is:\n{journalBlock}"
2044                        )
2045
2046                # If we made progress, backtrack to the start of the room
2047                for backwards in thread:
2048                    exploration.retrace(backwards)
2049
2050                # Now we exit back to the original room
2051                exploration.retrace(content + '-return')
2052
2053            elif eType == 'unify': # TODO: HERE
2054                pass
2055
2056            elif eType == 'obviate': # TODO: HERE
2057                # This represents a connection to somewhere we've been
2058                # before which is recognized but not traversed.
2059                # Note that when you want to use this to replace a mis-named
2060                # unexplored connection (which you now realize actually goes
2061                # to an existing sub-room, not a new one) you should just
2062                # oops that connection first, and then obviate to the actual
2063                # destination.
2064                if now is None:
2065                    raise JournalParseError(
2066                        f"On line {lineNumber}: Cannot obviate a transition"
2067                        f" before we've created the starting graph. Block"
2068                        f" is:\n{journalBlock}"
2069                    )
2070
2071                here = exploration.currentPosition()
2072
2073                # Two options: if the content lists a room:entrance combo in
2074                # brackets after a transition name, then it represents the
2075                # other side of a door from another room. If, on the other
2076                # hand, it just has a transition name, it represents a
2077                # sub-room name.
2078                content, otherSide = parseFormat.splitAnonymousRoom(content)
2079
2080                if otherSide is None:
2081                    # Must be in-room progress
2082                    # We create (but don't explore) a transition to that
2083                    # sub-room.
2084                    baseRoom = parseFormat.baseRoomName(here)
2085                    currentSubPart = parseFormat.roomPartName(here)
2086                    if currentSubPart is None:
2087                        currentSubPart = parseFormat.formatDict["progress"]
2088                    fromDecision = parseFormat.subRoomName(
2089                        baseRoomName,
2090                        content
2091                    )
2092
2093                    existingReciprocalDestination = now.getDestination(
2094                        fromDecision,
2095                        currentSubPart
2096                    )
2097                    # If the place we're linking to doesn't have a link back
2098                    # to us, then we just create a completely new link.
2099                    if existingReciprocalDestination is None:
2100                        pass
2101                        if now.getDestination(here, content):
2102                            pass
2103                        # TODO: HERE
2104                        # ISSUE: Sub-room links cannot just be named after
2105                        # their destination, because they might not be
2106                        # unique!
2107
2108                    elif now.isUnknown(existingReciprocalDestination):
2109                        pass
2110                        # TODO
2111
2112                    else:
2113                        # TODO
2114                        raise JournalParseError("")
2115
2116                    transitionName = content + '-return'
2117                    # fromDecision, incoming = fromOptions[0]
2118                    # TODO
2119                else:
2120                    # Here the content specifies an outgoing transition name
2121                    # and otherSide specifies the other side, so we don't
2122                    # have to search for anything
2123                    transitionName = content
2124
2125                    # Split decision name and transition name
2126                    fromDecision, incoming = parseFormat.parseSpecificTransition(
2127                        otherSide
2128                    )
2129                    dest = now.getDestination(fromDecision, incoming)
2130
2131                    # Check destination exists and is unknown
2132                    if dest is None:
2133                        # TODO: Look for alternate sub-room?
2134                        raise JournalParseError(
2135                            f"Obviate entry #{lineNumber} for transition"
2136                            f" {content} has invalid reciprocal transition"
2137                            f" {otherSide}. (Did you forget to specify the"
2138                            f" sub-room?)"
2139                        )
2140                    elif not now.isUnknown(dest):
2141                        raise JournalParseError(
2142                            f"Obviate entry #{lineNumber} for transition"
2143                            f" {content} has invalid reciprocal transition"
2144                            f" {otherSide}: that transition's destination"
2145                            f" is already known."
2146                        )
2147
2148                # Now that we know which edge we're obviating, do that
2149                # Note that while the other end is always an existing
2150                # transition to an unexplored destination, our end might be
2151                # novel, so we use replaceUnexplored from the other side
2152                # which allows it to do the work of creating the new
2153                # outgoing transition.
2154                now.replaceUnexplored(
2155                    fromDecision,
2156                    incoming,
2157                    here,
2158                    transitionName,
2159                    requirement=backReq, # flipped
2160                    revRequires=forwardReq,
2161                    tags=rTags, # also flipped
2162                    revTags=fTags,
2163                )
2164
2165            elif eType == 'challenge':
2166                # For now, these are just annotations
2167                if now is None:
2168                    raise JournalParseError(
2169                        f"On line {lineNumber}: Cannot annotate a challenge"
2170                        f" before we've created the starting graph. Block"
2171                        f" is:\n{journalBlock}"
2172                    )
2173
2174                here = exploration.currentPosition()
2175                now.annotateDecision(here, f"{eType}: " + content)
2176
2177            elif eType in ('warp', 'death'):
2178                # These warp the player without creating a connection
2179                if forwardReq or backReq:
2180                    raise JournalParseError(
2181                        f"'{eType}' entry #{lineNumber} cannot include"
2182                        f" requirements. Block is:\n{journalBlock}"
2183                    )
2184                if fTags or rTags:
2185                    raise JournalParseError(
2186                        f"'{eType}' entry #{lineNumber} cannot include"
2187                        f" tags. Block is:\n{journalBlock}"
2188                    )
2189
2190                try:
2191                    exploration.warp(
2192                        content,
2193                        'death' if eType == 'death' else ''
2194                    )
2195                    # TODO: Death effects?!?
2196                    # TODO: We could rewind until we're in a room marked
2197                    # 'save' and pick up that position and even state
2198                    # automatically ?!? But for save-anywhere games, we'd
2199                    # need to have some way of marking a save (could be an
2200                    # entry type that creates a special wait?).
2201                    # There could even be a way to clone the old graph for
2202                    # death, since things like tags applied would presumably
2203                    # not be? Or maybe some would and some wouldn't?
2204                except KeyError:
2205                    raise JournalParseError(
2206                        f"'{eType}' entry #{lineNumber} specifies"
2207                        f" non-existent destination '{content}'. Block"
2208                        f" is:\n{journalBlock}"
2209                    )
2210
2211            elif eType == 'runback':
2212                # For now, we just warp there and back
2213                # TODO: Actually trace the path of the runback...
2214                # TODO: Allow for an action to be taken at the destination
2215                # (like farming health, flipping a switch, etc.)
2216                if forwardReq or backReq:
2217                    raise JournalParseError(
2218                        f"Runback on line #{lineNumber} cannot include"
2219                        f" requirements. Block is:\n{journalBlock}"
2220                    )
2221                if fTags or rTags:
2222                    raise JournalParseError(
2223                        f"Runback on line #{lineNumber} cannot include tags."
2224                        f" Block is:\n{journalBlock}"
2225                    )
2226
2227                # Remember where we are
2228                here = exploration.currentPosition()
2229
2230                # Warp back to the runback point
2231                try:
2232                    exploration.warp(content, 'runaway')
2233                except KeyError:
2234                    raise JournalParseError(
2235                        f"Runback on line #{lineNumber} specifies"
2236                        f" non-existent destination '{content}'. Block"
2237                        f" is:\n{journalBlock}"
2238                    )
2239
2240                # Then warp back to the current decision
2241                exploration.warp(here, 'runback')
2242
2243            elif eType == 'traverse':
2244                # For now, we just warp there
2245                # TODO: Actually trace the path of the runback...
2246                if forwardReq or backReq:
2247                    raise JournalParseError(
2248                        f"Traversal on line #{lineNumber} cannot include"
2249                        f" requirements. Block is:\n{journalBlock}"
2250                    )
2251                if fTags or rTags:
2252                    raise JournalParseError(
2253                        f"Traversal on line #{lineNumber} cannot include tags."
2254                        f" Block is:\n{journalBlock}"
2255                    )
2256
2257                if now is None:
2258                    raise JournalParseError(
2259                        f"Cannot traverse sub-rooms on line #{lineNumber}"
2260                        f" before exploration is started. Block"
2261                        f" is:\n{journalBlock}"
2262                    )
2263
2264                # Warp to the destination
2265                here = exploration.currentPosition()
2266                destination = parseFormat.getSubRoom(now, here, content)
2267                if destination is None:
2268                    raise JournalParseError(
2269                        f"Traversal on line #{lineNumber} specifies"
2270                        f" non-existent sub-room destination '{content}' in"
2271                        f" room '{parseFormat.baseRoomName(here)}'. Block"
2272                        f" is:\n{journalBlock}"
2273                    )
2274                else:
2275                    exploration.warp(destination, 'traversal')
2276
2277            elif eType == 'ending':
2278                if now is None:
2279                    raise JournalParseError(
2280                        f"On line {lineNumber}: Cannot annotate an ending"
2281                        f" before we've created the starting graph. Block"
2282                        f" is:\n{journalBlock}"
2283                    )
2284
2285                if backReq:
2286                    raise JournalParseError(
2287                        f"Ending on line #{lineNumber} cannot include"
2288                        f" reverse requirements. Block is:\n{journalBlock}"
2289                    )
2290
2291                # Create ending
2292                here = exploration.currentPosition()
2293                # Reverse tags are applied to the ending room itself
2294                now.addEnding(
2295                    here,
2296                    content,
2297                    tags=fTags,
2298                    endTags=rTags,
2299                    requires=forwardReq
2300                )
2301                # Transition to the ending
2302                print("ED RT", here, content, len(exploration))
2303                exploration.retrace('_e:' + content)
2304                print("ED RT", len(exploration))
2305                ended = True
2306
2307            elif eType == 'tag':
2308                tagsToApply = set(content.split())
2309                if fTags or rTags:
2310                    raise JournalParseError(
2311                        f"Found tags on tag entry on line #{lineNumber}"
2312                        f" of block:\n{journalBlock}"
2313                    )
2314
2315                if now is None:
2316                    raise JournalParseError(
2317                        f"On line {lineNumber}: Cannot add a tag before"
2318                        f" we've created the starting graph. Block"
2319                        f" is:\n{journalBlock}"
2320                    )
2321
2322                here = exploration.currentPosition()
2323                now.tagDecision(here, tagsToApply)
2324
2325            else:
2326                raise NotImplementedError(
2327                    f"Unhandled entry type '{eType}' (fix"
2328                    f" updateExplorationFromEntry)."
2329                )
2330
2331            # Note: at this point, currentNote must be None. If there is an
2332            # end-of-line note, set up currentNote to apply that to whatever
2333            # is on this line.
2334            if note is not None:
2335                if eType in (
2336                    'entrance',
2337                    'exit',
2338                    'blocked',
2339                    'otherway',
2340                    'unexplored',
2341                    'unexploredOneway',
2342                    'progress'
2343                    'oneway',
2344                    'hiddenOneway',
2345                    'detour'
2346                ):
2347                    # Annotate a specific transition
2348                    target = (exploration.currentPosition(), content)
2349
2350                elif eType in (
2351                    'pickup',
2352                    'unclaimed',
2353                    'action',
2354                ):
2355                    # Action name might be auto-generated
2356                    target = (
2357                        exploration.currentPosition(),
2358                        actionName
2359                    )
2360
2361                else:
2362                    # Default: annotate current room
2363                    target = exploration.currentPosition()
2364
2365                # Set current note value for accumulation
2366                currentNote = (
2367                    target,
2368                    True, # all post-entry notes count as indented
2369                    f"(step #{len(exploration)}) " + note
2370                )
2371
2372        # If we ended, return None
2373        if ended:
2374            return None
2375        elif exitRoom is None or exitTransition is None:
2376            raise JournalParseError(
2377                f"Missing exit room and/or transition ({exitRoom},"
2378                f" {exitTransition}) at end of journal"
2379                f" block:\n{journalBlock}"
2380            )
2381
2382        return exitRoom, exitTransition
2383
2384    def observeNote(
2385        self,
2386        noteText: str,
2387        indented: bool = False,
2388        target: Optional[
2389            Union[core.Decision, Tuple[core.Decision, core.Transition]]
2390        ] = None
2391    ) -> None:
2392        """
2393        Observes a whole-line note in a journal, which may or may not be
2394        indented (level of indentation is ignored). Creates or extends
2395        the current pending note, or applies that note and starts a new
2396        one if the indentation statues or targets are different. Except
2397        in that case, no change is made to the exploration or its
2398        graphs; the annotations are actually applied when
2399        `applyCurrentNote` is called.
2400
2401        ## Example
2402
2403        >>> obs = JournalObserver()
2404        >>> obs.observe('[Room]\\n? Left\\n')
2405        >>> obs.observeNote('hi')
2406        >>> obs.observeNote('the same note')
2407        >>> obs.observeNote('a new note', indented=True) # different indent
2408        >>> obs.observeNote('another note', indented=False)
2409        >>> obs.observeNote('this applies to Left', target=('Room', 'Left'))
2410        >>> obs.observeNote('more') # same target by implication
2411        >>> obs.observeNote('another', target='Room') # different target
2412        >>> e = obs.getExploration()
2413        >>> m = e.currentGraph()
2414        >>> m.decisionAnnotations('Room') # Last note is not here yet...
2415        ['hi\\nthe same note', 'a new note', 'another note']
2416        >>> m.transitionAnnotations('Room', 'Left')
2417        ['this applies to Left\\nmore']
2418        >>> m.applyCurrentNote()
2419        >>> m.decisionAnnotations('Room') # Last note is not here yet...
2420        ['hi\\nthe same note', 'a new note', 'another note', 'another']
2421        """
2422
2423        # whole line is a note; handle new vs. continuing note
2424        if self.currentNote is None:
2425            # Start a new note
2426            if target is None:
2427                target = self.exploration.currentPosition()
2428            self.currentNote = (
2429                target,
2430                indented,
2431                f"(step #{len(self.exploration)}) " + noteText
2432            )
2433        else:
2434            # Previous note exists, use indentation & target to decide
2435            # if we're continuing or starting a new note
2436            oldTarget, wasIndented, prevText = self.currentNote
2437            if (
2438                indented != wasIndented
2439             or (target is not None and target != oldTarget)
2440            ):
2441                # Then we apply the old note and create a new note (at
2442                # the decision level by default)
2443                self.applyCurrentNote()
2444                self.currentNote = (
2445                    target or self.exploration.currentPosition(),
2446                    indented,
2447                    f"(step #{len(self.exploration)}) " + noteText
2448                )
2449            else:
2450                # Else indentation matched and target either matches or
2451                # was None, so add to previous note
2452                self.currentNote = (
2453                    oldTarget,
2454                    wasIndented,
2455                    prevText + '\n' + noteText
2456                )
2457
2458    def applyCurrentNote(self) -> None:
2459        """
2460        If there is a note waiting to be either continued or applied,
2461        applies that note to whatever it is targeting, and clears it.
2462        Does nothing if there is no pending note.
2463
2464        See `observeNote` for an example.
2465        """
2466        if self.currentNote is not None:
2467            target, _, noteText = self.currentNote
2468            self.currentNote = None
2469            # Apply our annotation to the room or transition it targets
2470            # TODO: Annotate the exploration instead?!?
2471            if isinstance(target, str):
2472                self.exploration.currentGraph().annotateDecision(
2473                    target,
2474                    noteText
2475                )
2476            else:
2477                room, transition = target
2478                self.exploration.currentGraph().annotateTransition(
2479                    room,
2480                    transition,
2481                    noteText
2482                )
2483
2484    def makeProgressInRoom(
2485        self,
2486        subRoomName: core.Decision,
2487        transitionName: Optional[core.Transition] = None,
2488        oneway: Union[bool, str] = False,
2489        requires: Optional[core.Requirement] = None,
2490        revRequires: Optional[core.Requirement] = None,
2491        tags: Optional[Set[core.Tag]] = None,
2492        revTags: Optional[Set[core.Tag]] = None,
2493        annotations: Optional[List[core.Annotation]] = None,
2494        revAnnotations: Optional[List[core.Annotation]] = None
2495    ) -> None:
2496        """
2497        Updates the exploration state to indicate that movement to a new
2498        sub-room has occurred. Handles three cases: a
2499        previously-observed but unexplored sub-room, a
2500        never-before-observed sub-room, and a previously-visited
2501        sub-room. By using the parse format's progress marker (default
2502        '-') as the room name, a transition to the base subroom can be
2503        specified.
2504
2505        The destination sub-room name is required, and the exploration
2506        object's current position will dictate which decision the player
2507        is currently at. If no transition name is specified, the
2508        transition name will be the same as the destination name (only
2509        the provided sub-room part) or the same as the first previous
2510        transition to the specified destination from the current
2511        location is such a transition already exists. Optional arguments
2512        may specify requirements, tags, and/or annotations to be applied
2513        to the transition, and requirements, tags, and/or annotations
2514        for the reciprocal transition; these will be applied in the new
2515        graph that results, but not retroactively. If the transition is
2516        a one-way transition, set `oneway` to True (default is False).
2517        `oneway` may also be set to the string 'hidden' to indicate a
2518        hidden one-way. The `newConnection` argument should be set to
2519        True (default False) if a new connection should be created even
2520        in cases where a connection already exists.
2521
2522        ## Example:
2523
2524        >>> obs = JournalObserver()
2525        >>> obs.observe("[Room]\\n< T")
2526        >>> obs.makeProgressInRoom("subroom")
2527        >>> e = obs.getExploration()
2528        >>> len(e)
2529        2
2530        >>> e.currentPosition()
2531        'Room%subroom'
2532        >>> g = e.currentGraph()
2533        >>> g.destinationsFrom("Room")
2534        { 'T': '_u.0', 'subroom': 'Room%subroom' }
2535        >>> g.destinationsFrom("Room%subroom")
2536        { '-': 'Room' }
2537        >>> obs.makeProgressInRoom("-") # Back to base subroom
2538        >>> len(e)
2539        3
2540        >>> e.currentPosition()
2541        'Room'
2542        >>> g = e.currentGraph()
2543        >>> g.destinationsFrom("Room")
2544        { 'T': '_u.0', 'subroom': 'Room%subroom' }
2545        >>> g.destinationsFrom("Room%subroom")
2546        { '-': 'Room' }
2547        >>> obs.makeProgressInRoom(
2548        ...   "other",
2549        ...   oneway='hidden',
2550        ...   tags={"blue"},
2551        ...   requires=core.ReqPower("fly"),
2552        ...   revRequires=core.ReqAll(
2553        ...     core.ReqPower("shatter"),
2554        ...     core.ReqPower("fly")
2555        ...   ),
2556        ...   revTags={"blue"},
2557        ...   annotations=["Another subroom"],
2558        ...   revAnnotations=["This way back"],
2559        ... )
2560        >>> len(e)
2561        4
2562        >>> e.currentPosition()
2563        'Room%other'
2564        >>> g = e.currentGraph()
2565        >>> g.destinationsFrom("Room")
2566        { 'T': '_u.0', 'subroom': 'Room%subroom', 'other': 'Room%other' }
2567        >>> g.destinationsFrom("Room%subroom")
2568        { '-': 'Room' }
2569        >>> g.destinationsFrom("Room%other")
2570        { '-': 'Room' }
2571        >>> g.getTransitionRequirement("Room", "other")
2572        ReqPower('fly')
2573        >>> g.getTransitionRequirement("Room%other", "-")
2574        ReqAll(ReqPower('shatter'), ReqPower('fly'))
2575        >>> g.transitionTags("Room", "other")
2576        {'blue'}
2577        >>> g.transitionTags("Room%other", "-")
2578        {'blue'}
2579        >>> g.transitionAnnotations("Room", "other")
2580        ['Another subroom']
2581        >>> g.transitionAnnotations("Room%other", "-")
2582        ['This way back']
2583        >>> prevM = e.graphAtStep(-2)
2584        >>> prevM.destinationsFrom("Room")
2585        { 'T': '_u.0', 'subroom': 'Room%subroom', 'other': '_u.2' }
2586        >>> prevM.destinationsFrom("Room%subroom")
2587        { '-': 'Room' }
2588        >>> "Room%other" in prevM
2589        False
2590        >>> obs.makeProgressInRoom("-", transitionName="-.1", oneway=True)
2591        >>> len(e)
2592        5
2593        >>> e.currentPosition()
2594        'Room'
2595        >>> g = e.currentGraph()
2596        >>> d = g.destinationsFrom("Room")
2597        >>> g['T']
2598        '_u.0'
2599        >>> g['subroom']
2600        'Room%subroom'
2601        >>> g['other']
2602        'Room%other'
2603        >>> g['other.1']
2604        'Room%other'
2605        >>> g.destinationsFrom("Room%subroom")
2606        { '-': 'Room' }
2607        >>> g.destinationsFrom("Room%other")
2608        { '-': 'Room', '-.1': 'Room' }
2609        >>> g.getTransitionRequirement("Room", "other")
2610        ReqPower('fly')
2611        >>> g.getTransitionRequirement("Room%other", "-")
2612        ReqAll(ReqPower('shatter'), ReqPower('fly'))
2613        >>> g.getTransitionRequirement("Room", "other.1")
2614        ReqImpossible()
2615        >>> g.getTransitionRequirement("Room%other", "-.1")
2616        ReqNothing()
2617        """
2618
2619        # Default argument values
2620        if transitionName is None:
2621            transitionName = subRoomName
2622        if tags is None:
2623            tags = set()
2624        if revTags is None:
2625            revTags = set()
2626        if annotations is None:
2627            annotations = []
2628        if revAnnotations is None:
2629            revAnnotations = []
2630
2631        # Tag the transition with 'internal' since this is in-room progress
2632        tags.add('internal')
2633
2634        # Get current stuff
2635        now = self.exploration.currentGraph()
2636        here = self.exploration.currentPosition()
2637        outgoing = now.destinationsFrom(here)
2638        base = self.parseFormat.baseRoomName(here)
2639        currentSubPart = self.parseFormat.roomPartName(here)
2640        if currentSubPart is None:
2641            currentSubPart = self.parseFormat.formatDict["progress"]
2642        destination = self.parseFormat.subRoomName(base, subRoomName)
2643        isNew = destination not in now
2644
2645        # Handle oneway settings (explicit requirements override them)
2646        if oneway is True and revRequires is None: # not including 'hidden'
2647            revRequires = core.ReqImpossible()
2648
2649        # Did we end up creating a new subroom?
2650        createdSubRoom = False
2651
2652        # A hidden oneway applies both explicit and implied transition
2653        # requirements only after the transition has been taken
2654        if oneway == "hidden":
2655            postRevReq: Optional[core.Requirement] = None
2656            if revRequires is None:
2657                postRevReq = core.ReqImpossible()
2658            else:
2659                postRevReq = revRequires
2660            revRequires = None
2661        else:
2662            postRevReq = revRequires
2663
2664        # Are we going somewhere new, or not?
2665        if transitionName in outgoing: # A transition we've seen before
2666            rev = now.getReciprocal(here, transitionName)
2667            if not now.isUnknown(destination): # Just retrace it
2668                self.exploration.retrace(transitionName)
2669            else: # previously unknown
2670                self.exploration.explore(
2671                    transitionName,
2672                    destination,
2673                    [],
2674                    rev # No need to worry here about collisions
2675                )
2676                createdSubRoom = True
2677
2678        else: # A new connection (not necessarily destination)
2679            # Find a unique name for the returning connection
2680            rev = currentSubPart
2681            if not isNew:
2682                rev = core.uniqueName(
2683                    rev,
2684                    now.destinationsFrom(destination)
2685                )
2686
2687            # Add an unexplored transition and then explore it
2688            if not isNew and now.isUnknown(destination):
2689                # Connecting to an existing unexplored region
2690                now.addTransition(
2691                    here,
2692                    transitionName,
2693                    destination,
2694                    rev,
2695                    tags=tags,
2696                    annotations=annotations,
2697                    requires=requires,
2698                    revTags=revTags,
2699                    revAnnotations=revAnnotations,
2700                    revRequires=revRequires
2701                )
2702            else:
2703                # Connecting to a new decision or one that's not
2704                # unexplored
2705                now.addUnexploredEdge(
2706                    here,
2707                    transitionName,
2708                    # auto unexplored name
2709                    reciprocal=rev,
2710                    tags=tags,
2711                    annotations=annotations,
2712                    requires=requires,
2713                    revTags=revTags,
2714                    revAnnotations=revAnnotations,
2715                    revRequires=revRequires
2716                )
2717
2718
2719            # Explore the unknown we just created
2720            if isNew or now.isUnknown(destination):
2721                # A new destination: create it
2722                self.exploration.explore(
2723                    transitionName,
2724                    destination,
2725                    [],
2726                    rev # No need to worry here about collisions
2727                )
2728                createdSubRoom = True
2729            else:
2730                # An existing destination: return to it
2731                self.exploration.returnTo(
2732                    transitionName,
2733                    destination,
2734                    rev
2735                )
2736
2737        # Overwrite requirements, tags, and annotations
2738        # based on any new info. TODO: Warn if new info is
2739        # mismatched with old info?
2740        newGraph = self.exploration.currentGraph()
2741        newPos = self.exploration.currentPosition()
2742        if requires is not None:
2743            self.exploration.updateRequirementNow(
2744                here,
2745                subRoomName,
2746                requires
2747            )
2748        newGraph.tagTransition(here, subRoomName, tags)
2749        newGraph.annotateTransition(here, subRoomName, annotations)
2750
2751        # If there's a reciprocal, apply any specified tags,
2752        # annotations, and/or requirements to it.
2753        reciprocal = newGraph.getReciprocal(here, subRoomName)
2754        if reciprocal is not None:
2755            newGraph.tagTransition(newPos, reciprocal, revTags)
2756            newGraph.annotateTransition(
2757                newPos,
2758                reciprocal,
2759                revAnnotations
2760            )
2761            if revRequires is not None:
2762                newGraph.setTransitionRequirement(
2763                    newPos,
2764                    reciprocal,
2765                    postRevReq
2766                )
2767
2768    def takeActionInRoom(
2769        self,
2770        name: Optional[core.Transition] = None,
2771        gain: Optional[str] = None,
2772        forwardReq: Optional[core.Requirement] = None,
2773        extraGain: Optional[core.Requirement] = None,
2774        fTags: Optional[Set[core.Tag]] = None,
2775        rTags: Optional[Set[core.Tag]] = None,
2776        untaken: bool = False
2777    ) -> core.Transition:
2778        """
2779        Adds an action to the current room, and takes it. The exploration to
2780        modify and the parse format to use are required. If a name for the
2781        action is not provided, a unique name will be generated. If the
2782        action results in gaining an item, the item gained should be passed
2783        as a string (will be parsed using `ParseFormat.parseItem`).
2784        Forward/backward requirements and tags may be provided, but passing
2785        anything other than None for the backward requirement or tags will
2786        result in a `JournalParseError`.
2787
2788        If `untaken` is set to True (default is False) then the action will
2789        be created, but will not be taken.
2790
2791        Returns the name of the transition, which is either the specified
2792        name or a unique name created automatically.
2793        """
2794        # Get current info
2795        here = self.exploration.currentPosition()
2796        now = self.exploration.currentGraph()
2797
2798        # Assign a unique action name if none was provided
2799        wantsUnique = False
2800        if name is None:
2801            wantsUnique = True
2802            name = f"action@{len(exploration)}"
2803
2804        # Accumulate powers/tokens gained
2805        gainedStuff = []
2806        # Parse item gained if there is one, and add it to the action name
2807        # as well
2808        if gain is not None:
2809            gainedStuff.append(parseFormat.parseItem(gain))
2810            name += gain
2811
2812        # Reverse requirements are translated into extra powers/tokens gained
2813        # (but may only be a disjunction of power/token requirements).
2814        # TODO: Allow using ReqNot to instantiate power-removal/token-cost
2815        # effects!!!
2816        if extraGain is not None:
2817            gainedStuff.extend(extraGain.asGainList())
2818
2819        if len(gainedStuff) > 0:
2820            effects = core.effects(gain=gainedStuff)
2821        else:
2822            effects = core.effects() # no effects
2823
2824        # Ensure that action name is unique
2825        if wantsUnique:
2826            # Find all transitions that start with this name which have a
2827            # '.' in their name.
2828            already = [
2829                transition
2830                for transition in now.destinationsFrom(here)
2831                if transition.startswith(name) and '.' in transition
2832            ]
2833
2834            # Collect just the numerical parts after the dots
2835            nums = []
2836            for prev in already:
2837                try:
2838                    nums.append(int(prev.split('.')[-1]))
2839                except ValueError:
2840                    pass
2841
2842            # If there aren't any (or aren't any with a .number part), make
2843            # the name unique by adding '.1'
2844            if len(nums) == 0:
2845                name = name + '.1'
2846            else:
2847                # If there are nums, pick a higher one
2848                name = name + '.' + str(max(nums) + 1)
2849
2850        # TODO: Handle repeatable actions with effects, and other effect
2851        # types...
2852
2853        if rTags:
2854            raise JournalParseError(
2855                f"Cannot apply reverse tags {rTags} to action '{name}' in"
2856                f" room {here}: Actions have no reciprocal."
2857            )
2858
2859        # Create and/or take the action
2860        if untaken:
2861            now.addAction(
2862                here,
2863                name,
2864                forwardReq, # might be None
2865                effects
2866            )
2867        else:
2868            exploration.takeAction(
2869                name,
2870                forwardReq, # might be None
2871                effects
2872            )
2873
2874        # Apply tags to the action transition
2875        if fTags is not None:
2876            now = exploration.currentGraph()
2877            now.tagTransition(here, name, fTags)
2878
2879        # Return the action name
2880        return name
2881
2882    def observeRoomEntrance(
2883        self,
2884        transitionTaken: core.Transition,
2885        roomName: core.Decision,
2886        revName: Optional[core.Transition] = None,
2887        oneway: bool = False,
2888        fReq: Optional[core.Requirement] = None,
2889        rReq: Optional[core.Requirement] = None,
2890        fTags: Optional[Set[core.Tag]] = None,
2891        rTags: Optional[Set[core.Tag]] = None
2892    ):
2893        """
2894        Records entry into a new room via a specific transition from the
2895        current position, creating a new unexplored node if necessary
2896        and then exploring it, or returning to or retracing an existing
2897        decision/transition.
2898        """
2899
2900        # TODO: HERE

Keeps track of extra state needed when parsing a journal in order to produce a core.Exploration object. The methods of this class act as an API for constructing explorations that have several special properties (for example, some transitions are tagged 'internal' and decision names are standardized so that a pattern of "rooms" emerges above the decision level). 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 some ambiguous entries which cannot be directly interpreted until further entries are observed. The basic usage is as follows:

  1. Create a JournalObserver, optionally specifying a custom ParseFormat.
  2. Repeatedly either:
    • Call observe* 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.applyState to clear any remaining un-finalized state.
  4. Call JournalObserver.getExploration to retrieve the core.Exploration object that's been created.

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 observed). 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, simply use the core.Exploration.currentGraph method of the JournalObserver.getExploration result.
  • If you don't call JournalObserver.applyState some entries may not have affected the exploration yet, because they're ambiguous and further entries need to be observed (or applyState needs to be called) to resolve that ambiguity.

Example

>>> obs = JournalObserver()
>>> obs.getExploration() is None
True
>>> # We start by using the observe* methods...
>>> obs.observeRoom("Start") # no effect until entrance is observed
>>> obs.getExploration() is None
True
>>> obs.observeProgress("bottom") # New sub-room within current room
>>> e = obs.getExploration()
>>> len(e) # base state + first movement
2
>>> e.positionAtStep(0)
'Start'
>>> e.positionAtStep(1)
'Start%bottom'
>>> e.transitionAtStep(0)
'bottom'
>>> obs.observeOneway("R") # no effect yet (might be one-way progress)
>>> len(e)
2
>>> obs.observeRoom("Second") # Need to know entrance
>>> len(e) # oneway is now understood to be an inter-room transition
2
>>> obs.observeProgress("bad") # Need to see an entrance first!
Traceback (most recent call last):
...
exploration.journal.JournalParseError...
>>> obs.observeEntrance("L")
>>> len(e) # Now full transition can be mapped
3
>>> e.positionAtStep(2)
'Second'
>>> e.transitionAtStep(1)
'R'
>>> e.currentGraph().getTransitionRequirement('Second', 'L')
ReqImpossible()
>>> # Now we demonstrate the use of "observe"
>>> obs.observe("x< T (tall)\n? R\n> B\n\n[Third]\nx< T")
>>> len(e)
4
>>> m2 = e.graphAtStep(2) # Updates were applied without adding a step
>>> m2.getDestination('Second', 'T')
'_u.1'
>>> m2.getTransitionRequirement('Second', 'T')
ReqPower('tall')
>>> m2.getDestination('Second', 'R')
'_u.2'
>>> m2.getDestination('Second', 'B')
'_u.3'
>>> m = e.currentGraph()
>>> m == e.graphAtStep(3)
>>> m.getDestination('Second', 'B')
'Third'
>>> m.getDestination('Third', 'T')
'Second'
>>> m.getTransitionRequirement('Third', 'T') # Due to 'x<' for entrance
ReqImpossible()
JournalObserver(parseFormat: Optional[exploration.oldJournal.ParseFormat] = None)
1143    def __init__(self, parseFormat: Optional[ParseFormat] = None):
1144        """
1145        Sets up the observer. If a parse format is supplied, that will
1146        be used instead of the default parse format, which is just the
1147        result of creating a `ParseFormat` with default arguments.
1148        """
1149        if parseFormat is not None:
1150            self.parseFormat = parseFormat
1151
1152        # Create  blank exploration
1153        self.exploration = core.Exploration()
1154
1155        # State variables
1156
1157        # Tracks the current room name and tags for the room, once a
1158        # room has been declared
1159        self.currentRoomName: Optional[core.Decision] = None
1160        self.currentRoomTags: Set[core.Tag] = set()
1161
1162        # Whether we've seen an entrance/exit yet in the current room
1163        self.seenRoomEntrance = False
1164
1165        # The room & transition used to exit
1166        self.previousRoom: Optional[core.Decision] = None
1167        self.previousTransition: Optional[core.Transition] = None
1168
1169        # The room & transition identified as our next source/transition
1170        self.exitTransition = None
1171
1172        # This tracks the current note text, since notes can continue
1173        # across multiple lines
1174        self.currentNote: Optional[Tuple[
1175            Union[
1176                core.Decision,
1177                Tuple[core.Decision, core.Transition]
1178            ], # target
1179            bool, # was this note indented?
1180            str # note text
1181        ]] = None
1182
1183        # Tracks a pending progress step, since things like a oneway can
1184        # be used for either within-room progress OR room-to-room
1185        # transitions.
1186        self.pendingProgress: Optional[Tuple[
1187            core.Decision, # destination of progress (maybe just sub-part)
1188            Optional[core.Transition], # transition name (None -> auto)
1189            Union[bool, str], # is it one-way; 'hidden' for a hidden one-way?
1190            Optional[core.Requirement], # requirement for the transition
1191            Optional[core.Requirement], # reciprocal requirement
1192            Optional[Set[core.Tag]], # tags to apply
1193            Optional[Set[core.Tag]], # reciprocal tags
1194            Optional[List[core.Annotation]], # annotations to apply
1195            Optional[List[core.Annotation]] # reciprocal annotations
1196        ]] = None
1197
1198        # This tracks the current entries in an inter-room abbreviated
1199        # path, since we first have to accumulate all of them and then
1200        # do pathfinding to figure out a concrete inter-room path.
1201        self.interRoomPath: List[
1202            Union[Type[InterRoomEllipsis], core.Decision]
1203        ] = []
1204
1205        # Tracks presence of an end entry, which must be final in the
1206        # block it occurs in except for notes or tags.
1207        self.blockEnded = False

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.

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 observed because in some cases entries are ambiguous and are not translated into exploration steps until a further entry resolves that ambiguity.

def observe(self, journalText: str) -> None:
1209    def observe(self, journalText: str) -> None:
1210        """
1211        Ingests one or more journal blocks in text format (as a
1212        multi-line string) and updates the exploration being built by
1213        this observer, as well as updating internal state. Note that
1214        without later calling `applyState`, some parts of the observed
1215        entries may remain saved as internal state that hasn't yet been
1216        disambiguated and applied to the exploration. jor example, a
1217        final one-way transition could indicate in-room one-way
1218        progress, or a one-way transition to another room, and this is
1219        disambiguated by observing whether the next entry is another
1220        entry in the same block or a blank line to indicate the end of a
1221        block.
1222
1223        This method can be called multiple times to process a longer
1224        journal incrementally including line-by-line. If you give it an
1225        empty string, that will count as the end of a journal block (or
1226        a continuation of space between blocks).
1227
1228        ## Example:
1229
1230        >>> obs = JournalObserver()
1231        >>> obs.observe('''\\
1232        ... [Room1]
1233        ... < Top " Comment
1234        ... x nope (power|tokens*3)
1235        ... ? unexplored
1236        ... -> sub_room " This is a one-way transition
1237        ... -> - " The default sub-room is named '-'
1238        ... > Bottom
1239        ...
1240        ... [Room2]
1241        ... < Top
1242        ... * switch " Took an action in this room
1243        ... ? Left
1244        ... > Right {blue}
1245        ...
1246        ... [Room3]
1247        ... < Left
1248        ... # Miniboss " Faced a challenge
1249        ... . power " Get a power
1250        ... >< Right [
1251        ...    - ledge (tall)
1252        ...    . treasure
1253        ... ] " Detour to an anonymous room
1254        ... > Left
1255        ...
1256        ... - Room2 " Visited along the way
1257        ... [Room1]
1258        ... - nope " Entrance may be omitted if implied
1259        ... > Right
1260        ... ''')
1261        >>> e = obs.getExploration()
1262        >>> len(e)
1263        12
1264        >>> m = e.currentGraph()
1265        >>> len(m)
1266        11
1267        >>> def showDestinations(m, r):
1268        ...     d = m.destinationsFrom(r)
1269        ...     for outgoing in d:
1270        ...         req = m.getTransitionRequirement(r, outgoing)
1271        ...         if req is None:
1272        ...             req = ''
1273        ...         else:
1274        ...             req = ' (' + repr(req) + ')'
1275        ...         print(outgoing, d[outgoing] + req)
1276        ...
1277        >>> showDestinations(m, "Room1")
1278        Top _u.0
1279        nope Room1%nope ReqAny(ReqPower("power"), ReqTokens("tokens", 3))
1280        unexplored _u.1
1281        sub_room Room1%sub_room
1282        sub_room.1 Room1%sub_room ReqImpossible()
1283        Bottom: Room2
1284        >>> showDestinations(m, "Room1%nope")
1285        - Room1 ReqAny(ReqPower("power"), ReqTokens("tokens", 3))
1286        Right _u.3
1287        >>> showDestinations(m, "Room1%sub_room")
1288        - Room1 ReqImpossible()
1289        -.1 Room1
1290        >>> showDestinations(m, "Room2")
1291        Top Room1
1292        action@5 Room2
1293        Left _u.2
1294        Right: Room3
1295        >>> m.transitionTags("Room3", "Right")
1296        {'blue'}
1297        >>> showDestinations(m, "Room3")
1298        Left Room2
1299        action@7 Room3
1300        Right Room3$Right
1301        >>> showDestinations(m, "Room3$Right")
1302        ledge Room3$Right%ledge ReqPower("tall")
1303        return Room3
1304        >>> showDestinations(m, "Room3$Right%ledge")
1305        - Room3$Right
1306        action@9 Room3$Right%ledge
1307        >>> m.decisionAnnotations("Room3")
1308        ['challenge: Miniboss']
1309        >>> e.currentPosition()
1310        'Room1%nope'
1311
1312        Note that there are plenty of other annotations not shown in
1313        this example; see `DEFAULT_FORMAT` for the default mapping from
1314        journal entry types to markers, and see `JournalEntryType` for
1315        the explanation for each entry type.
1316
1317        Most entries start with a marker followed by a single space, and
1318        everything after that is the content of the entry. A few
1319        different modifiers are removed from the right-hand side of
1320        entries first:
1321
1322        - Notes starting with `"` by default and going to the end of the
1323            line, possibly continued on other lines that are indented
1324            and start with the note marker.
1325        - Tags surrounded by `{` and `}` by default and separated from
1326            each other by commas and optional spaces. These are applied
1327            to the current room (if alone on a line) or to the decision
1328            or transition implicated in the line they're at the end of.
1329        - Requirements surrounded by `(` and `)` by default, with `/`
1330            used to separate forward/reverse requirements. These are
1331            applied to the transition implicated by the rest of the
1332            line, and are not allowed on lines that don't imply a
1333            transition. The contents are parsed into a requirement using
1334            `core.Requirement.parse`. Warnings may be issued for
1335            requirements specified on transitions that are taken which
1336            are not met at the time.
1337        - For detours and a few other select entry types, anonymous room
1338            or transition info may be surrounded by `[` and `]` at the
1339            end of the line. For detours, there may be multiple lines
1340            between `[` and `]` as shown in the example above.
1341        """
1342        # Normalize newlines
1343        journalText = journalText\
1344            .replace('\r\n', '\n')\
1345            .replace('\n\r', '\n')\
1346            .replace('\r', '\n')
1347
1348        # Line splitting variables
1349        lineNumber = 0 # first iteration will increment to 1 before use
1350        cursor = 0 # Character index into the block tracking progress
1351        journalLen = len(journalText) # So we know when to stop
1352        lineIncrement = 1 # How many lines we've processed
1353        thisBlock = '' # Lines in this block of the journal
1354
1355        # Shortcut variable
1356        pf = self.parseFormat
1357
1358        # Parse each line separately, but collect multiple lines for
1359        # multi-line entries such as detours
1360        while cursor < journalLen:
1361            lineNumber += lineIncrement
1362            lineIncrement = 1
1363            try:
1364                # Find the next newline
1365                nextNL = journalText.index('\n', cursor)
1366                fullLine = journalText[cursor:nextNL]
1367                cursor = nextNL + 1
1368            except ValueError:
1369                # If there isn't one, rest of the journal is the next line
1370                fullLine = journalText[cursor:]
1371                cursor = journalLen
1372
1373            thisBlock += fullLine + '\n'
1374
1375            # TODO: DEBUG
1376            print("LL", lineNumber, fullLine)
1377
1378            # Check for and split off anonymous room content
1379            line, anonymousContent = pf.splitAnonymousRoom(fullLine)
1380            if (
1381                anonymousContent is None
1382            and pf.startsAnonymousRoom(fullLine)
1383            ):
1384                endIndex = pf.anonymousRoomEnd(journalText, cursor)
1385                if endIndex is None:
1386                    raise JournalParseError(
1387                        f"Anonymous room started on line {lineNumber}"
1388                        f" was never closed in block:\n{thisBlock}\n..."
1389                    )
1390                anonymousContent = journalText[nextNL + 1:endIndex].strip()
1391                thisBlock += anonymousContent + '\n'
1392                # TODO: Is this correct?
1393                lineIncrement = anonymousContent.count('\n') + 1
1394                # Skip to end of line where anonymous room ends
1395                cursor = journalText.index('\n', endIndex + 1)
1396
1397                # Trim the start of the anonymous room from the line end
1398                line = line.rstrip()[:-1]
1399
1400            # Blank lines end one block and start another
1401            if not line.strip():
1402                thisBlock = ''
1403                lineNumber = 0
1404                self.previousRoom = self.exploration.currentPosition()
1405                self.previousTransition = self.exitTransition
1406                self.exitTransition = None
1407                self.currentRoomName = None
1408                self.blockEnded = False
1409                # TODO: More inter-block state here...!
1410                continue
1411
1412            # Check for indentation (mostly ignored, but important for
1413            # comments).
1414            indented = line[0] == ' '
1415
1416            # Strip indentation going forward
1417            line = line.strip()
1418
1419            # Detect entry type and separate content
1420            eType, eContent = pf.determineEntryType(line)
1421
1422            # TODO: DEBUG
1423            print("EE", lineNumber, eType, eContent)
1424
1425            if self.exitTransition is not None and eType != 'note':
1426                raise JournalParseError(
1427                    f"Entry after room exit on line {lineNumber} in"
1428                    f" block:\n{thisBlock}"
1429                )
1430
1431            if (
1432                eType not in ('detour', 'obviate')
1433            and anonymousContent is not None
1434            ):
1435                raise JournalParseError(
1436                    f"Entry on line #{lineNumber} with type {eType}"
1437                    f" does not support anonymous room content. Block"
1438                    f" is:\n{thisBlock}"
1439                )
1440
1441            # Handle note creation
1442            if self.currentNote is not None and eType != 'note':
1443                # This ends a note, so we can apply the pending note and
1444                # reset it.
1445                self.applyCurrentNote()
1446            elif eType == 'note':
1447                self.observeNote(eContent, indented=indented)
1448                # In (only) this case, we've handled the entire line
1449                continue
1450
1451            # Handle a pending progress step if there is one
1452            if self.pendingProgress is not None:
1453                # Any kind of entry except a note (which we would have
1454                # hit above and continued) indicates that a progress
1455                # marker is in-room progress rather than being a room
1456                # exit.
1457                self.makeProgressInRoom(*self.pendingProgress)
1458
1459                # Clean out pendingProgress
1460                self.pendingProgress = None
1461
1462            # Check for valid eType if pre-room
1463            if (
1464                self.currentRoomName is None
1465            and eType not in ('room', 'progress')
1466            ):
1467                raise JournalParseError(
1468                    f"Invalid entry on line #{lineNumber}: Entry type"
1469                    f" '{eType}' not allowed before room name. Block"
1470                    f" is:\n{thisBlock}"
1471                )
1472
1473            # Check for valid eType if post-room
1474            if self.blockEnded and eType not in ('note', 'tag'):
1475                raise JournalParseError(
1476                    f"Invalid entry on line #{lineNumber}: Entry type"
1477                    f" '{eType}' not allowed after an block ends. Block"
1478                    f" is:\n{thisBlock}"
1479                )
1480
1481            # Parse a line-end note if there is one
1482            # Note that note content will be handled after we handle main
1483            # entry stuff
1484            content, note = pf.splitFinalNote(eContent)
1485
1486            # Parse a line-end tags section if there is one
1487            content, fTags, rTags = pf.splitTags(content)
1488
1489            # Parse a line-end requirements section if there is one
1490            content, forwardReq, backReq = pf.splitRequirement(content)
1491
1492            # Strip any remaining whitespace from the edges of our content
1493            content = content.strip()
1494
1495            # Get current graph
1496            now = self.exploration.getCurrentGraph()
1497
1498            # This will trigger on the first line in the room, and handles
1499            # the actual room creation in the graph
1500            handledEntry = False # did we handle the entry in this block?
1501            if (
1502                self.currentRoomName is not None
1503            and not self.seenRoomEntrance
1504            ):
1505                # We're looking for an entrance and if we see anything else
1506                # except a tag, we'll assume that the entrance is implicit,
1507                # and give an error if we don't have an implicit entrance
1508                # set up. If the entrance is explicit, we'll give a warning
1509                # if it doesn't match the previous entrance for the same
1510                # prior-room exit from last time.
1511                if eType in ('entrance', 'otherway'):
1512                    # An explicit entrance; must match previous associated
1513                    # entrance if there was one.
1514                    self.observeRoomEntrance(
1515                        taken, # TODO: transition taken?
1516                        newRoom, # TODO: new room name?
1517                        content,
1518                        eType == 'otherway',
1519                        fReq=forwardReq,
1520                        rReq=backReq,
1521                        fTags=fTags,
1522                        rTags=rTags
1523                    )
1524
1525                elif eType == 'tag':
1526                    roomTags |= set(content.split())
1527                    if fTags or rTags:
1528                        raise JournalParseError(
1529                            f"Found tags on tag entry on line #{lineNumber}"
1530                            f" of block:\n{journalBlock}"
1531                        )
1532                    # don't do anything else here since it's a tag;
1533                    # seenEntrance remains False
1534                    handledEntry = True
1535
1536                else:
1537                    # For any other entry type, it counts as an implicit
1538                    # entrance. We need to follow that transition, or if an
1539                    # appropriate link does not already exist, raise an
1540                    # error.
1541                    seenEntrance = True
1542                    # handledEntry remains False in this case
1543
1544                    # Check that the entry point for this room can be
1545                    # deduced, and deduce it so that we can figure out which
1546                    # sub-room we're actually entering...
1547                    if enterFrom is None:
1548                        if len(exploration) == 0:
1549                            # At the start of the exploration, there's often
1550                            # no specific transition we come from, which is
1551                            # fine.
1552                            exploration.start(roomName, [])
1553                        else:
1554                            # Continuation after an ending
1555                            exploration.warp(roomName, 'restart')
1556                    else:
1557                        fromDecision, fromTransition = enterFrom
1558                        prevReciprocal = None
1559                        if now is not None:
1560                            prevReciprocal = now.getReciprocal(
1561                                fromDecision,
1562                                fromTransition
1563                            )
1564                        if prevReciprocal is None:
1565                            raise JournalParseError(
1566                                f"Implicit transition into room {roomName}"
1567                                f" is invalid because no reciprocal"
1568                                f" transition has been established for exit"
1569                                f" {fromTransition} in previous room"
1570                                f" {fromDecision}."
1571                            )
1572
1573                        # In this case, we retrace the transition, and if
1574                        # that fails because of a ValueError (e.g., because
1575                        # that transition doesn't exist yet or leads to an
1576                        # unknown node) then we'll raise the error as a
1577                        # JournalParseError.
1578                        try:
1579                            exploration.retrace(fromTransition)
1580                        except ValueError as e:
1581                            raise JournalParseError(
1582                                f"Implicit transition into room {roomName}"
1583                                f" is invalid because:\n{e.args[0]}"
1584                            )
1585
1586                        # Note: no tags get applied here, because this is an
1587                        # implicit transition, so there's no room to apply
1588                        # new tags. An explicit transition could be used
1589                        # instead to update transition properties.
1590
1591            # Previous block may have updated the current graph
1592            now = exploration.getCurrentGraph()
1593
1594            # At this point, if we've seen an entrance we're in the right
1595            # room, so we should apply accumulated room tags
1596            if seenEntrance and roomTags:
1597                if now is None:
1598                    raise RuntimeError(
1599                        "Inconsistency: seenEntrance is True but the current"
1600                        " graph is None."
1601                    )
1602
1603                here = exploration.currentPosition()
1604                now.tagDecision(here, roomTags)
1605                roomTags = set() # reset room tags
1606
1607            # Handle all entry types not handled above (like note)
1608            if handledEntry:
1609                # We skip this if/else but still do end-of-loop cleanup
1610                pass
1611
1612            elif eType == 'note':
1613                raise RuntimeError("Saw 'note' eType in lower handling block.")
1614
1615            elif eType == 'room':
1616                if roomName is not None:
1617                    raise ValueError(
1618                        f"Multiple room names detected on line {lineNumber}"
1619                        f" in block:\n{journalBlock}"
1620                    )
1621
1622                # Setting the room name changes the loop state
1623                roomName = content
1624
1625                # These will be applied later
1626                roomTags = fTags
1627
1628                if rTags:
1629                    raise JournalParseError(
1630                        f"Reverse tags cannot be applied to a room"
1631                        f" (found tags {rTags} for room '{roomName}')."
1632                    )
1633
1634            elif eType == 'entrance':
1635                # would be handled above if seenEntrance was false
1636                raise JournalParseError(
1637                    f"Multiple entrances on line {lineNumber} in"
1638                    f" block:\n{journalBlock}"
1639                )
1640
1641            elif eType == 'exit':
1642                # We note the exit transition and will use that as our
1643                # return value. This also will cause an error on the next
1644                # iteration if there are further non-note entries in the
1645                # journal block
1646                exitRoom = exploration.currentPosition()
1647                exitTransition = content
1648
1649                # At this point we add an unexplored edge for this exit,
1650                # assuming it's not one we've seen before. Note that this
1651                # does not create a new exploration step (that will happen
1652                # later).
1653                knownDestination = None
1654                if now is not None:
1655                    knownDestination = now.getDestination(
1656                        exitRoom,
1657                        exitTransition
1658                    )
1659
1660                    if knownDestination is None:
1661                        now.addUnexploredEdge(
1662                            exitRoom,
1663                            exitTransition,
1664                            tags=fTags,
1665                            revTags=rTags,
1666                            requires=forwardReq,
1667                            revRequires=backReq
1668                        )
1669
1670                    else:
1671                        # Otherwise just apply any tags to the transition
1672                        now.tagTransition(exitRoom, exitTransition, fTags)
1673                        existingReciprocal = now.getReciprocal(
1674                            exitRoom,
1675                            exitTransition
1676                        )
1677                        if existingReciprocal is not None:
1678                            now.tagTransition(
1679                                knownDestination,
1680                                existingReciprocal,
1681                                rTags
1682                            )
1683
1684            elif eType in (
1685                'blocked',
1686                'otherway',
1687                'unexplored',
1688                'unexploredOneway',
1689            ):
1690                # Simply add the listed transition to our current room,
1691                # leading to an unknown destination, without creating a new
1692                # exploration step
1693                transition = content
1694                here = exploration.currentPosition()
1695
1696                # If there isn't a listed requirement, infer ReqImpossible
1697                # where appropriate
1698                if forwardReq is None and eType in ('blocked', 'otherway'):
1699                    forwardReq = core.ReqImpossible()
1700                if backReq is None and eType in ('blocked', 'unexploredOneway'):
1701                    backReq = core.ReqImpossible()
1702
1703                # TODO: What if we've annotated a known source for this
1704                # link?
1705
1706                if now is None:
1707                    raise JournalParseError(
1708                        f"On line {lineNumber}: Cannot create an unexplored"
1709                        f" transition before we've created the starting"
1710                        f" graph. Block is:\n{journalBlock}"
1711                    )
1712
1713                now.addUnexploredEdge(
1714                    here,
1715                    transition,
1716                    tags=fTags,
1717                    revTags=rTags,
1718                    requires=forwardReq,
1719                    revRequires=backReq
1720                )
1721
1722            elif eType in ('pickup', 'unclaimed', 'action'):
1723                # We both add an action to the current room, and then take
1724                # that action, or if the type is unclaimed, we don't take
1725                # the action.
1726
1727                if eType == 'unclaimed' and content[0] == '?':
1728                    fTags.add('unknown')
1729
1730                name: Optional[str] = None # auto by default
1731                gains: Optional[str] = None
1732                if eType == 'action':
1733                    name = content
1734                    # TODO: Generalize action effects; also handle toggles,
1735                    # repeatability, etc.
1736                else:
1737                    gains = content
1738
1739                actionName = takeActionInRoom(
1740                    exploration,
1741                    parseFormat,
1742                    name,
1743                    gains,
1744                    forwardReq,
1745                    backReq,
1746                    fTags,
1747                    rTags,
1748                    eType == 'unclaimed' # whether to leave it untaken
1749                )
1750
1751                # Limit scope to this case
1752                del name
1753                del gains
1754
1755            elif eType == 'progress':
1756                # If the room name hasn't been specified yet, this indicates
1757                # a room that we traverse en route. If the room name has
1758                # been specified, this is movement to a new sub-room.
1759                if roomName is None:
1760                    # Here we need to accumulate the named route, since the
1761                    # navigation of sub-rooms has to be figured out by
1762                    # pathfinding, but that's only possible once we know
1763                    # *all* of the listed rooms. Note that the parse
1764                    # format's 'runback' symbol may be used as a room name
1765                    # to indicate that some of the route should be
1766                    # auto-completed.
1767                    if content == parseFormat.formatDict['runback']:
1768                        interRoomPath.append(InterRoomEllipsis)
1769                    else:
1770                        interRoomPath.append(content)
1771                else:
1772                    # This is progress to a new sub-room. If we've been
1773                    # to that sub-room from the current sub-room before, we
1774                    # retrace the connection, and if not, we first add an
1775                    # unexplored connection and then explore it.
1776                    makeProgressInRoom(
1777                        exploration,
1778                        parseFormat,
1779                        content,
1780                        False,
1781                        forwardReq,
1782                        backReq,
1783                        fTags,
1784                        rTags
1785                        # annotations handled separately
1786                    )
1787
1788            elif eType == 'frontier':
1789                pass
1790                # TODO: HERE
1791
1792            elif eType == 'frontierEnd':
1793                pass
1794                # TODO: HERE
1795
1796            elif eType == 'oops':
1797                # This removes the specified transition from the graph,
1798                # creating a new exploration step to do so. It tags that
1799                # transition as an oops in the previous graph, because
1800                # the transition won't exist to be tagged in the new
1801                # graph. If the transition led to a non-frontier unknown
1802                # node, that entire node is removed; otherwise just the
1803                # single transition is removed, along with its
1804                # reciprocal.
1805                if now is None:
1806                    raise JournalParseError(
1807                        f"On line {lineNumber}: Cannot mark an oops before"
1808                        f" we've created the starting graph. Block"
1809                        f" is:\n{journalBlock}"
1810                    )
1811
1812                prev = now # remember the previous graph
1813                # TODO
1814                now = exploration.currentGraph()
1815                here = exploration.currentPosition()
1816                print("OOP", now.destinationsFrom(here))
1817                exploration.wait('oops') # create new step w/ no changes
1818                now = exploration.currentGraph()
1819                here = exploration.currentPosition()
1820                accidental = now.getDestination(here, content)
1821                if accidental is None:
1822                    raise JournalParseError(
1823                        f"Cannot erase transition '{content}' because it"
1824                        f" does not exist at decision {here}."
1825                    )
1826
1827                # If it's an unknown (the usual case) then we remove the
1828                # entire node
1829                if now.isUnknown(accidental):
1830                    now.remove_node(accidental)
1831                else:
1832                    # Otherwise re move the edge and its reciprocal
1833                    reciprocal = now.getReciprocal(here, content)
1834                    now.remove_edge(here, accidental, content)
1835                    if reciprocal is not None:
1836                        now.remove_edge(accidental, here, reciprocal)
1837
1838                # Tag the transition as an oops in the step before it gets
1839                # removed:
1840                prev.tagTransition(here, content, 'oops')
1841
1842            elif eType in ('oneway', 'hiddenOneway'):
1843                # In these cases, we create a pending progress value, since
1844                # it's possible to use 'oneway' as the exit from a room in
1845                # which case it's not in-room progress but rather a room
1846                # transition.
1847                pendingProgress = (
1848                    content,
1849                    True if eType == 'oneway' else 'hidden',
1850                    forwardReq,
1851                    backReq,
1852                    fTags,
1853                    rTags,
1854                    None, # No annotations need be applied now
1855                    None
1856                )
1857
1858            elif eType == 'detour':
1859                if anonymousContent is None:
1860                    raise JournalParseError(
1861                        f"Detour on line #{lineNumber} is missing an"
1862                        f" anonymous room definition. Block"
1863                        f" is:\n{journalBlock}"
1864                    )
1865                # TODO: Support detours to existing rooms w/out anonymous
1866                # content...
1867                if now is None:
1868                    raise JournalParseError(
1869                        f"On line {lineNumber}: Cannot create a detour"
1870                        f" before we've created the starting graph. Block"
1871                        f" is:\n{journalBlock}"
1872                    )
1873
1874                # First, we create an unexplored transition and then use it
1875                # to enter the anonymous room...
1876                here = exploration.currentPosition()
1877                now.addUnexploredEdge(
1878                    here,
1879                    content,
1880                    tags=fTags,
1881                    revTags=rTags,
1882                    requires=forwardReq,
1883                    revRequires=backReq
1884                )
1885
1886                if roomName is None:
1887                    raise JournalParseError(
1888                        f"Detour on line #{lineNumber} occurred before room"
1889                        f" name was known. Block is:\n{journalBlock}"
1890                    )
1891
1892                # Get a new unique anonymous name
1893                anonName = parseFormat.anonName(roomName, content)
1894
1895                # Actually enter our detour room
1896                exploration.explore(
1897                    content,
1898                    anonName,
1899                    [], # No connections yet
1900                    content + '-return'
1901                )
1902
1903                # Tag the new room as anonymous
1904                now = exploration.currentGraph()
1905                now.tagDecision(anonName, 'anonymous')
1906
1907                # Remember transitions needed to get out of room
1908                thread: List[core.Transition] = []
1909
1910                # Parse in-room activity and create steps for it
1911                anonLines = anonymousContent.splitlines()
1912                for anonLine in anonLines:
1913                    anonLine = anonLine.strip()
1914                    try:
1915                        anonType, anonContent = parseFormat.determineEntryType(
1916                            anonLine
1917                        )
1918                    except JournalParseError:
1919                        # One liner that doesn't parse -> treat as tag(s)
1920                        anonType = 'tag'
1921                        anonContent = anonLine.strip()
1922                        if len(anonLines) > 1:
1923                            raise JournalParseError(
1924                                f"Detour on line #{lineNumber} has multiple"
1925                                f" lines but one cannot be parsed as an"
1926                                f" entry:\n{anonLine}\nBlock"
1927                                f" is:\n{journalBlock}"
1928                            )
1929
1930                    # Parse final notes, tags, and/or requirements
1931                    if anonType != 'note':
1932                        anonContent, note = parseFormat.splitFinalNote(
1933                            anonContent
1934                        )
1935                        anonContent, fTags, rTags = parseFormat.splitTags(
1936                            anonContent
1937                        )
1938                        (
1939                            anonContent,
1940                            forwardReq,
1941                            backReq
1942                        ) = parseFormat.splitRequirement(anonContent)
1943
1944                    if anonType == 'note':
1945                        here = exploration.currentPosition()
1946                        now.annotateDecision(here, anonContent)
1947                        # We don't handle multi-line notes in anon rooms
1948
1949                    elif anonType == 'tag':
1950                        tags = set(anonContent.split())
1951                        here = exploration.currentPosition()
1952                        now.tagDecision(here, tags)
1953                        if note is not None:
1954                            now.annotateDecision(here, note)
1955
1956                    elif anonType == 'progress':
1957                        makeProgressInRoom(
1958                            exploration,
1959                            parseFormat,
1960                            anonContent,
1961                            False,
1962                            forwardReq,
1963                            backReq,
1964                            fTags,
1965                            rTags,
1966                            [ note ] if note is not None else None
1967                            # No reverse annotations
1968                        )
1969                        # We don't handle multi-line notes in anon rooms
1970
1971                        # Remember the way back
1972                        # TODO: HERE Is this still accurate?
1973                        thread.append(anonContent + '-return')
1974
1975                    elif anonType in ('pickup', 'unclaimed', 'action'):
1976
1977                        if (
1978                            anonType == 'unclaimed'
1979                        and anonContent.startswith('?')
1980                        ):
1981                            fTags.add('unknown')
1982
1983                        # Note: these are both type Optional[str], but since
1984                        # they exist in another case, they can't be
1985                        # explicitly typed that way here. See:
1986                        # https://github.com/python/mypy/issues/1174
1987                        name = None
1988                        gains = None
1989                        if anonType == 'action':
1990                            name = anonContent
1991                        else:
1992                            gains = anonContent
1993
1994                        actionName = takeActionInRoom(
1995                            exploration,
1996                            parseFormat,
1997                            name,
1998                            gains,
1999                            forwardReq,
2000                            backReq,
2001                            fTags,
2002                            rTags,
2003                            anonType == 'unclaimed' # leave it untaken or not?
2004                        )
2005
2006                        # Limit scope
2007                        del name
2008                        del gains
2009
2010                    elif anonType == 'challenge':
2011                        here = exploration.currentPosition()
2012                        now.annotateDecision(
2013                            here,
2014                            "challenge: " + anonContent
2015                        )
2016
2017                    elif anonType in ('blocked', 'otherway'):
2018                        here = exploration.currentPosition()
2019
2020                        # Mark as blocked even when no explicit requirement
2021                        # has been provided
2022                        if forwardReq is None:
2023                            forwardReq = core.ReqImpossible()
2024                        if backReq is None and anonType == 'blocked':
2025                            backReq = core.ReqImpossible()
2026
2027                        now.addUnexploredEdge(
2028                            here,
2029                            anonContent,
2030                            tags=fTags,
2031                            revTags=rTags,
2032                            requires=forwardReq,
2033                            revRequires=backReq
2034                        )
2035
2036                    else:
2037                        # TODO: Any more entry types we need to support in
2038                        # anonymous rooms?
2039                        raise JournalParseError(
2040                            f"Detour on line #{lineNumber} includes an"
2041                            f" entry of type '{anonType}' which is not"
2042                            f" allowed in an anonymous room. Block"
2043                            f" is:\n{journalBlock}"
2044                        )
2045
2046                # If we made progress, backtrack to the start of the room
2047                for backwards in thread:
2048                    exploration.retrace(backwards)
2049
2050                # Now we exit back to the original room
2051                exploration.retrace(content + '-return')
2052
2053            elif eType == 'unify': # TODO: HERE
2054                pass
2055
2056            elif eType == 'obviate': # TODO: HERE
2057                # This represents a connection to somewhere we've been
2058                # before which is recognized but not traversed.
2059                # Note that when you want to use this to replace a mis-named
2060                # unexplored connection (which you now realize actually goes
2061                # to an existing sub-room, not a new one) you should just
2062                # oops that connection first, and then obviate to the actual
2063                # destination.
2064                if now is None:
2065                    raise JournalParseError(
2066                        f"On line {lineNumber}: Cannot obviate a transition"
2067                        f" before we've created the starting graph. Block"
2068                        f" is:\n{journalBlock}"
2069                    )
2070
2071                here = exploration.currentPosition()
2072
2073                # Two options: if the content lists a room:entrance combo in
2074                # brackets after a transition name, then it represents the
2075                # other side of a door from another room. If, on the other
2076                # hand, it just has a transition name, it represents a
2077                # sub-room name.
2078                content, otherSide = parseFormat.splitAnonymousRoom(content)
2079
2080                if otherSide is None:
2081                    # Must be in-room progress
2082                    # We create (but don't explore) a transition to that
2083                    # sub-room.
2084                    baseRoom = parseFormat.baseRoomName(here)
2085                    currentSubPart = parseFormat.roomPartName(here)
2086                    if currentSubPart is None:
2087                        currentSubPart = parseFormat.formatDict["progress"]
2088                    fromDecision = parseFormat.subRoomName(
2089                        baseRoomName,
2090                        content
2091                    )
2092
2093                    existingReciprocalDestination = now.getDestination(
2094                        fromDecision,
2095                        currentSubPart
2096                    )
2097                    # If the place we're linking to doesn't have a link back
2098                    # to us, then we just create a completely new link.
2099                    if existingReciprocalDestination is None:
2100                        pass
2101                        if now.getDestination(here, content):
2102                            pass
2103                        # TODO: HERE
2104                        # ISSUE: Sub-room links cannot just be named after
2105                        # their destination, because they might not be
2106                        # unique!
2107
2108                    elif now.isUnknown(existingReciprocalDestination):
2109                        pass
2110                        # TODO
2111
2112                    else:
2113                        # TODO
2114                        raise JournalParseError("")
2115
2116                    transitionName = content + '-return'
2117                    # fromDecision, incoming = fromOptions[0]
2118                    # TODO
2119                else:
2120                    # Here the content specifies an outgoing transition name
2121                    # and otherSide specifies the other side, so we don't
2122                    # have to search for anything
2123                    transitionName = content
2124
2125                    # Split decision name and transition name
2126                    fromDecision, incoming = parseFormat.parseSpecificTransition(
2127                        otherSide
2128                    )
2129                    dest = now.getDestination(fromDecision, incoming)
2130
2131                    # Check destination exists and is unknown
2132                    if dest is None:
2133                        # TODO: Look for alternate sub-room?
2134                        raise JournalParseError(
2135                            f"Obviate entry #{lineNumber} for transition"
2136                            f" {content} has invalid reciprocal transition"
2137                            f" {otherSide}. (Did you forget to specify the"
2138                            f" sub-room?)"
2139                        )
2140                    elif not now.isUnknown(dest):
2141                        raise JournalParseError(
2142                            f"Obviate entry #{lineNumber} for transition"
2143                            f" {content} has invalid reciprocal transition"
2144                            f" {otherSide}: that transition's destination"
2145                            f" is already known."
2146                        )
2147
2148                # Now that we know which edge we're obviating, do that
2149                # Note that while the other end is always an existing
2150                # transition to an unexplored destination, our end might be
2151                # novel, so we use replaceUnexplored from the other side
2152                # which allows it to do the work of creating the new
2153                # outgoing transition.
2154                now.replaceUnexplored(
2155                    fromDecision,
2156                    incoming,
2157                    here,
2158                    transitionName,
2159                    requirement=backReq, # flipped
2160                    revRequires=forwardReq,
2161                    tags=rTags, # also flipped
2162                    revTags=fTags,
2163                )
2164
2165            elif eType == 'challenge':
2166                # For now, these are just annotations
2167                if now is None:
2168                    raise JournalParseError(
2169                        f"On line {lineNumber}: Cannot annotate a challenge"
2170                        f" before we've created the starting graph. Block"
2171                        f" is:\n{journalBlock}"
2172                    )
2173
2174                here = exploration.currentPosition()
2175                now.annotateDecision(here, f"{eType}: " + content)
2176
2177            elif eType in ('warp', 'death'):
2178                # These warp the player without creating a connection
2179                if forwardReq or backReq:
2180                    raise JournalParseError(
2181                        f"'{eType}' entry #{lineNumber} cannot include"
2182                        f" requirements. Block is:\n{journalBlock}"
2183                    )
2184                if fTags or rTags:
2185                    raise JournalParseError(
2186                        f"'{eType}' entry #{lineNumber} cannot include"
2187                        f" tags. Block is:\n{journalBlock}"
2188                    )
2189
2190                try:
2191                    exploration.warp(
2192                        content,
2193                        'death' if eType == 'death' else ''
2194                    )
2195                    # TODO: Death effects?!?
2196                    # TODO: We could rewind until we're in a room marked
2197                    # 'save' and pick up that position and even state
2198                    # automatically ?!? But for save-anywhere games, we'd
2199                    # need to have some way of marking a save (could be an
2200                    # entry type that creates a special wait?).
2201                    # There could even be a way to clone the old graph for
2202                    # death, since things like tags applied would presumably
2203                    # not be? Or maybe some would and some wouldn't?
2204                except KeyError:
2205                    raise JournalParseError(
2206                        f"'{eType}' entry #{lineNumber} specifies"
2207                        f" non-existent destination '{content}'. Block"
2208                        f" is:\n{journalBlock}"
2209                    )
2210
2211            elif eType == 'runback':
2212                # For now, we just warp there and back
2213                # TODO: Actually trace the path of the runback...
2214                # TODO: Allow for an action to be taken at the destination
2215                # (like farming health, flipping a switch, etc.)
2216                if forwardReq or backReq:
2217                    raise JournalParseError(
2218                        f"Runback on line #{lineNumber} cannot include"
2219                        f" requirements. Block is:\n{journalBlock}"
2220                    )
2221                if fTags or rTags:
2222                    raise JournalParseError(
2223                        f"Runback on line #{lineNumber} cannot include tags."
2224                        f" Block is:\n{journalBlock}"
2225                    )
2226
2227                # Remember where we are
2228                here = exploration.currentPosition()
2229
2230                # Warp back to the runback point
2231                try:
2232                    exploration.warp(content, 'runaway')
2233                except KeyError:
2234                    raise JournalParseError(
2235                        f"Runback on line #{lineNumber} specifies"
2236                        f" non-existent destination '{content}'. Block"
2237                        f" is:\n{journalBlock}"
2238                    )
2239
2240                # Then warp back to the current decision
2241                exploration.warp(here, 'runback')
2242
2243            elif eType == 'traverse':
2244                # For now, we just warp there
2245                # TODO: Actually trace the path of the runback...
2246                if forwardReq or backReq:
2247                    raise JournalParseError(
2248                        f"Traversal on line #{lineNumber} cannot include"
2249                        f" requirements. Block is:\n{journalBlock}"
2250                    )
2251                if fTags or rTags:
2252                    raise JournalParseError(
2253                        f"Traversal on line #{lineNumber} cannot include tags."
2254                        f" Block is:\n{journalBlock}"
2255                    )
2256
2257                if now is None:
2258                    raise JournalParseError(
2259                        f"Cannot traverse sub-rooms on line #{lineNumber}"
2260                        f" before exploration is started. Block"
2261                        f" is:\n{journalBlock}"
2262                    )
2263
2264                # Warp to the destination
2265                here = exploration.currentPosition()
2266                destination = parseFormat.getSubRoom(now, here, content)
2267                if destination is None:
2268                    raise JournalParseError(
2269                        f"Traversal on line #{lineNumber} specifies"
2270                        f" non-existent sub-room destination '{content}' in"
2271                        f" room '{parseFormat.baseRoomName(here)}'. Block"
2272                        f" is:\n{journalBlock}"
2273                    )
2274                else:
2275                    exploration.warp(destination, 'traversal')
2276
2277            elif eType == 'ending':
2278                if now is None:
2279                    raise JournalParseError(
2280                        f"On line {lineNumber}: Cannot annotate an ending"
2281                        f" before we've created the starting graph. Block"
2282                        f" is:\n{journalBlock}"
2283                    )
2284
2285                if backReq:
2286                    raise JournalParseError(
2287                        f"Ending on line #{lineNumber} cannot include"
2288                        f" reverse requirements. Block is:\n{journalBlock}"
2289                    )
2290
2291                # Create ending
2292                here = exploration.currentPosition()
2293                # Reverse tags are applied to the ending room itself
2294                now.addEnding(
2295                    here,
2296                    content,
2297                    tags=fTags,
2298                    endTags=rTags,
2299                    requires=forwardReq
2300                )
2301                # Transition to the ending
2302                print("ED RT", here, content, len(exploration))
2303                exploration.retrace('_e:' + content)
2304                print("ED RT", len(exploration))
2305                ended = True
2306
2307            elif eType == 'tag':
2308                tagsToApply = set(content.split())
2309                if fTags or rTags:
2310                    raise JournalParseError(
2311                        f"Found tags on tag entry on line #{lineNumber}"
2312                        f" of block:\n{journalBlock}"
2313                    )
2314
2315                if now is None:
2316                    raise JournalParseError(
2317                        f"On line {lineNumber}: Cannot add a tag before"
2318                        f" we've created the starting graph. Block"
2319                        f" is:\n{journalBlock}"
2320                    )
2321
2322                here = exploration.currentPosition()
2323                now.tagDecision(here, tagsToApply)
2324
2325            else:
2326                raise NotImplementedError(
2327                    f"Unhandled entry type '{eType}' (fix"
2328                    f" updateExplorationFromEntry)."
2329                )
2330
2331            # Note: at this point, currentNote must be None. If there is an
2332            # end-of-line note, set up currentNote to apply that to whatever
2333            # is on this line.
2334            if note is not None:
2335                if eType in (
2336                    'entrance',
2337                    'exit',
2338                    'blocked',
2339                    'otherway',
2340                    'unexplored',
2341                    'unexploredOneway',
2342                    'progress'
2343                    'oneway',
2344                    'hiddenOneway',
2345                    'detour'
2346                ):
2347                    # Annotate a specific transition
2348                    target = (exploration.currentPosition(), content)
2349
2350                elif eType in (
2351                    'pickup',
2352                    'unclaimed',
2353                    'action',
2354                ):
2355                    # Action name might be auto-generated
2356                    target = (
2357                        exploration.currentPosition(),
2358                        actionName
2359                    )
2360
2361                else:
2362                    # Default: annotate current room
2363                    target = exploration.currentPosition()
2364
2365                # Set current note value for accumulation
2366                currentNote = (
2367                    target,
2368                    True, # all post-entry notes count as indented
2369                    f"(step #{len(exploration)}) " + note
2370                )
2371
2372        # If we ended, return None
2373        if ended:
2374            return None
2375        elif exitRoom is None or exitTransition is None:
2376            raise JournalParseError(
2377                f"Missing exit room and/or transition ({exitRoom},"
2378                f" {exitTransition}) at end of journal"
2379                f" block:\n{journalBlock}"
2380            )
2381
2382        return exitRoom, exitTransition

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. Note that without later calling applyState, some parts of the observed entries may remain saved as internal state that hasn't yet been disambiguated and applied to the exploration. jor example, a final one-way transition could indicate in-room one-way progress, or a one-way transition to another room, and this is disambiguated by observing whether the next entry is another entry in the same block or a blank line to indicate the end of a block.

This method can be called multiple times to process a longer journal incrementally including line-by-line. If you give it an empty string, that will count as the end of a journal block (or a continuation of space between blocks).

Example:

>>> obs = JournalObserver()
>>> obs.observe('''\
... [Room1]
... < Top " Comment
... x nope (power|tokens*3)
... ? unexplored
... -> sub_room " This is a one-way transition
... -> - " The default sub-room is named '-'
... > Bottom
...
... [Room2]
... < Top
... * switch " Took an action in this room
... ? Left
... > Right {blue}
...
... [Room3]
... < Left
... # Miniboss " Faced a challenge
... . power " Get a power
... >< Right [
...    - ledge (tall)
...    . treasure
... ] " Detour to an anonymous room
... > Left
...
... - Room2 " Visited along the way
... [Room1]
... - nope " Entrance may be omitted if implied
... > Right
... ''')
>>> e = obs.getExploration()
>>> len(e)
12
>>> m = e.currentGraph()
>>> len(m)
11
>>> def showDestinations(m, r):
...     d = m.destinationsFrom(r)
...     for outgoing in d:
...         req = m.getTransitionRequirement(r, outgoing)
...         if req is None:
...             req = ''
...         else:
...             req = ' (' + repr(req) + ')'
...         print(outgoing, d[outgoing] + req)
...
>>> showDestinations(m, "Room1")
Top _u.0
nope Room1%nope ReqAny(ReqPower("power"), ReqTokens("tokens", 3))
unexplored _u.1
sub_room Room1%sub_room
sub_room.1 Room1%sub_room ReqImpossible()
Bottom: Room2
>>> showDestinations(m, "Room1%nope")
- Room1 ReqAny(ReqPower("power"), ReqTokens("tokens", 3))
Right _u.3
>>> showDestinations(m, "Room1%sub_room")
- Room1 ReqImpossible()
-.1 Room1
>>> showDestinations(m, "Room2")
Top Room1
action@5 Room2
Left _u.2
Right: Room3
>>> m.transitionTags("Room3", "Right")
{'blue'}
>>> showDestinations(m, "Room3")
Left Room2
action@7 Room3
Right Room3$Right
>>> showDestinations(m, "Room3$Right")
ledge Room3$Right%ledge ReqPower("tall")
return Room3
>>> showDestinations(m, "Room3$Right%ledge")
- Room3$Right
action@9 Room3$Right%ledge
>>> m.decisionAnnotations("Room3")
['challenge: Miniboss']
>>> e.currentPosition()
'Room1%nope'

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 followed by a single space, and everything after that is the content of the entry. A few different modifiers are removed from the right-hand side of entries first:

  • Notes starting with " by default and going to the end of the line, possibly continued on other lines that are indented and start with the note marker.
  • Tags surrounded by { and } by default and separated from each other by commas and optional spaces. These are applied to the current room (if alone on a line) or to the decision or transition implicated in the line they're at the end of.
  • Requirements surrounded by ( and ) by default, with / used to separate forward/reverse requirements. These are applied to the transition implicated by the rest of the line, and are not allowed on lines that don't imply a transition. The contents are parsed into a requirement using core.Requirement.parse. Warnings may be issued for requirements specified on transitions that are taken which are not met at the time.
  • For detours and a few other select entry types, anonymous room or transition info may be surrounded by [ and ] at the end of the line. For detours, there may be multiple lines between [ and ] as shown in the example above.
def observeNote( self, noteText: str, indented: bool = False, target: Union[str, Tuple[str, str], NoneType] = None) -> None:
2384    def observeNote(
2385        self,
2386        noteText: str,
2387        indented: bool = False,
2388        target: Optional[
2389            Union[core.Decision, Tuple[core.Decision, core.Transition]]
2390        ] = None
2391    ) -> None:
2392        """
2393        Observes a whole-line note in a journal, which may or may not be
2394        indented (level of indentation is ignored). Creates or extends
2395        the current pending note, or applies that note and starts a new
2396        one if the indentation statues or targets are different. Except
2397        in that case, no change is made to the exploration or its
2398        graphs; the annotations are actually applied when
2399        `applyCurrentNote` is called.
2400
2401        ## Example
2402
2403        >>> obs = JournalObserver()
2404        >>> obs.observe('[Room]\\n? Left\\n')
2405        >>> obs.observeNote('hi')
2406        >>> obs.observeNote('the same note')
2407        >>> obs.observeNote('a new note', indented=True) # different indent
2408        >>> obs.observeNote('another note', indented=False)
2409        >>> obs.observeNote('this applies to Left', target=('Room', 'Left'))
2410        >>> obs.observeNote('more') # same target by implication
2411        >>> obs.observeNote('another', target='Room') # different target
2412        >>> e = obs.getExploration()
2413        >>> m = e.currentGraph()
2414        >>> m.decisionAnnotations('Room') # Last note is not here yet...
2415        ['hi\\nthe same note', 'a new note', 'another note']
2416        >>> m.transitionAnnotations('Room', 'Left')
2417        ['this applies to Left\\nmore']
2418        >>> m.applyCurrentNote()
2419        >>> m.decisionAnnotations('Room') # Last note is not here yet...
2420        ['hi\\nthe same note', 'a new note', 'another note', 'another']
2421        """
2422
2423        # whole line is a note; handle new vs. continuing note
2424        if self.currentNote is None:
2425            # Start a new note
2426            if target is None:
2427                target = self.exploration.currentPosition()
2428            self.currentNote = (
2429                target,
2430                indented,
2431                f"(step #{len(self.exploration)}) " + noteText
2432            )
2433        else:
2434            # Previous note exists, use indentation & target to decide
2435            # if we're continuing or starting a new note
2436            oldTarget, wasIndented, prevText = self.currentNote
2437            if (
2438                indented != wasIndented
2439             or (target is not None and target != oldTarget)
2440            ):
2441                # Then we apply the old note and create a new note (at
2442                # the decision level by default)
2443                self.applyCurrentNote()
2444                self.currentNote = (
2445                    target or self.exploration.currentPosition(),
2446                    indented,
2447                    f"(step #{len(self.exploration)}) " + noteText
2448                )
2449            else:
2450                # Else indentation matched and target either matches or
2451                # was None, so add to previous note
2452                self.currentNote = (
2453                    oldTarget,
2454                    wasIndented,
2455                    prevText + '\n' + noteText
2456                )

Observes a whole-line note in a journal, which may or may not be indented (level of indentation is ignored). Creates or extends the current pending note, or applies that note and starts a new one if the indentation statues or targets are different. Except in that case, no change is made to the exploration or its graphs; the annotations are actually applied when applyCurrentNote is called.

Example

>>> obs = JournalObserver()
>>> obs.observe('[Room]\n? Left\n')
>>> obs.observeNote('hi')
>>> obs.observeNote('the same note')
>>> obs.observeNote('a new note', indented=True) # different indent
>>> obs.observeNote('another note', indented=False)
>>> obs.observeNote('this applies to Left', target=('Room', 'Left'))
>>> obs.observeNote('more') # same target by implication
>>> obs.observeNote('another', target='Room') # different target
>>> e = obs.getExploration()
>>> m = e.currentGraph()
>>> m.decisionAnnotations('Room') # Last note is not here yet...
['hi\nthe same note', 'a new note', 'another note']
>>> m.transitionAnnotations('Room', 'Left')
['this applies to Left\nmore']
>>> m.applyCurrentNote()
>>> m.decisionAnnotations('Room') # Last note is not here yet...
['hi\nthe same note', 'a new note', 'another note', 'another']
def applyCurrentNote(self) -> None:
2458    def applyCurrentNote(self) -> None:
2459        """
2460        If there is a note waiting to be either continued or applied,
2461        applies that note to whatever it is targeting, and clears it.
2462        Does nothing if there is no pending note.
2463
2464        See `observeNote` for an example.
2465        """
2466        if self.currentNote is not None:
2467            target, _, noteText = self.currentNote
2468            self.currentNote = None
2469            # Apply our annotation to the room or transition it targets
2470            # TODO: Annotate the exploration instead?!?
2471            if isinstance(target, str):
2472                self.exploration.currentGraph().annotateDecision(
2473                    target,
2474                    noteText
2475                )
2476            else:
2477                room, transition = target
2478                self.exploration.currentGraph().annotateTransition(
2479                    room,
2480                    transition,
2481                    noteText
2482                )

If there is a note waiting to be either continued or applied, applies that note to whatever it is targeting, and clears it. Does nothing if there is no pending note.

See observeNote for an example.

def makeProgressInRoom( self, subRoomName: str, transitionName: Optional[str] = None, oneway: Union[bool, str] = False, requires: Optional[exploration.core.Requirement] = None, revRequires: Optional[exploration.core.Requirement] = None, tags: Optional[Set[str]] = None, revTags: Optional[Set[str]] = None, annotations: Optional[List[str]] = None, revAnnotations: Optional[List[str]] = None) -> None:
2484    def makeProgressInRoom(
2485        self,
2486        subRoomName: core.Decision,
2487        transitionName: Optional[core.Transition] = None,
2488        oneway: Union[bool, str] = False,
2489        requires: Optional[core.Requirement] = None,
2490        revRequires: Optional[core.Requirement] = None,
2491        tags: Optional[Set[core.Tag]] = None,
2492        revTags: Optional[Set[core.Tag]] = None,
2493        annotations: Optional[List[core.Annotation]] = None,
2494        revAnnotations: Optional[List[core.Annotation]] = None
2495    ) -> None:
2496        """
2497        Updates the exploration state to indicate that movement to a new
2498        sub-room has occurred. Handles three cases: a
2499        previously-observed but unexplored sub-room, a
2500        never-before-observed sub-room, and a previously-visited
2501        sub-room. By using the parse format's progress marker (default
2502        '-') as the room name, a transition to the base subroom can be
2503        specified.
2504
2505        The destination sub-room name is required, and the exploration
2506        object's current position will dictate which decision the player
2507        is currently at. If no transition name is specified, the
2508        transition name will be the same as the destination name (only
2509        the provided sub-room part) or the same as the first previous
2510        transition to the specified destination from the current
2511        location is such a transition already exists. Optional arguments
2512        may specify requirements, tags, and/or annotations to be applied
2513        to the transition, and requirements, tags, and/or annotations
2514        for the reciprocal transition; these will be applied in the new
2515        graph that results, but not retroactively. If the transition is
2516        a one-way transition, set `oneway` to True (default is False).
2517        `oneway` may also be set to the string 'hidden' to indicate a
2518        hidden one-way. The `newConnection` argument should be set to
2519        True (default False) if a new connection should be created even
2520        in cases where a connection already exists.
2521
2522        ## Example:
2523
2524        >>> obs = JournalObserver()
2525        >>> obs.observe("[Room]\\n< T")
2526        >>> obs.makeProgressInRoom("subroom")
2527        >>> e = obs.getExploration()
2528        >>> len(e)
2529        2
2530        >>> e.currentPosition()
2531        'Room%subroom'
2532        >>> g = e.currentGraph()
2533        >>> g.destinationsFrom("Room")
2534        { 'T': '_u.0', 'subroom': 'Room%subroom' }
2535        >>> g.destinationsFrom("Room%subroom")
2536        { '-': 'Room' }
2537        >>> obs.makeProgressInRoom("-") # Back to base subroom
2538        >>> len(e)
2539        3
2540        >>> e.currentPosition()
2541        'Room'
2542        >>> g = e.currentGraph()
2543        >>> g.destinationsFrom("Room")
2544        { 'T': '_u.0', 'subroom': 'Room%subroom' }
2545        >>> g.destinationsFrom("Room%subroom")
2546        { '-': 'Room' }
2547        >>> obs.makeProgressInRoom(
2548        ...   "other",
2549        ...   oneway='hidden',
2550        ...   tags={"blue"},
2551        ...   requires=core.ReqPower("fly"),
2552        ...   revRequires=core.ReqAll(
2553        ...     core.ReqPower("shatter"),
2554        ...     core.ReqPower("fly")
2555        ...   ),
2556        ...   revTags={"blue"},
2557        ...   annotations=["Another subroom"],
2558        ...   revAnnotations=["This way back"],
2559        ... )
2560        >>> len(e)
2561        4
2562        >>> e.currentPosition()
2563        'Room%other'
2564        >>> g = e.currentGraph()
2565        >>> g.destinationsFrom("Room")
2566        { 'T': '_u.0', 'subroom': 'Room%subroom', 'other': 'Room%other' }
2567        >>> g.destinationsFrom("Room%subroom")
2568        { '-': 'Room' }
2569        >>> g.destinationsFrom("Room%other")
2570        { '-': 'Room' }
2571        >>> g.getTransitionRequirement("Room", "other")
2572        ReqPower('fly')
2573        >>> g.getTransitionRequirement("Room%other", "-")
2574        ReqAll(ReqPower('shatter'), ReqPower('fly'))
2575        >>> g.transitionTags("Room", "other")
2576        {'blue'}
2577        >>> g.transitionTags("Room%other", "-")
2578        {'blue'}
2579        >>> g.transitionAnnotations("Room", "other")
2580        ['Another subroom']
2581        >>> g.transitionAnnotations("Room%other", "-")
2582        ['This way back']
2583        >>> prevM = e.graphAtStep(-2)
2584        >>> prevM.destinationsFrom("Room")
2585        { 'T': '_u.0', 'subroom': 'Room%subroom', 'other': '_u.2' }
2586        >>> prevM.destinationsFrom("Room%subroom")
2587        { '-': 'Room' }
2588        >>> "Room%other" in prevM
2589        False
2590        >>> obs.makeProgressInRoom("-", transitionName="-.1", oneway=True)
2591        >>> len(e)
2592        5
2593        >>> e.currentPosition()
2594        'Room'
2595        >>> g = e.currentGraph()
2596        >>> d = g.destinationsFrom("Room")
2597        >>> g['T']
2598        '_u.0'
2599        >>> g['subroom']
2600        'Room%subroom'
2601        >>> g['other']
2602        'Room%other'
2603        >>> g['other.1']
2604        'Room%other'
2605        >>> g.destinationsFrom("Room%subroom")
2606        { '-': 'Room' }
2607        >>> g.destinationsFrom("Room%other")
2608        { '-': 'Room', '-.1': 'Room' }
2609        >>> g.getTransitionRequirement("Room", "other")
2610        ReqPower('fly')
2611        >>> g.getTransitionRequirement("Room%other", "-")
2612        ReqAll(ReqPower('shatter'), ReqPower('fly'))
2613        >>> g.getTransitionRequirement("Room", "other.1")
2614        ReqImpossible()
2615        >>> g.getTransitionRequirement("Room%other", "-.1")
2616        ReqNothing()
2617        """
2618
2619        # Default argument values
2620        if transitionName is None:
2621            transitionName = subRoomName
2622        if tags is None:
2623            tags = set()
2624        if revTags is None:
2625            revTags = set()
2626        if annotations is None:
2627            annotations = []
2628        if revAnnotations is None:
2629            revAnnotations = []
2630
2631        # Tag the transition with 'internal' since this is in-room progress
2632        tags.add('internal')
2633
2634        # Get current stuff
2635        now = self.exploration.currentGraph()
2636        here = self.exploration.currentPosition()
2637        outgoing = now.destinationsFrom(here)
2638        base = self.parseFormat.baseRoomName(here)
2639        currentSubPart = self.parseFormat.roomPartName(here)
2640        if currentSubPart is None:
2641            currentSubPart = self.parseFormat.formatDict["progress"]
2642        destination = self.parseFormat.subRoomName(base, subRoomName)
2643        isNew = destination not in now
2644
2645        # Handle oneway settings (explicit requirements override them)
2646        if oneway is True and revRequires is None: # not including 'hidden'
2647            revRequires = core.ReqImpossible()
2648
2649        # Did we end up creating a new subroom?
2650        createdSubRoom = False
2651
2652        # A hidden oneway applies both explicit and implied transition
2653        # requirements only after the transition has been taken
2654        if oneway == "hidden":
2655            postRevReq: Optional[core.Requirement] = None
2656            if revRequires is None:
2657                postRevReq = core.ReqImpossible()
2658            else:
2659                postRevReq = revRequires
2660            revRequires = None
2661        else:
2662            postRevReq = revRequires
2663
2664        # Are we going somewhere new, or not?
2665        if transitionName in outgoing: # A transition we've seen before
2666            rev = now.getReciprocal(here, transitionName)
2667            if not now.isUnknown(destination): # Just retrace it
2668                self.exploration.retrace(transitionName)
2669            else: # previously unknown
2670                self.exploration.explore(
2671                    transitionName,
2672                    destination,
2673                    [],
2674                    rev # No need to worry here about collisions
2675                )
2676                createdSubRoom = True
2677
2678        else: # A new connection (not necessarily destination)
2679            # Find a unique name for the returning connection
2680            rev = currentSubPart
2681            if not isNew:
2682                rev = core.uniqueName(
2683                    rev,
2684                    now.destinationsFrom(destination)
2685                )
2686
2687            # Add an unexplored transition and then explore it
2688            if not isNew and now.isUnknown(destination):
2689                # Connecting to an existing unexplored region
2690                now.addTransition(
2691                    here,
2692                    transitionName,
2693                    destination,
2694                    rev,
2695                    tags=tags,
2696                    annotations=annotations,
2697                    requires=requires,
2698                    revTags=revTags,
2699                    revAnnotations=revAnnotations,
2700                    revRequires=revRequires
2701                )
2702            else:
2703                # Connecting to a new decision or one that's not
2704                # unexplored
2705                now.addUnexploredEdge(
2706                    here,
2707                    transitionName,
2708                    # auto unexplored name
2709                    reciprocal=rev,
2710                    tags=tags,
2711                    annotations=annotations,
2712                    requires=requires,
2713                    revTags=revTags,
2714                    revAnnotations=revAnnotations,
2715                    revRequires=revRequires
2716                )
2717
2718
2719            # Explore the unknown we just created
2720            if isNew or now.isUnknown(destination):
2721                # A new destination: create it
2722                self.exploration.explore(
2723                    transitionName,
2724                    destination,
2725                    [],
2726                    rev # No need to worry here about collisions
2727                )
2728                createdSubRoom = True
2729            else:
2730                # An existing destination: return to it
2731                self.exploration.returnTo(
2732                    transitionName,
2733                    destination,
2734                    rev
2735                )
2736
2737        # Overwrite requirements, tags, and annotations
2738        # based on any new info. TODO: Warn if new info is
2739        # mismatched with old info?
2740        newGraph = self.exploration.currentGraph()
2741        newPos = self.exploration.currentPosition()
2742        if requires is not None:
2743            self.exploration.updateRequirementNow(
2744                here,
2745                subRoomName,
2746                requires
2747            )
2748        newGraph.tagTransition(here, subRoomName, tags)
2749        newGraph.annotateTransition(here, subRoomName, annotations)
2750
2751        # If there's a reciprocal, apply any specified tags,
2752        # annotations, and/or requirements to it.
2753        reciprocal = newGraph.getReciprocal(here, subRoomName)
2754        if reciprocal is not None:
2755            newGraph.tagTransition(newPos, reciprocal, revTags)
2756            newGraph.annotateTransition(
2757                newPos,
2758                reciprocal,
2759                revAnnotations
2760            )
2761            if revRequires is not None:
2762                newGraph.setTransitionRequirement(
2763                    newPos,
2764                    reciprocal,
2765                    postRevReq
2766                )

Updates the exploration state to indicate that movement to a new sub-room has occurred. Handles three cases: a previously-observed but unexplored sub-room, a never-before-observed sub-room, and a previously-visited sub-room. By using the parse format's progress marker (default '-') as the room name, a transition to the base subroom can be specified.

The destination sub-room name is required, and the exploration object's current position will dictate which decision the player is currently at. If no transition name is specified, the transition name will be the same as the destination name (only the provided sub-room part) or the same as the first previous transition to the specified destination from the current location is such a transition already exists. Optional arguments may specify requirements, tags, and/or annotations to be applied to the transition, and requirements, tags, and/or annotations for the reciprocal transition; these will be applied in the new graph that results, but not retroactively. If the transition is a one-way transition, set oneway to True (default is False). oneway may also be set to the string 'hidden' to indicate a hidden one-way. The newConnection argument should be set to True (default False) if a new connection should be created even in cases where a connection already exists.

Example:

>>> obs = JournalObserver()
>>> obs.observe("[Room]\n< T")
>>> obs.makeProgressInRoom("subroom")
>>> e = obs.getExploration()
>>> len(e)
2
>>> e.currentPosition()
'Room%subroom'
>>> g = e.currentGraph()
>>> g.destinationsFrom("Room")
{ 'T': '_u.0', 'subroom': 'Room%subroom' }
>>> g.destinationsFrom("Room%subroom")
{ '-': 'Room' }
>>> obs.makeProgressInRoom("-") # Back to base subroom
>>> len(e)
3
>>> e.currentPosition()
'Room'
>>> g = e.currentGraph()
>>> g.destinationsFrom("Room")
{ 'T': '_u.0', 'subroom': 'Room%subroom' }
>>> g.destinationsFrom("Room%subroom")
{ '-': 'Room' }
>>> obs.makeProgressInRoom(
...   "other",
...   oneway='hidden',
...   tags={"blue"},
...   requires=core.ReqPower("fly"),
...   revRequires=core.ReqAll(
...     core.ReqPower("shatter"),
...     core.ReqPower("fly")
...   ),
...   revTags={"blue"},
...   annotations=["Another subroom"],
...   revAnnotations=["This way back"],
... )
>>> len(e)
4
>>> e.currentPosition()
'Room%other'
>>> g = e.currentGraph()
>>> g.destinationsFrom("Room")
{ 'T': '_u.0', 'subroom': 'Room%subroom', 'other': 'Room%other' }
>>> g.destinationsFrom("Room%subroom")
{ '-': 'Room' }
>>> g.destinationsFrom("Room%other")
{ '-': 'Room' }
>>> g.getTransitionRequirement("Room", "other")
ReqPower('fly')
>>> g.getTransitionRequirement("Room%other", "-")
ReqAll(ReqPower('shatter'), ReqPower('fly'))
>>> g.transitionTags("Room", "other")
{'blue'}
>>> g.transitionTags("Room%other", "-")
{'blue'}
>>> g.transitionAnnotations("Room", "other")
['Another subroom']
>>> g.transitionAnnotations("Room%other", "-")
['This way back']
>>> prevM = e.graphAtStep(-2)
>>> prevM.destinationsFrom("Room")
{ 'T': '_u.0', 'subroom': 'Room%subroom', 'other': '_u.2' }
>>> prevM.destinationsFrom("Room%subroom")
{ '-': 'Room' }
>>> "Room%other" in prevM
False
>>> obs.makeProgressInRoom("-", transitionName="-.1", oneway=True)
>>> len(e)
5
>>> e.currentPosition()
'Room'
>>> g = e.currentGraph()
>>> d = g.destinationsFrom("Room")
>>> g['T']
'_u.0'
>>> g['subroom']
'Room%subroom'
>>> g['other']
'Room%other'
>>> g['other.1']
'Room%other'
>>> g.destinationsFrom("Room%subroom")
{ '-': 'Room' }
>>> g.destinationsFrom("Room%other")
{ '-': 'Room', '-.1': 'Room' }
>>> g.getTransitionRequirement("Room", "other")
ReqPower('fly')
>>> g.getTransitionRequirement("Room%other", "-")
ReqAll(ReqPower('shatter'), ReqPower('fly'))
>>> g.getTransitionRequirement("Room", "other.1")
ReqImpossible()
>>> g.getTransitionRequirement("Room%other", "-.1")
ReqNothing()
def takeActionInRoom( self, name: Optional[str] = None, gain: Optional[str] = None, forwardReq: Optional[exploration.core.Requirement] = None, extraGain: Optional[exploration.core.Requirement] = None, fTags: Optional[Set[str]] = None, rTags: Optional[Set[str]] = None, untaken: bool = False) -> str:
2768    def takeActionInRoom(
2769        self,
2770        name: Optional[core.Transition] = None,
2771        gain: Optional[str] = None,
2772        forwardReq: Optional[core.Requirement] = None,
2773        extraGain: Optional[core.Requirement] = None,
2774        fTags: Optional[Set[core.Tag]] = None,
2775        rTags: Optional[Set[core.Tag]] = None,
2776        untaken: bool = False
2777    ) -> core.Transition:
2778        """
2779        Adds an action to the current room, and takes it. The exploration to
2780        modify and the parse format to use are required. If a name for the
2781        action is not provided, a unique name will be generated. If the
2782        action results in gaining an item, the item gained should be passed
2783        as a string (will be parsed using `ParseFormat.parseItem`).
2784        Forward/backward requirements and tags may be provided, but passing
2785        anything other than None for the backward requirement or tags will
2786        result in a `JournalParseError`.
2787
2788        If `untaken` is set to True (default is False) then the action will
2789        be created, but will not be taken.
2790
2791        Returns the name of the transition, which is either the specified
2792        name or a unique name created automatically.
2793        """
2794        # Get current info
2795        here = self.exploration.currentPosition()
2796        now = self.exploration.currentGraph()
2797
2798        # Assign a unique action name if none was provided
2799        wantsUnique = False
2800        if name is None:
2801            wantsUnique = True
2802            name = f"action@{len(exploration)}"
2803
2804        # Accumulate powers/tokens gained
2805        gainedStuff = []
2806        # Parse item gained if there is one, and add it to the action name
2807        # as well
2808        if gain is not None:
2809            gainedStuff.append(parseFormat.parseItem(gain))
2810            name += gain
2811
2812        # Reverse requirements are translated into extra powers/tokens gained
2813        # (but may only be a disjunction of power/token requirements).
2814        # TODO: Allow using ReqNot to instantiate power-removal/token-cost
2815        # effects!!!
2816        if extraGain is not None:
2817            gainedStuff.extend(extraGain.asGainList())
2818
2819        if len(gainedStuff) > 0:
2820            effects = core.effects(gain=gainedStuff)
2821        else:
2822            effects = core.effects() # no effects
2823
2824        # Ensure that action name is unique
2825        if wantsUnique:
2826            # Find all transitions that start with this name which have a
2827            # '.' in their name.
2828            already = [
2829                transition
2830                for transition in now.destinationsFrom(here)
2831                if transition.startswith(name) and '.' in transition
2832            ]
2833
2834            # Collect just the numerical parts after the dots
2835            nums = []
2836            for prev in already:
2837                try:
2838                    nums.append(int(prev.split('.')[-1]))
2839                except ValueError:
2840                    pass
2841
2842            # If there aren't any (or aren't any with a .number part), make
2843            # the name unique by adding '.1'
2844            if len(nums) == 0:
2845                name = name + '.1'
2846            else:
2847                # If there are nums, pick a higher one
2848                name = name + '.' + str(max(nums) + 1)
2849
2850        # TODO: Handle repeatable actions with effects, and other effect
2851        # types...
2852
2853        if rTags:
2854            raise JournalParseError(
2855                f"Cannot apply reverse tags {rTags} to action '{name}' in"
2856                f" room {here}: Actions have no reciprocal."
2857            )
2858
2859        # Create and/or take the action
2860        if untaken:
2861            now.addAction(
2862                here,
2863                name,
2864                forwardReq, # might be None
2865                effects
2866            )
2867        else:
2868            exploration.takeAction(
2869                name,
2870                forwardReq, # might be None
2871                effects
2872            )
2873
2874        # Apply tags to the action transition
2875        if fTags is not None:
2876            now = exploration.currentGraph()
2877            now.tagTransition(here, name, fTags)
2878
2879        # Return the action name
2880        return name

Adds an action to the current room, and takes it. The exploration to modify and the parse format to use are required. If a name for the action is not provided, a unique name will be generated. If the action results in gaining an item, the item gained should be passed as a string (will be parsed using ParseFormat.parseItem). Forward/backward requirements and tags may be provided, but passing anything other than None for the backward requirement or tags will result in a JournalParseError.

If untaken is set to True (default is False) then the action will be created, but will not be taken.

Returns the name of the transition, which is either the specified name or a unique name created automatically.

def observeRoomEntrance( self, transitionTaken: str, roomName: str, revName: Optional[str] = None, oneway: bool = False, fReq: Optional[exploration.core.Requirement] = None, rReq: Optional[exploration.core.Requirement] = None, fTags: Optional[Set[str]] = None, rTags: Optional[Set[str]] = None)
2882    def observeRoomEntrance(
2883        self,
2884        transitionTaken: core.Transition,
2885        roomName: core.Decision,
2886        revName: Optional[core.Transition] = None,
2887        oneway: bool = False,
2888        fReq: Optional[core.Requirement] = None,
2889        rReq: Optional[core.Requirement] = None,
2890        fTags: Optional[Set[core.Tag]] = None,
2891        rTags: Optional[Set[core.Tag]] = None
2892    ):
2893        """
2894        Records entry into a new room via a specific transition from the
2895        current position, creating a new unexplored node if necessary
2896        and then exploring it, or returning to or retracing an existing
2897        decision/transition.
2898        """
2899
2900        # TODO: HERE

Records entry into a new room via a specific transition from the current position, creating a new unexplored node if necessary and then exploring it, or returning to or retracing an existing decision/transition.

def updateExplorationFromEntry( exploration: exploration.core.Exploration, parseFormat: exploration.oldJournal.ParseFormat, journalBlock: str, enterFrom: Optional[Tuple[str, str]] = None) -> Optional[Tuple[str, str]]:
3081def updateExplorationFromEntry(
3082    exploration: core.Exploration,
3083    parseFormat: ParseFormat,
3084    journalBlock: str,
3085    enterFrom: Optional[Tuple[core.Decision, core.Transition]] = None,
3086) -> Optional[Tuple[core.Decision, core.Transition]]:
3087    """
3088    Given an exploration object, a parsing format dictionary, and a
3089    multi-line string which is a journal entry block, updates the
3090    exploration to reflect the entries in the block. Except for the
3091    first block of a journal, or continuing blocks after an ending,
3092    where `enterFrom` must be None, a tuple specifying the room and
3093    transition taken to enter the block must be provided so we know where
3094    to anchor the new activity.
3095
3096    This function returns a tuple specifying the room and transition in
3097    that room taken to exit from the block, which can be used as the
3098    `enterFrom` value for the next block. It returns none if the block
3099    ends with an 'ending' entry.
3100    """
3101    # Set up state variables
3102
3103    # Tracks the room name, once one has been declared
3104    roomName: Optional[core.Decision] = None
3105    roomTags: Set[core.Tag] = set()
3106
3107    # Whether we've seen an entrance/exit yet
3108    seenEntrance = False
3109
3110    # The room & transition used to exit
3111    exitRoom = None
3112    exitTransition = None
3113
3114    # This tracks the current note text, since notes can continue across
3115    # multiple lines
3116    currentNote: Optional[Tuple[
3117        Union[core.Decision, Tuple[core.Decision, core.Transition]], # target
3118        bool, # was this note indented?
3119        str # note text
3120    ]] = None
3121
3122    # Tracks a pending progress step, since things like a oneway can be
3123    # used for either within-room progress OR room-to-room transitions.
3124    pendingProgress: Optional[Tuple[
3125        core.Transition, # transition name to create
3126        Union[bool, str], # is it one-way; 'hidden' for a hidden one-way?
3127        Optional[core.Requirement], # requirement for the transition
3128        Optional[core.Requirement], # reciprocal requirement
3129        Optional[Set[core.Tag]], # tags to apply
3130        Optional[Set[core.Tag]], # reciprocal tags
3131        Optional[List[core.Annotation]], # annotations to apply
3132        Optional[List[core.Annotation]] # reciprocal annotations
3133    ]] = None
3134
3135    # This tracks the current entries in an inter-room abbreviated path,
3136    # since we first have to accumulate all of them and then do
3137    # pathfinding to figure out a concrete inter-room path.
3138    interRoomPath: List[Union[Type[InterRoomEllipsis], core.Decision]] = []
3139
3140    # Standardize newlines just in case
3141    journalBlock = journalBlock\
3142        .replace('\r\n', '\n')\
3143        .replace('\n\r', '\n')\
3144        .replace('\r', '\n')
3145
3146    # Line splitting variables
3147    lineNumber = 0 # first iteration will increment to 1 before use
3148    blockIndex = 0 # Character index into the block tracking progress
3149    blockLen = len(journalBlock) # So we know when to stop
3150    lineIncrement = 1 # How many lines we've processed
3151
3152    # Tracks presence of an end entry, which must be final in the block
3153    # except for notes or tags.
3154    ended = False
3155
3156    # Parse each line separately, but collect multiple lines for
3157    # multi-line detours
3158    while blockIndex < blockLen:
3159        lineNumber += lineIncrement
3160        lineIncrement = 1
3161        try:
3162            # Find the next newline
3163            nextNL = journalBlock.index('\n', blockIndex)
3164            line = journalBlock[blockIndex:nextNL]
3165            blockIndex = nextNL + 1
3166        except ValueError:
3167            # If there isn't one, rest of the block is the next line
3168            line = journalBlock[blockIndex:]
3169            blockIndex = blockLen
3170
3171        print("LL", lineNumber, line)
3172
3173        # Check for and split off anonymous room content
3174        line, anonymousContent = parseFormat.splitAnonymousRoom(line)
3175        if (
3176            anonymousContent is None
3177        and parseFormat.startsAnonymousRoom(line)
3178        ):
3179            endIndex = parseFormat.anonymousRoomEnd(
3180                journalBlock,
3181                blockIndex
3182            )
3183            if endIndex is None:
3184                raise JournalParseError(
3185                    f"Anonymous room started on line {lineNumber}"
3186                    f" was never closed in block:\n{journalBlock}"
3187                )
3188            anonymousContent = journalBlock[nextNL + 1:endIndex].strip()
3189            # TODO: Is this correct?
3190            lineIncrement = anonymousContent.count('\n') + 1
3191            # Skip to end of line where anonymous room ends
3192            blockIndex = journalBlock.index('\n', endIndex + 1)
3193
3194            # Trim the start of the anonymous room from the line end
3195            line = line.rstrip()[:-1]
3196
3197        # Skip blank lines
3198        if not line.strip():
3199            continue
3200
3201        # Check for indentation (mostly ignored, but important for
3202        # comments).
3203        indented = line[0] == ' '
3204
3205        # Strip indentation going forward
3206        line = line.strip()
3207
3208        # Detect entry type and separate content
3209        eType, eContent = parseFormat.determineEntryType(line)
3210
3211        print("EE", lineNumber, eType, eContent)
3212
3213        if exitTransition is not None and eType != 'note':
3214            raise JournalParseError(
3215                f"Entry after room exit on line {lineNumber} in"
3216                f" block:\n{journalBlock}"
3217            )
3218
3219        if eType != 'detour' and anonymousContent is not None:
3220            raise JournalParseError(
3221                f"Entry #{lineNumber} with type {eType} does not"
3222                f" support anonymous room content. Block"
3223                f" is:\n{journalBlock}"
3224            )
3225
3226        # Handle note creation
3227        if currentNote is not None and eType != 'note':
3228            # This ends a note, so we can apply the pending note and
3229            # reset it.
3230            target, _, noteText = currentNote
3231            currentNote = None
3232            # Apply our annotation to the room or transition it targets
3233            if isinstance(target, str):
3234                exploration.currentGraph().annotateDecision(target, noteText)
3235            else:
3236                room, transition = target
3237                exploration.currentGraph().annotateTransition(
3238                    room,
3239                    transition,
3240                    noteText
3241                )
3242        elif eType == 'note':
3243            # whole line is a note; handle new vs. continuing note
3244            if currentNote is None:
3245                # Start a new note
3246                currentNote = (
3247                    exploration.currentPosition(),
3248                    indented,
3249                    eContent
3250                )
3251            else:
3252                # Previous note exists, use indentation to decide if
3253                # we're continuing or starting a new note
3254                target, wasIndented, noteText = currentNote
3255                if indented != wasIndented:
3256                    # Then we apply the old note and create a new note at
3257                    # the room level
3258                    if isinstance(target, str):
3259                        exploration.currentGraph().annotateDecision(
3260                            target,
3261                            noteText
3262                        )
3263                    else:
3264                        room, transition = target
3265                        exploration.currentGraph().annotateTransition(
3266                            room,
3267                            transition,
3268                            noteText
3269                        )
3270                    currentNote = (
3271                        exploration.currentPosition(),
3272                        indented,
3273                        f"(step #{len(exploration)}) " + eContent
3274                    )
3275                else:
3276                    # Else indentation matches so add to previous note
3277                    currentNote = (
3278                        target,
3279                        wasIndented,
3280                        noteText + '\n' + eContent
3281                    )
3282            # In (only) this case, we've handled the entire line
3283            continue
3284
3285        # Handle a pending progress step if there is one
3286        if pendingProgress is not None:
3287            # Any kind of entry except a note (which we would have hit
3288            # above and continued) indicates that a progress marker is
3289            # in-room progress rather than being a room exit.
3290            makeProgressInRoom(exploration, parseFormat, *pendingProgress)
3291
3292            # Clean out pendingProgress
3293            pendingProgress = None
3294
3295        # Check for valid eType if pre-room
3296        if roomName is None and eType not in ('room', 'progress'):
3297            raise JournalParseError(
3298                f"Invalid entry #{lineNumber}: Entry type '{eType}' not"
3299                f" allowed before room name. Block is:\n{journalBlock}"
3300            )
3301
3302        # Check for valid eType if post-room
3303        if ended and eType not in ('note', 'tag'):
3304            raise JournalParseError(
3305                f"Invalid entry #{lineNumber}: Entry type '{eType}' not"
3306                f" allowed after an ending. Block is:\n{journalBlock}"
3307            )
3308
3309        # Parse a line-end note if there is one
3310        # Note that note content will be handled after we handle main
3311        # entry stuff
3312        content, note = parseFormat.splitFinalNote(eContent)
3313
3314        # Parse a line-end tags section if there is one
3315        content, fTags, rTags = parseFormat.splitTags(content)
3316
3317        # Parse a line-end requirements section if there is one
3318        content, forwardReq, backReq = parseFormat.splitRequirement(content)
3319
3320        # Strip any remaining whitespace from the edges of our content
3321        content = content.strip()
3322
3323        # Get current graph
3324        now = exploration.getCurrentGraph()
3325
3326        # This will trigger on the first line in the room, and handles
3327        # the actual room creation in the graph
3328        handledEntry = False # did we handle the entry in this block?
3329        if roomName is not None and not seenEntrance:
3330            # We're looking for an entrance and if we see anything else
3331            # except a tag, we'll assume that the entrance is implicit,
3332            # and give an error if we don't have an implicit entrance
3333            # set up. If the entrance is explicit, we'll give a warning
3334            # if it doesn't match the previous entrance for the same
3335            # prior-room exit from last time.
3336            if eType in ('entrance', 'otherway'):
3337                # An explicit entrance; must match previous associated
3338                # entrance if there was one.
3339
3340                # An otherway marker can be used as an entrance to
3341                # indicate that the connection is one-way. Note that for
3342                # a one-way connection, we may have a requirement
3343                # specifying that the reverse connection exists but
3344                # can't be traversed yet. In cases where there is no
3345                # requirement, we *still* add a reciprocal edge to the
3346                # graph, but mark it as `ReqImpossible`. This is because
3347                # we want the rooms' adjacency to be visible from both
3348                # sides, and some of our graph algorithms have to respect
3349                # requirements anyways. Cases where a reciprocal edge
3350                # will be absent are one-way teleporters where there's
3351                # actually no sealed connection indicator in the
3352                # destination room. TODO: Syntax for those?
3353
3354                # Get transition name
3355                transitionName = content
3356
3357                # If this is not the start of the exploration or a
3358                # reset after an ending, check for a previous transition
3359                # entering this decision from the same previous
3360                # room/transition.
3361                prevReciprocal = None
3362                prevDestination = None
3363                if enterFrom is not None and now is not None:
3364                    fromDecision, fromTransition = enterFrom
3365                    prevReciprocal = now.getReciprocal(
3366                        fromDecision,
3367                        fromTransition
3368                    )
3369                    prevDestination = now.getDestination(
3370                        fromDecision,
3371                        fromTransition
3372                    )
3373                    if prevDestination is None:
3374                        raise JournalParseError(
3375                            f"Transition {fromTransition} from"
3376                            f" {fromDecision} was named as exploration"
3377                            f" point but has not been created!"
3378                        )
3379
3380                    # If there is a previous reciprocal edge marked, and
3381                    # it doesn't match the entering reciprocal edge,
3382                    # that's an inconsistency, unless that edge was
3383                    # coming from an unknown node.
3384                    if (
3385                        not now.isUnknown(prevDestination)
3386                    and prevReciprocal != transitionName
3387                    ): # prevReciprocal of None won't be
3388                        warnings.warn(
3389                            (
3390                                f"Explicit incoming transition from"
3391                                f" {fromDecision}:{fromTransition}"
3392                                f" entering {roomName} via"
3393                                f" {transitionName} does not match"
3394                                f" previous entrance point for that"
3395                                f" transition, which was"
3396                                f" {prevReciprocal}. The reciprocal edge"
3397                                f" will NOT be updated."
3398                            ),
3399                            JournalParseWarning
3400                        )
3401
3402                    # Similarly, if there is an outgoing transition in
3403                    # the destination room whose name matches the
3404                    # declared reciprocal but whose destination isn't
3405                    # unknown and isn't he current location, that's an
3406                    # inconsistency
3407                    prevRevDestination = now.getDestination(
3408                        roomName,
3409                        transitionName
3410                    )
3411                    if (
3412                        prevRevDestination is not None
3413                    and not now.isUnknown(prevRevDestination)
3414                    and prevRevDestination != fromDecision
3415                    ):
3416                        warnings.warn(
3417                            (
3418                                f"Explicit incoming transition from"
3419                                f" {fromDecision}:{fromTransition}"
3420                                f" entering {roomName} via"
3421                                f" {transitionName} does not match"
3422                                f" previous destination for"
3423                                f" {transitionName} in that room, which was"
3424                                f" {prevRevDestination}. The reciprocal edge"
3425                                f" will NOT be updated."
3426                                # TODO: What will happen?
3427                            ),
3428                            JournalParseWarning
3429                        )
3430
3431                seenEntrance = True
3432                handledEntry = True
3433                if enterFrom is None or now is None:
3434                    # No incoming transition info
3435                    if len(exploration) == 0:
3436                        # Start of the exploration
3437                        exploration.start(roomName, [])
3438                        # with an explicit entrance.
3439                        exploration.currentGraph().addUnexploredEdge(
3440                            roomName,
3441                            transitionName,
3442                            tags=fTags,
3443                            revTags=rTags,
3444                            requires=forwardReq,
3445                            revRequires=backReq
3446                        )
3447                    else:
3448                        # Continuing after an ending MUST NOT involve an
3449                        # explicit entrance, because the transition is a
3450                        # warp. To annotate a warp where the character
3451                        # enters back into the game using a traversable
3452                        # transition (and e.g., transition effects
3453                        # apply), include a block noting their presence
3454                        # on the other side of that doorway followed by
3455                        # an explicit transition into the room where
3456                        # control is available, with a 'forced' tag. If
3457                        # the other side is unknown, just use an
3458                        # unexplored entry as the first entry in the
3459                        # block after the ending.
3460                        raise JournalParseError(
3461                            f"On line #{lineNumber}, an explicit"
3462                            f" entrance is not allowed because the"
3463                            f" previous block ended with an ending."
3464                            f" Block is:\n{journalBlock}"
3465                        )
3466                else:
3467                    # Implicitly, prevDestination must not be None here,
3468                    # since a JournalParseError would have been raised
3469                    # if enterFrom was not None and we didn't get a
3470                    # prevDestination. But it might be an unknown area.
3471                    prevDestination = cast(core.Decision, prevDestination)
3472
3473                    # Extract room & transition we're entering from
3474                    fromRoom, fromTransition = enterFrom
3475
3476                    # If we've seen this room before, check for an old
3477                    # transition destination, since we might implicitly
3478                    # be entering a sub-room.
3479                    if now is not None and roomName in now:
3480                        if now.isUnknown(prevDestination):
3481                            # The room already exists, but the
3482                            # transition we're taking to enter it is not
3483                            # one we've used before. If the entry point
3484                            # is not a known transition, unless the
3485                            # journaler has explicitly tagged the
3486                            # reciprocal transition with 'discovered', we
3487                            # assume entrance is to a new sub-room, since
3488                            # otherwise the transition should have been
3489                            # known ahead of time.
3490                            # TODO: Does this mean we have to search for
3491                            # matching names in other sub-room parts
3492                            # when doing in-room transitions... ?
3493                            exploration.returnTo(
3494                                fromTransition,
3495                                roomName,
3496                                transitionName
3497                            )
3498                        else:
3499                            # We already know where this transition
3500                            # leads
3501                            exploration.retrace(fromTransition)
3502                    else:
3503                        # We're entering this room for the first time.
3504                        exploration.explore(
3505                            fromTransition,
3506                            roomName,
3507                            [],
3508                            transitionName
3509                        )
3510                    # Apply forward tags to the outgoing transition
3511                    # that's named, and reverse tags to the incoming
3512                    # transition we just followed
3513                    now = exploration.currentGraph() # graph was updated
3514                    here = exploration.currentPosition()
3515                    now.tagTransition(here, transitionName, fTags)
3516                    now.tagTransition(fromRoom, fromTransition, rTags)
3517
3518            elif eType == 'tag':
3519                roomTags |= set(content.split())
3520                if fTags or rTags:
3521                    raise JournalParseError(
3522                        f"Found tags on tag entry on line #{lineNumber}"
3523                        f" of block:\n{journalBlock}"
3524                    )
3525                # don't do anything else here since it's a tag;
3526                # seenEntrance remains False
3527                handledEntry = True
3528
3529            else:
3530                # For any other entry type, it counts as an implicit
3531                # entrance. We need to follow that transition, or if an
3532                # appropriate link does not already exist, raise an
3533                # error.
3534                seenEntrance = True
3535                # handledEntry remains False in this case
3536
3537                # Check that the entry point for this room can be
3538                # deduced, and deduce it so that we can figure out which
3539                # sub-room we're actually entering...
3540                if enterFrom is None:
3541                    if len(exploration) == 0:
3542                        # At the start of the exploration, there's often
3543                        # no specific transition we come from, which is
3544                        # fine.
3545                        exploration.start(roomName, [])
3546                    else:
3547                        # Continuation after an ending
3548                        exploration.warp(roomName, 'restart')
3549                else:
3550                    fromDecision, fromTransition = enterFrom
3551                    prevReciprocal = None
3552                    if now is not None:
3553                        prevReciprocal = now.getReciprocal(
3554                            fromDecision,
3555                            fromTransition
3556                        )
3557                    if prevReciprocal is None:
3558                        raise JournalParseError(
3559                            f"Implicit transition into room {roomName}"
3560                            f" is invalid because no reciprocal"
3561                            f" transition has been established for exit"
3562                            f" {fromTransition} in previous room"
3563                            f" {fromDecision}."
3564                        )
3565
3566                    # In this case, we retrace the transition, and if
3567                    # that fails because of a ValueError (e.g., because
3568                    # that transition doesn't exist yet or leads to an
3569                    # unknown node) then we'll raise the error as a
3570                    # JournalParseError.
3571                    try:
3572                        exploration.retrace(fromTransition)
3573                    except ValueError as e:
3574                        raise JournalParseError(
3575                            f"Implicit transition into room {roomName}"
3576                            f" is invalid because:\n{e.args[0]}"
3577                        )
3578
3579                    # Note: no tags get applied here, because this is an
3580                    # implicit transition, so there's no room to apply
3581                    # new tags. An explicit transition could be used
3582                    # instead to update transition properties.
3583
3584        # Previous block may have updated the current graph
3585        now = exploration.getCurrentGraph()
3586
3587        # At this point, if we've seen an entrance we're in the right
3588        # room, so we should apply accumulated room tags
3589        if seenEntrance and roomTags:
3590            if now is None:
3591                raise RuntimeError(
3592                    "Inconsistency: seenEntrance is True but the current"
3593                    " graph is None."
3594                )
3595
3596            here = exploration.currentPosition()
3597            now.tagDecision(here, roomTags)
3598            roomTags = set() # reset room tags
3599
3600        # Handle all entry types not handled above (like note)
3601        if handledEntry:
3602            # We skip this if/else but still do end-of-loop cleanup
3603            pass
3604
3605        elif eType == 'note':
3606            raise RuntimeError("Saw 'note' eType in lower handling block.")
3607
3608        elif eType == 'room':
3609            if roomName is not None:
3610                raise ValueError(
3611                    f"Multiple room names detected on line {lineNumber}"
3612                    f" in block:\n{journalBlock}"
3613                )
3614
3615            # Setting the room name changes the loop state
3616            roomName = content
3617
3618            # These will be applied later
3619            roomTags = fTags
3620
3621            if rTags:
3622                raise JournalParseError(
3623                    f"Reverse tags cannot be applied to a room"
3624                    f" (found tags {rTags} for room '{roomName}')."
3625                )
3626
3627        elif eType == 'entrance':
3628            # would be handled above if seenEntrance was false
3629            raise JournalParseError(
3630                f"Multiple entrances on line {lineNumber} in"
3631                f" block:\n{journalBlock}"
3632            )
3633
3634        elif eType == 'exit':
3635            # We note the exit transition and will use that as our
3636            # return value. This also will cause an error on the next
3637            # iteration if there are further non-note entries in the
3638            # journal block
3639            exitRoom = exploration.currentPosition()
3640            exitTransition = content
3641
3642            # At this point we add an unexplored edge for this exit,
3643            # assuming it's not one we've seen before. Note that this
3644            # does not create a new exploration step (that will happen
3645            # later).
3646            knownDestination = None
3647            if now is not None:
3648                knownDestination = now.getDestination(
3649                    exitRoom,
3650                    exitTransition
3651                )
3652
3653                if knownDestination is None:
3654                    now.addUnexploredEdge(
3655                        exitRoom,
3656                        exitTransition,
3657                        tags=fTags,
3658                        revTags=rTags,
3659                        requires=forwardReq,
3660                        revRequires=backReq
3661                    )
3662
3663                else:
3664                    # Otherwise just apply any tags to the transition
3665                    now.tagTransition(exitRoom, exitTransition, fTags)
3666                    existingReciprocal = now.getReciprocal(
3667                        exitRoom,
3668                        exitTransition
3669                    )
3670                    if existingReciprocal is not None:
3671                        now.tagTransition(
3672                            knownDestination,
3673                            existingReciprocal,
3674                            rTags
3675                        )
3676
3677        elif eType in (
3678            'blocked',
3679            'otherway',
3680            'unexplored',
3681            'unexploredOneway',
3682        ):
3683            # Simply add the listed transition to our current room,
3684            # leading to an unknown destination, without creating a new
3685            # exploration step
3686            transition = content
3687            here = exploration.currentPosition()
3688
3689            # If there isn't a listed requirement, infer ReqImpossible
3690            # where appropriate
3691            if forwardReq is None and eType in ('blocked', 'otherway'):
3692                forwardReq = core.ReqImpossible()
3693            if backReq is None and eType in ('blocked', 'unexploredOneway'):
3694                backReq = core.ReqImpossible()
3695
3696            # TODO: What if we've annotated a known source for this
3697            # link?
3698
3699            if now is None:
3700                raise JournalParseError(
3701                    f"On line {lineNumber}: Cannot create an unexplored"
3702                    f" transition before we've created the starting"
3703                    f" graph. Block is:\n{journalBlock}"
3704                )
3705
3706            now.addUnexploredEdge(
3707                here,
3708                transition,
3709                tags=fTags,
3710                revTags=rTags,
3711                requires=forwardReq,
3712                revRequires=backReq
3713            )
3714
3715        elif eType in ('pickup', 'unclaimed', 'action'):
3716            # We both add an action to the current room, and then take
3717            # that action, or if the type is unclaimed, we don't take
3718            # the action.
3719
3720            if eType == 'unclaimed' and content[0] == '?':
3721                fTags.add('unknown')
3722
3723            name: Optional[str] = None # auto by default
3724            gains: Optional[str] = None
3725            if eType == 'action':
3726                name = content
3727                # TODO: Generalize action effects; also handle toggles,
3728                # repeatability, etc.
3729            else:
3730                gains = content
3731
3732            actionName = takeActionInRoom(
3733                exploration,
3734                parseFormat,
3735                name,
3736                gains,
3737                forwardReq,
3738                backReq,
3739                fTags,
3740                rTags,
3741                eType == 'unclaimed' # whether to leave it untaken
3742            )
3743
3744            # Limit scope to this case
3745            del name
3746            del gains
3747
3748        elif eType == 'progress':
3749            # If the room name hasn't been specified yet, this indicates
3750            # a room that we traverse en route. If the room name has
3751            # been specified, this is movement to a new sub-room.
3752            if roomName is None:
3753                # Here we need to accumulate the named route, since the
3754                # navigation of sub-rooms has to be figured out by
3755                # pathfinding, but that's only possible once we know
3756                # *all* of the listed rooms. Note that the parse
3757                # format's 'runback' symbol may be used as a room name
3758                # to indicate that some of the route should be
3759                # auto-completed.
3760                if content == parseFormat.formatDict['runback']:
3761                    interRoomPath.append(InterRoomEllipsis)
3762                else:
3763                    interRoomPath.append(content)
3764            else:
3765                # This is progress to a new sub-room. If we've been
3766                # to that sub-room from the current sub-room before, we
3767                # retrace the connection, and if not, we first add an
3768                # unexplored connection and then explore it.
3769                makeProgressInRoom(
3770                    exploration,
3771                    parseFormat,
3772                    content,
3773                    False,
3774                    forwardReq,
3775                    backReq,
3776                    fTags,
3777                    rTags
3778                    # annotations handled separately
3779                )
3780
3781        elif eType == 'frontier':
3782            pass
3783            # TODO: HERE
3784
3785        elif eType == 'frontierEnd':
3786            pass
3787            # TODO: HERE
3788
3789        elif eType == 'oops':
3790            # This removes the specified transition from the graph,
3791            # creating a new exploration step to do so. It tags that
3792            # transition as an oops in the previous graph, because the
3793            # transition won't exist to be tagged in the new graph. If the
3794            # transition led to a non-frontier unknown node, that entire
3795            # node is removed; otherwise just the single transition is
3796            # removed, along with its reciprocal.
3797            if now is None:
3798                raise JournalParseError(
3799                    f"On line {lineNumber}: Cannot mark an oops before"
3800                    f" we've created the starting graph. Block"
3801                    f" is:\n{journalBlock}"
3802                )
3803
3804            prev = now # remember the previous graph
3805            # TODO
3806            now = exploration.currentGraph()
3807            here = exploration.currentPosition()
3808            print("OOP", now.destinationsFrom(here))
3809            exploration.wait('oops') # create new step w/ no changes
3810            now = exploration.currentGraph()
3811            here = exploration.currentPosition()
3812            accidental = now.getDestination(here, content)
3813            if accidental is None:
3814                raise JournalParseError(
3815                    f"Cannot erase transition '{content}' because it"
3816                    f" does not exist at decision {here}."
3817                )
3818
3819            # If it's an unknown (the usual case) then we remove the
3820            # entire node
3821            if now.isUnknown(accidental):
3822                now.remove_node(accidental)
3823            else:
3824                # Otherwise re move the edge and its reciprocal
3825                reciprocal = now.getReciprocal(here, content)
3826                now.remove_edge(here, accidental, content)
3827                if reciprocal is not None:
3828                    now.remove_edge(accidental, here, reciprocal)
3829
3830            # Tag the transition as an oops in the step before it gets
3831            # removed:
3832            prev.tagTransition(here, content, 'oops')
3833
3834        elif eType in ('oneway', 'hiddenOneway'):
3835            # In these cases, we create a pending progress value, since
3836            # it's possible to use 'oneway' as the exit from a room in
3837            # which case it's not in-room progress but rather a room
3838            # transition.
3839            pendingProgress = (
3840                content,
3841                True if eType == 'oneway' else 'hidden',
3842                forwardReq,
3843                backReq,
3844                fTags,
3845                rTags,
3846                None, # No annotations need be applied now
3847                None
3848            )
3849
3850        elif eType == 'detour':
3851            if anonymousContent is None:
3852                raise JournalParseError(
3853                    f"Detour on line #{lineNumber} is missing an"
3854                    f" anonymous room definition. Block"
3855                    f" is:\n{journalBlock}"
3856                )
3857            # TODO: Support detours to existing rooms w/out anonymous
3858            # content...
3859            if now is None:
3860                raise JournalParseError(
3861                    f"On line {lineNumber}: Cannot create a detour"
3862                    f" before we've created the starting graph. Block"
3863                    f" is:\n{journalBlock}"
3864                )
3865
3866            # First, we create an unexplored transition and then use it
3867            # to enter the anonymous room...
3868            here = exploration.currentPosition()
3869            now.addUnexploredEdge(
3870                here,
3871                content,
3872                tags=fTags,
3873                revTags=rTags,
3874                requires=forwardReq,
3875                revRequires=backReq
3876            )
3877
3878            if roomName is None:
3879                raise JournalParseError(
3880                    f"Detour on line #{lineNumber} occurred before room"
3881                    f" name was known. Block is:\n{journalBlock}"
3882                )
3883
3884            # Get a new unique anonymous name
3885            anonName = parseFormat.anonName(roomName, content)
3886
3887            # Actually enter our detour room
3888            exploration.explore(
3889                content,
3890                anonName,
3891                [], # No connections yet
3892                content + '-return'
3893            )
3894
3895            # Tag the new room as anonymous
3896            now = exploration.currentGraph()
3897            now.tagDecision(anonName, 'anonymous')
3898
3899            # Remember transitions needed to get out of room
3900            thread: List[core.Transition] = []
3901
3902            # Parse in-room activity and create steps for it
3903            anonLines = anonymousContent.splitlines()
3904            for anonLine in anonLines:
3905                anonLine = anonLine.strip()
3906                try:
3907                    anonType, anonContent = parseFormat.determineEntryType(
3908                        anonLine
3909                    )
3910                except JournalParseError:
3911                    # One liner that doesn't parse -> treat as tag(s)
3912                    anonType = 'tag'
3913                    anonContent = anonLine.strip()
3914                    if len(anonLines) > 1:
3915                        raise JournalParseError(
3916                            f"Detour on line #{lineNumber} has multiple"
3917                            f" lines but one cannot be parsed as an"
3918                            f" entry:\n{anonLine}\nBlock"
3919                            f" is:\n{journalBlock}"
3920                        )
3921
3922                # Parse final notes, tags, and/or requirements
3923                if anonType != 'note':
3924                    anonContent, note = parseFormat.splitFinalNote(
3925                        anonContent
3926                    )
3927                    anonContent, fTags, rTags = parseFormat.splitTags(
3928                        anonContent
3929                    )
3930                    (
3931                        anonContent,
3932                        forwardReq,
3933                        backReq
3934                    ) = parseFormat.splitRequirement(anonContent)
3935
3936                if anonType == 'note':
3937                    here = exploration.currentPosition()
3938                    now.annotateDecision(here, anonContent)
3939                    # We don't handle multi-line notes in anon rooms
3940
3941                elif anonType == 'tag':
3942                    tags = set(anonContent.split())
3943                    here = exploration.currentPosition()
3944                    now.tagDecision(here, tags)
3945                    if note is not None:
3946                        now.annotateDecision(here, note)
3947
3948                elif anonType == 'progress':
3949                    makeProgressInRoom(
3950                        exploration,
3951                        parseFormat,
3952                        anonContent,
3953                        False,
3954                        forwardReq,
3955                        backReq,
3956                        fTags,
3957                        rTags,
3958                        [ note ] if note is not None else None
3959                        # No reverse annotations
3960                    )
3961                    # We don't handle multi-line notes in anon rooms
3962
3963                    # Remember the way back
3964                    # TODO: HERE Is this still accurate?
3965                    thread.append(anonContent + '-return')
3966
3967                elif anonType in ('pickup', 'unclaimed', 'action'):
3968
3969                    if (
3970                        anonType == 'unclaimed'
3971                    and anonContent.startswith('?')
3972                    ):
3973                        fTags.add('unknown')
3974
3975                    # Note: these are both type Optional[str], but since
3976                    # they exist in another case, they can't be
3977                    # explicitly typed that way here. See:
3978                    # https://github.com/python/mypy/issues/1174
3979                    name = None
3980                    gains = None
3981                    if anonType == 'action':
3982                        name = anonContent
3983                    else:
3984                        gains = anonContent
3985
3986                    actionName = takeActionInRoom(
3987                        exploration,
3988                        parseFormat,
3989                        name,
3990                        gains,
3991                        forwardReq,
3992                        backReq,
3993                        fTags,
3994                        rTags,
3995                        anonType == 'unclaimed' # leave it untaken or not?
3996                    )
3997
3998                    # Limit scope
3999                    del name
4000                    del gains
4001
4002                elif anonType == 'challenge':
4003                    here = exploration.currentPosition()
4004                    now.annotateDecision(
4005                        here,
4006                        "challenge: " + anonContent
4007                    )
4008
4009                elif anonType in ('blocked', 'otherway'):
4010                    here = exploration.currentPosition()
4011
4012                    # Mark as blocked even when no explicit requirement
4013                    # has been provided
4014                    if forwardReq is None:
4015                        forwardReq = core.ReqImpossible()
4016                    if backReq is None and anonType == 'blocked':
4017                        backReq = core.ReqImpossible()
4018
4019                    now.addUnexploredEdge(
4020                        here,
4021                        anonContent,
4022                        tags=fTags,
4023                        revTags=rTags,
4024                        requires=forwardReq,
4025                        revRequires=backReq
4026                    )
4027
4028                else:
4029                    # TODO: Any more entry types we need to support in
4030                    # anonymous rooms?
4031                    raise JournalParseError(
4032                        f"Detour on line #{lineNumber} includes an"
4033                        f" entry of type '{anonType}' which is not"
4034                        f" allowed in an anonymous room. Block"
4035                        f" is:\n{journalBlock}"
4036                    )
4037
4038            # If we made progress, backtrack to the start of the room
4039            for backwards in thread:
4040                exploration.retrace(backwards)
4041
4042            # Now we exit back to the original room
4043            exploration.retrace(content + '-return')
4044
4045        elif eType == 'unify': # TODO: HERE
4046            pass
4047
4048        elif eType == 'obviate': # TODO: HERE
4049            # This represents a connection to somewhere we've been
4050            # before which is recognized but not traversed.
4051            # Note that when you want to use this to replace a mis-named
4052            # unexplored connection (which you now realize actually goes
4053            # to an existing sub-room, not a new one) you should just
4054            # oops that connection first, and then obviate to the actual
4055            # destination.
4056            if now is None:
4057                raise JournalParseError(
4058                    f"On line {lineNumber}: Cannot obviate a transition"
4059                    f" before we've created the starting graph. Block"
4060                    f" is:\n{journalBlock}"
4061                )
4062
4063            here = exploration.currentPosition()
4064
4065            # Two options: if the content lists a room:entrance combo in
4066            # brackets after a transition name, then it represents the
4067            # other side of a door from another room. If, on the other
4068            # hand, it just has a transition name, it represents a
4069            # sub-room name.
4070            content, otherSide = parseFormat.splitAnonymousRoom(content)
4071
4072            if otherSide is None:
4073                # Must be in-room progress
4074                # We create (but don't explore) a transition to that
4075                # sub-room.
4076                baseRoom = parseFormat.baseRoomName(here)
4077                currentSubPart = parseFormat.roomPartName(here)
4078                if currentSubPart is None:
4079                    currentSubPart = parseFormat.formatDict["progress"]
4080                fromDecision = parseFormat.subRoomName(
4081                    baseRoomName,
4082                    content
4083                )
4084
4085                existingReciprocalDestination = now.getDestination(
4086                    fromDecision,
4087                    currentSubPart
4088                )
4089                # If the place we're linking to doesn't have a link back
4090                # to us, then we just create a completely new link.
4091                # TODO
4092            else:
4093                # Here the content specifies an outgoing transition name
4094                # and otherSide specifies the other side, so we don't
4095                # have to search for anything
4096                transitionName = content
4097
4098                # Split decision name and transition name
4099                fromDecision, incoming = parseFormat.parseSpecificTransition(
4100                    otherSide
4101                )
4102                dest = now.getDestination(fromDecision, incoming)
4103
4104                # Check destination exists and is unknown
4105                if dest is None:
4106                    # TODO: Look for alternate sub-room?
4107                    raise JournalParseError(
4108                        f"Obviate entry #{lineNumber} for transition"
4109                        f" {content} has invalid reciprocal transition"
4110                        f" {otherSide}. (Did you forget to specify the"
4111                        f" sub-room?)"
4112                    )
4113                elif not now.isUnknown(dest):
4114                    raise JournalParseError(
4115                        f"Obviate entry #{lineNumber} for transition"
4116                        f" {content} has invalid reciprocal transition"
4117                        f" {otherSide}: that transition's destination"
4118                        f" is already known."
4119                    )
4120
4121            # Now that we know which edge we're obviating, do that
4122            # Note that while the other end is always an existing
4123            # transition to an unexplored destination, our end might be
4124            # novel, so we use replaceUnexplored from the other side
4125            # which allows it to do the work of creating the new
4126            # outgoing transition.
4127            now.replaceUnexplored(
4128                fromDecision,
4129                incoming,
4130                here,
4131                transitionName,
4132                requirement=backReq, # flipped
4133                revRequires=forwardReq,
4134                tags=rTags, # also flipped
4135                revTags=fTags,
4136            )
4137
4138        elif eType == 'challenge':
4139            # For now, these are just annotations
4140            if now is None:
4141                raise JournalParseError(
4142                    f"On line {lineNumber}: Cannot annotate a challenge"
4143                    f" before we've created the starting graph. Block"
4144                    f" is:\n{journalBlock}"
4145                )
4146
4147            here = exploration.currentPosition()
4148            now.annotateDecision(here, f"{eType}: " + content)
4149
4150        elif eType in ('warp', 'death'):
4151            # These warp the player without creating a connection
4152            if forwardReq or backReq:
4153                raise JournalParseError(
4154                    f"'{eType}' entry #{lineNumber} cannot include"
4155                    f" requirements. Block is:\n{journalBlock}"
4156                )
4157            if fTags or rTags:
4158                raise JournalParseError(
4159                    f"'{eType}' entry #{lineNumber} cannot include"
4160                    f" tags. Block is:\n{journalBlock}"
4161                )
4162
4163            try:
4164                exploration.warp(
4165                    content,
4166                    'death' if eType == 'death' else ''
4167                )
4168                # TODO: Death effects?!?
4169                # TODO: We could rewind until we're in a room marked
4170                # 'save' and pick up that position and even state
4171                # automatically ?!? But for save-anywhere games, we'd
4172                # need to have some way of marking a save (could be an
4173                # entry type that creates a special wait?).
4174                # There could even be a way to clone the old graph for
4175                # death, since things like tags applied would presumably
4176                # not be? Or maybe some would and some wouldn't?
4177            except KeyError:
4178                raise JournalParseError(
4179                    f"'{eType}' entry #{lineNumber} specifies"
4180                    f" non-existent destination '{content}'. Block"
4181                    f" is:\n{journalBlock}"
4182                )
4183
4184        elif eType == 'runback':
4185            # For now, we just warp there and back
4186            # TODO: Actually trace the path of the runback...
4187            # TODO: Allow for an action to be taken at the destination
4188            # (like farming health, flipping a switch, etc.)
4189            if forwardReq or backReq:
4190                raise JournalParseError(
4191                    f"Runback on line #{lineNumber} cannot include"
4192                    f" requirements. Block is:\n{journalBlock}"
4193                )
4194            if fTags or rTags:
4195                raise JournalParseError(
4196                    f"Runback on line #{lineNumber} cannot include tags."
4197                    f" Block is:\n{journalBlock}"
4198                )
4199
4200            # Remember where we are
4201            here = exploration.currentPosition()
4202
4203            # Warp back to the runback point
4204            try:
4205                exploration.warp(content, 'runaway')
4206            except KeyError:
4207                raise JournalParseError(
4208                    f"Runback on line #{lineNumber} specifies"
4209                    f" non-existent destination '{content}'. Block"
4210                    f" is:\n{journalBlock}"
4211                )
4212
4213            # Then warp back to the current decision
4214            exploration.warp(here, 'runback')
4215
4216        elif eType == 'traverse':
4217            # For now, we just warp there
4218            # TODO: Actually trace the path of the runback...
4219            if forwardReq or backReq:
4220                raise JournalParseError(
4221                    f"Traversal on line #{lineNumber} cannot include"
4222                    f" requirements. Block is:\n{journalBlock}"
4223                )
4224            if fTags or rTags:
4225                raise JournalParseError(
4226                    f"Traversal on line #{lineNumber} cannot include tags."
4227                    f" Block is:\n{journalBlock}"
4228                )
4229
4230            if now is None:
4231                raise JournalParseError(
4232                    f"Cannot traverse sub-rooms on line #{lineNumber}"
4233                    f" before exploration is started. Block"
4234                    f" is:\n{journalBlock}"
4235                )
4236
4237            # Warp to the destination
4238            here = exploration.currentPosition()
4239            destination = parseFormat.getSubRoom(now, here, content)
4240            if destination is None:
4241                raise JournalParseError(
4242                    f"Traversal on line #{lineNumber} specifies"
4243                    f" non-existent sub-room destination '{content}' in"
4244                    f" room '{parseFormat.baseRoomName(here)}'. Block"
4245                    f" is:\n{journalBlock}"
4246                )
4247            else:
4248                exploration.warp(destination, 'traversal')
4249
4250        elif eType == 'ending':
4251            if now is None:
4252                raise JournalParseError(
4253                    f"On line {lineNumber}: Cannot annotate an ending"
4254                    f" before we've created the starting graph. Block"
4255                    f" is:\n{journalBlock}"
4256                )
4257
4258            if backReq:
4259                raise JournalParseError(
4260                    f"Ending on line #{lineNumber} cannot include"
4261                    f" reverse requirements. Block is:\n{journalBlock}"
4262                )
4263
4264            # Create ending
4265            here = exploration.currentPosition()
4266            # Reverse tags are applied to the ending room itself
4267            now.addEnding(
4268                here,
4269                content,
4270                tags=fTags,
4271                endTags=rTags,
4272                requires=forwardReq
4273            )
4274            # Transition to the ending
4275            print("ED RT", here, content, len(exploration))
4276            exploration.retrace('_e:' + content)
4277            print("ED RT", len(exploration))
4278            ended = True
4279
4280        elif eType == 'tag':
4281            tagsToApply = set(content.split())
4282            if fTags or rTags:
4283                raise JournalParseError(
4284                    f"Found tags on tag entry on line #{lineNumber}"
4285                    f" of block:\n{journalBlock}"
4286                )
4287
4288            if now is None:
4289                raise JournalParseError(
4290                    f"On line {lineNumber}: Cannot add a tag before"
4291                    f" we've created the starting graph. Block"
4292                    f" is:\n{journalBlock}"
4293                )
4294
4295            here = exploration.currentPosition()
4296            now.tagDecision(here, tagsToApply)
4297
4298        else:
4299            raise NotImplementedError(
4300                f"Unhandled entry type '{eType}' (fix"
4301                f" updateExplorationFromEntry)."
4302            )
4303
4304        # Note: at this point, currentNote must be None. If there is an
4305        # end-of-line note, set up currentNote to apply that to whatever
4306        # is on this line.
4307        if note is not None:
4308            if eType in (
4309                'entrance',
4310                'exit',
4311                'blocked',
4312                'otherway',
4313                'unexplored',
4314                'unexploredOneway',
4315                'progress'
4316                'oneway',
4317                'hiddenOneway',
4318                'detour'
4319            ):
4320                # Annotate a specific transition
4321                target = (exploration.currentPosition(), content)
4322
4323            elif eType in (
4324                'pickup',
4325                'unclaimed',
4326                'action',
4327            ):
4328                # Action name might be auto-generated
4329                target = (
4330                    exploration.currentPosition(),
4331                    actionName
4332                )
4333
4334            else:
4335                # Default: annotate current room
4336                target = exploration.currentPosition()
4337
4338            # Set current note value for accumulation
4339            currentNote = (
4340                target,
4341                True, # all post-entry notes count as indented
4342                f"(step #{len(exploration)}) " + note
4343            )
4344
4345    # If we ended, return None
4346    if ended:
4347        return None
4348    elif exitRoom is None or exitTransition is None:
4349        raise JournalParseError(
4350            f"Missing exit room and/or transition ({exitRoom},"
4351            f" {exitTransition}) at end of journal"
4352            f" block:\n{journalBlock}"
4353        )
4354
4355    return exitRoom, exitTransition

Given an exploration object, a parsing format dictionary, and a multi-line string which is a journal entry block, updates the exploration to reflect the entries in the block. Except for the first block of a journal, or continuing blocks after an ending, where enterFrom must be None, a tuple specifying the room and transition taken to enter the block must be provided so we know where to anchor the new activity.

This function returns a tuple specifying the room and transition in that room taken to exit from the block, which can be used as the enterFrom value for the next block. It returns none if the block ends with an 'ending' entry.