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
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.
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.
Any journal marker type.
The marker types which need delimiter marker values.
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.
The default Format
dictionary.
Marker values which are treated as delimiters.
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.
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.
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.
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.
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).
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.
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.
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...
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: ...
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.
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.
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.
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.
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.
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'
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'
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
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: ...
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.Decision
s 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.
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.
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.
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.
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'
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
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
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.
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:
- Create a
JournalObserver
, optionally specifying a customParseFormat
. - 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.
- Call
- Call
JournalObserver.applyState
to clear any remaining un-finalized state. - Call
JournalObserver.getExploration
to retrieve thecore.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'sNone
) 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 theJournalObserver.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 (orapplyState
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()
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.
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 usingcore.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.
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']
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.
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()
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.
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.
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.