exploration.journal
- Authors: Peter Mawhorter
- Consulted:
- Date: 2022-9-4
- Purpose: Parsing for journal-format exploration records.
A journal fundamentally consists of a number of lines detailing decisions reached, options observed, and options chosen. Other information like enemies fought, items acquired, or general comments may also be present.
The start of each line is a single letter that determines the entry type, and remaining parts of that line separated by whitespace determine the specifics of that entry. Indentation is allowed and ignored; its suggested use is to indicate which entries apply to previous entries (e.g., tags, annotations, effects, and requirements).
The convertJournal
function converts a journal string into a
core.DiscreteExploration
object, or adds to an existing exploration
object if one is specified.
To support slightly different journal formats, a Format
dictionary is
used to define the exact notation used for various things.
1""" 2- Authors: Peter Mawhorter 3- Consulted: 4- Date: 2022-9-4 5- Purpose: Parsing for journal-format exploration records. 6 7A journal fundamentally consists of a number of lines detailing 8decisions reached, options observed, and options chosen. Other 9information like enemies fought, items acquired, or general comments may 10also be present. 11 12The start of each line is a single letter that determines the entry 13type, and remaining parts of that line separated by whitespace determine 14the specifics of that entry. Indentation is allowed and ignored; its 15suggested use is to indicate which entries apply to previous entries 16(e.g., tags, annotations, effects, and requirements). 17 18The `convertJournal` function converts a journal string into a 19`core.DiscreteExploration` object, or adds to an existing exploration 20object if one is specified. 21 22To support slightly different journal formats, a `Format` dictionary is 23used to define the exact notation used for various things. 24""" 25 26# TODO: Base current decision on primary decision when reverting, and 27# maybe at other points! 28 29from __future__ import annotations 30 31from typing import ( 32 Optional, List, Tuple, Dict, Union, Collection, get_args, cast, 33 Sequence, Literal, Set, TypedDict, get_type_hints 34) 35 36import sys 37import re 38import warnings 39import textwrap 40 41from . import core, base, parsing 42 43 44#----------------------# 45# Parse format details # 46#----------------------# 47 48JournalEntryType = Literal[ 49 'preference', 50 'alias', 51 'custom', 52 'DEBUG', 53 54 'START', 55 'explore', 56 'return', 57 'action', 58 'retrace', 59 'warp', 60 'wait', 61 'observe', 62 'END', 63 64 'mechanism', 65 'requirement', 66 'effect', 67 'apply', 68 69 'tag', 70 'annotate', 71 72 'context', 73 'domain', 74 'focus', 75 'zone', 76 77 'unify', 78 'obviate', 79 'extinguish', 80 'complicate', 81 82 'status', 83 84 'revert', 85 86 'fulfills', 87 88 'relative' 89] 90""" 91One of the types of entries that can be present in a journal. These can 92be written out long form, or abbreviated using a single letter (see 93`DEFAULT_FORMAT`). Each journal line is either an entry or a continuation 94of a previous entry. The available types are: 95 96- 'P' / 'preference': Followed by a setting name and value, controls 97 global preferences for journal processing. 98 99- '=' / 'alias': Followed by zero or more words and then a block of 100 commands, this establishes an alias that can be used as a custom 101 command. Within the command block, curly braces surrounding a word 102 will be replaced by the argument in the same position that that word 103 appears following the alias (for example, an alias defined using: 104 105 = redDoor name [ 106 o {name} 107 qb red 108 ] 109 110 could be referenced using: 111 112 > redDoor door 113 114 and that reference would be equivalent to: 115 116 o door 117 qb red 118 119 To help aliases be more flexible, if '_' is referenced between curly 120 braces (or '_' followed by an integer), it will be substituted with 121 an underscore followed by a unique number (these numbers will count 122 up with each such reference used by a specific `JournalObserver` 123 object). References within each alias substitution which are 124 suffixed with the same digit (or which are unsuffixed) will get the 125 same value. So for example, an alias: 126 127 = savePoint [ 128 o {_} 129 x {_} {_1} {_2} 130 gt toSavePoint 131 a save 132 At save 133 t {_2} 134 ] 135 136 when deployed twice like this: 137 138 > savePoint 139 > savePoint 140 141 might translate to: 142 143 o _17 144 x _17 _18 _19 145 g savePoint 146 a save 147 At save 148 t _19 149 o _20 150 x _20 _21 _22 151 g savePoint 152 a save 153 At save 154 t _22 155 156- '>' / 'custom': Re-uses the code from a previously-defined alias. This 157 command type is followed by an alias name and then one argument for 158 each parameter of the named alias (see above for examples). 159 160- '?' / 'DEBUG': Prints out debugging information when executed. See 161 `DebugAction` for the possible argument values and `doDebug` for 162 more information on what they mean. 163 164- 'S" / 'START': Names the starting decision (or zone::decision pair). 165 Must appear first except in journal fragments. 166 167- 'x' / 'explore': Names a transition taken and the decision (or 168 new-zone::decision) reached as a result, possibly with a name for 169 the reciprocal transition which is created. Use 'zone' afterwards to 170 swap around zones above level 0. 171 172- 'r' / 'return': Names a transition taken and decision returned to, 173 connecting a transition which previously connected to an unexplored 174 area back to a known decision instead. May also include a reciprocal 175 name. 176 177- 'a' / 'action': Names an action taken at the current decision and may 178 include effects and/or requirements. Use to declare a new action 179 (including transforming what was thought to be a transition into an 180 action). Use 'retrace' instead (preferably with the 'actionPart' 181 part) to re-activate an existing action. 182 183- 't' / 'retrace': Names a transition taken, where the destination is 184 already explored. Works for actoins as well when 'actionPart' is 185 specified, but it will raise an error if the type of transition 186 (normal vs. action) doesn't match thid specifier. 187 188- 'w' / 'wait': indicates a step of exploration where no transition is 189 taken. You can use 'A' afterwards to apply effects in order to 190 represent events that happen outside of player control. Use 'action' 191 instead for player-initiated effects. 192 193- 'p' / 'warp': Names a new decision (or zone::decision) to be at, but 194 without adding a transition there from the previous decision. If no 195 zone name is provided but the destination is a novel decision, it 196 will be placed into the same zones as the origin. 197 198- 'o' / 'observe': Names a transition observed from the current 199 decision, or a transition plus destination if the destination is 200 known, or a transition plus destination plus reciprocal if 201 reciprocal information is also available. Observations don't create 202 exploration steps. 203 204- 'E' / 'END': Names an ending which is reached from the current 205 decision via a new automatically-named transition. 206 207- 'm' / 'mechanism': names a new mechanism at the current decision and 208 puts it in a starting state. 209 210- 'q' / 'requirement': Specifies a requirement to apply to the 211 most-recently-defined transition or its reciprocal. 212 213- 'e' / 'effect': Specifies a `base.Consequence` that gets added to the 214 consequence for the currently-relevant transition (or its reciprocal 215 or both if `reciprocalPart` or `bothPart` is used). The remainder of 216 the line (and/or the next few lines) should be parsable using 217 `ParseFormat.parseConsequence`, or if not, using 218 `ParseFormat.parseEffect` for a single effect. 219 220- 'A' / 'apply': Specifies an effect to be immediately applied to the 221 current state, relative to the most-recently-taken or -defined 222 transition. If a 'transitionPart' or 'reciprocalPart' target 223 specifier is included, the effect will also be recorded as an effect 224 in the current active `core.Consequence` context for the most recent 225 transition or reciprocal, but otherwise it will just be applied 226 without being stored in the graph. Note that effects which are 227 hidden until activated should have their 'hidden' property set to 228 `True`, regardless of whether they're added to the graph before or 229 after the transition they are associated with. Also, certain effects 230 like 'bounce' cannot be applied retroactively. 231 232- 'g' / 'tag': Applies one or more tags to the current exploration step, 233 or to the current decision if 'decisionPart' is specified, or to 234 either the most-recently-taken transition or its reciprocal if 235 'transitionPart' or 'reciprocalPart' is specified. May also tag a 236 zone by using 'zonePart'. Tags may have values associated with them; 237 without a value provided the default value is the number 1. 238 239- 'n' / 'annotate': Like 'tag' but applies an annotation, which is just a 240 piece of text attached to. Certain annotations will trigger checks of 241 various exploration state when applied to a step, and will emit 242 warnings if the checks fail.Step annotations which begin with: 243 * 'at:' - checks that the current primary decision matches the 244 decision identified by the rest of the annotation. 245 * 'active:' - checks that a decision is currently in the active 246 decision set. 247 * 'has:' - checks that the player has a specific amount of a 248 certain token (write 'token*amount' after 'has:', as in "has: 249 coins*3"). Will fail if the player doesn't have that amount, 250 including if they have more. 251 * 'level:' - checks that the level of the named skill matches a 252 specific level (write 'skill^level', as in "level: 253 bossFight^3"). This does not allow you to check 254 `SkillCombination` effective levels; just individual skill 255 levels. 256 * 'can:' - checks that a requirement (parsed using 257 `ParseFormat.parseRequirement`) is satisfied. 258 * 'state:' - checks that a mechanism is in a certain state (write 259 'mechanism:state', as in "level: doors:open"). 260 * 'exists:' - checks that a decision exists. 261 262- 'c' / 'context': Specifies either 'commonContext' or the name of a 263 specific focal context to activate. Focal contexts represent things 264 like multiple characters or teams, and by default capabilities, 265 tokens, and skills are all tied to a specific focal context. If the 266 name given is anything other than the 'commonContext' value then 267 that context will be swapped to active (and created as a blank 268 context if necessary). TODO: THIS 269 270- 'd' / 'domain': Specifies a domain name, swapping to that domain as 271 the current domain, and setting it as an active domain in the 272 current `core.FocalContext`. This does not de-activate other 273 domains, but the journal has a notion of a single 'current' domain 274 that entries will be applied to. If no focal point has been 275 specified and we swap into a plural-focalized domain, the 276 alphabetically-first focal point within that domain will be 277 selected, but focal points for each domain are remembered when 278 swapping back. Use the 'notApplicable' value after a domain name to 279 deactivate that domain. Any other value after a domain name must be 280 one of the 'focalizeSingular', 'focalizePlural', or 281 'focalizeSpreading' values to indicate that the domain uses that 282 type of focalization. These should only be used when a domain is 283 created, you cannot change the focalization type of a domain after 284 creation. If no focalization type is given along with a new domain 285 name, singular focalization will be used for that domain. If no 286 domain is specified before performing the first action of an 287 exploration, the `core.DEFAULT_DOMAIN` with singular focalization 288 will be set up. TODO: THIS 289 290- 'f' / 'focus': Specifies a `core.FocalPointName` for the specific 291 focal point that should be acted on by subsequent journal entries in 292 a plural-focalized domain. Focal points represent things like 293 individual units in a game where you have multiple units to control. 294 295 May also specify a domain followed by a focal point name to change 296 the focal point in a domain other than the current domain. 297 298- 'z' / 'zone': Specifies a zone name and a level (via extra `zonePart` 299 characters) that will replace the current zone at the given 300 hierarchy level for the current decision. This is done using the 301 `core.DiscreteExploration.reZone` method. 302 303- 'u' / 'unify': Specifies a decision with which the current decision 304 will be unified (or two decisions that will be unified with each 305 other), merging their transitions. The name of the merged decision 306 is the name of the second decision specified (or the only decision 307 specified when merging the current decision). Can instead target a 308 transition or reciprocal to merge (which must be at the current 309 decision), although the transition to merge with must either lead to 310 the same destination or lead to an unknown destination (which will 311 then be merged with the transition's destination). Any transitions 312 between the two merged decisions will remain as actions at the new 313 decision. 314 315- 'v' / 'obviate': Specifies a transition at the current decision and a 316 decision that it links to and updates that information, without 317 actually crossing the transition. The reciprocal transition must 318 also be specified, although one will be created if it didn't already 319 exist. If the reciprocal does already exist, it must lead to an 320 unknown decision. 321 322- 'X' / 'extinguish': Deletes an transition at the current decision. If it 323 leads to an unknown decision which is not otherwise connected to 324 anything, this will also delete that decision (even if it already 325 has tags or annotations or the like). Can also be used (with a 326 decision target) to delete a decision, which will delete all 327 transitions touching that decision. Note that usually, 'unify' is 328 easier to manage than extinguish for manipulating decisions. 329 330- 'C' / 'complicate': Takes a transition between two confirmed decisions 331 and adds a new confirmed decision in the middle of it. The old ends 332 of the transition both connect to the new decision, and new names are 333 given to their new reciprocals. Does not change the player's 334 position. 335 336- '.' / 'status': Sets the exploration status of the current decision 337 (argument should be a `base.ExplorationStatus`). Without an 338 argument, sets the status to 'explored'. When 'unfinishedPart' is 339 given as a target specifier (once or twice), this instead prevents 340 the decision from being automatically marked as 'explored' when we 341 leave it. 342 343- 'R' / 'revert': Reverts some or all of the current state to a 344 previously saved state. Saving happens via the 'save' effect type, 345 but reverting is an explicit action. The first argument names the 346 save slot to revert to, while the rest are interpreted as the set of 347 aspects to revert (see `base.revertedState`). 348 349- 'F' / 'fulfills': Specifies a requirement and a capability, and adds 350 an equivalence to the current graph such that if that requirement is 351 fulfilled, the specified capability is considered to be active. This 352 allows for later discovery of one or more powers which allow 353 traversal of previously-marked transitions whose true requirements 354 were unknown when they were discovered. 355 356- '@' / 'relative': Specifies a decision to be treated as the 'current 357 decision' without actually setting the position there. Use the 358 marker alone or twice (default '@ @') to enter relative mode at the 359 current decision (or to exit it). Until used to reverse this effect, 360 all position-changing entries change this relative position value 361 instead of the actual position in the graph, and updates are applied 362 to the current graph without creating new exploration steps or 363 applying any effects. Useful for doing things like noting information 364 about far-away locations disclosed in a cutscene. Can target a 365 transition at the current node by specifying 'transitionPart' or two 366 arguments for a decision and transition. In that case, the specified 367 transition is counted as the 'most-recent-transition' for entry 368 purposes and the same relative mode is entered. 369""" 370 371JournalTargetType = Literal[ 372 'decisionPart', 373 'transitionPart', 374 'reciprocalPart', 375 'bothPart', 376 'zonePart', 377 'actionPart', 378 'endingPart', 379 'unfinishedPart', 380] 381""" 382The different parts that an entry can target. The signifiers for these 383target types will be concatenated with a journal entry signifier in some 384cases. For example, by default 'g' as an entry type means 'tag', and 't' 385as a target type means 'transition'. So 'gt' as an entry type means 'tag 386transition' and applies the relevant tag to the most-recently-created 387transition instead of the most-recently-created decision. The 388`targetSeparator` character (default '@') is used to combine an entry 389type with a target type when the entry type is written without 390abbreviation. In that case, the target specifier may drop the suffix 391'Part' (e.g., `tag@transition` in place of `gt`). The available target 392parts are each valid only for specific entry types. The target parts are: 393 394- 'decisionPart' - Use to specify that the entry applies to a decision 395 when it would normally apply to something else. 396- 'transitionPart' - Use to specify that the entry applies to a 397 transition instead of a decision. 398- 'reciprocalPart' - Use to specify that the entry applies to a 399 reciprocal transition instead of a decision or the normal 400 transition. 401- 'bothPart' - Use to specify that the entry applies to both of two 402 possibilities, such as to a transition and its reciprocal. 403- 'zonePart' - Use for re-zoning to indicate the hierarchy level. May 404 be repeated; each instance increases the hierarchy level by 1 405 starting from 0. In the long form to specify a hierarchy level, use 406 the letter 'z' followed by an integer, as in 'z3' for level 3. Also 407 used in the same way for tagging or annotating zones. 408- 'actionPart' - Use for the 'observe' or 'retrace' entries to specify 409 that the observed/retraced transition is an action (i.e., its 410 destination is the same as its source) rather than a real transition 411 (whose destination would be a new, unknown node). 412- 'endingPart' - Use only for the 'observe' entry to specify that the 413 observed transition goes to an ending rather than a normal decision. 414- 'unfinishedPart' - Use only for the 'status' entry (and use either 415 once or twice in the short form) to specify that a decision should 416 NOT be finalized when we leave it. 417 418The entry types where a target specifier can be applied are: 419 420- 'requirement': By default these are applied to transitions, but the 421 'reciprocalPart' target can be used to apply to a reciprocal 422 instead. Use `bothPart` to apply the same requirement to both the 423 transition and its reciprocal. 424- 'effect': Same as 'requirement'. 425- 'apply': Same as 'effect' (and see above). 426- 'tag': Applies the tag to the specified target instead of the current 427 exploration step. When targeting zones using 'zonePart', if there are 428 multiple zones that apply at a certain hierarchy level we target the 429 smallest one (breaking ties alphabetically by name). TODO: target the 430 most-recently asserted one. The 'zonePart' may be repeated to specify 431 a hierarchy level, as in 'gzz' for level 1 instead of level 0, and 432 you may also use 'z' followed by an integer, as in 'gz3' for level 3. 433- 'annotation': Same as 'tag'. 434- 'unify': By default applies to a decision, but can be applied to a 435 transition or reciprocal instead. 436- 'extinguish': By default applies to a transition and its reciprocal, 437 but can be applied to just one or the other, or to a decision. 438- 'relative': Only 'transition' applies here and changes the 439 most-recent-transition value when entering relative mode instead of 440 just changing the current-decision value. Can be used within 441 relative mode to pick out an existing transition as well. 442- 'zone': This is the main place where the 'zonePart' target type 443 applies, and it can actually be applied as many times as you want. 444 Each application makes the zone specified apply to a higher level in 445 the hierarchy of zones, so that instead of swapping the level-0 zone 446 using 'z', the level-1 zone can be changed using 'zz' or the level 2 447 zone using 'zzz', etc. In lieu of using multiple 'z's, you can also 448 just write one 'z' followed by an integer for the level you want to 449 use (e.g., z0 for a level-0 zone, or z1 for a level-1 zone). When 450 using a long-form entry type, the target may be given as the string 451 'zone' in which case the level-1 zone is used. To use a different 452 zone level with a long-form entry type, repeat the 'z' followed by an 453 integer, or use multiple 'z's. 454- 'observe': Uses the 'actionPart' and 'endingPart' target types, and 455 those are the only applicable target types. Applying `actionPart` 456 turns the observed transition into an action; applying `endingPart` 457 turns it into an transition to an ending. 458- 'retrace': Uses 'actionPart' or not to distinguish what kind of 459 transition is being taken. Riases a `JournalParseError` if the type 460 of edge (external vs. self destination) doesn't match this 461 distinction. 462- 'status': The only place where 'unfinishedPart' target type applies. 463 Using it once or twice signifies that the decision should NOT be 464 marked as completely-explored when we leave it. 465""" 466 467JournalInfoType = Literal[ 468 'on', 469 'off', 470 'domainFocalizationSingular', 471 'domainFocalizationPlural', 472 'domainFocalizationSpreading', 473 'commonContext', 474 'comment', 475 'unknownItem', 476 'notApplicable', 477 'exclusiveDomain', 478 'targetSeparator', 479 'reciprocalSeparator', 480 'transitionAtDecision', 481 'blockDelimiters', 482] 483""" 484Represents a part of the journal syntax which isn't an entry type but is 485used to mark something else. For example, the character denoting an 486unknown item. The available values are: 487 488- 'on' / 'off': Used to indicate on/off status for preferences. 489- 'domainFocalizationSingular' / 'domainFocalizationPlural' 490 / 'domainFocalizationSpreading': Used as markers after a domain for 491 the `core.DomainFocalization` values. 492- 'commonContext': Used with 'context' in place of a 493 `core.FocalContextName` to indicate that we are targeting the common 494 focal context. 495- 'comment': Indicates extraneous text that should be ignored by the 496 journal parser. Note that tags and/or annotations should usually be 497 used to apply comments that will be accessible when viewing the 498 exploration object. 499- 'unknownItem': Used in place of an item name to indicate that 500 although an item is known to exist, it's not yet know what that item 501 is. Note that when journaling, you should make up names for items 502 you pick up, even if you don't know what they do yet. This notation 503 should only be used for items that you haven't picked up because 504 they're inaccessible, and despite being apparent, you don't know 505 what they are because they come in a container (e.g., you see a 506 sealed chest, but you don't know what's in it). 507- 'notApplicable': Used in certain positions to indicate that something 508 is missing entirely or otherwise that a piece of information 509 normally supplied is unnecessary. For example, when used as the 510 reciprocal name for a transition, this will cause the reciprocal 511 transition to be deleted entirely, or when used before a domain name 512 with the 'domain' entry type it deactivates that domain. TODO 513- 'exclusiveDomain': Used to indicate that a domain being activated 514 should deactivate other domains, instead of being activated along 515 with them. 516- 'targetSeparator': Used in long-form entry types to separate the entry 517 type from a target specifier when a target is specified. Default is 518 '@'. For example, a 'gt' entry (tag transition) would be expressed 519 as 'tag@transition' in the long form. 520- 'reciprocalSeparator': Used to indicate, within a requirement or a 521 tag set, a separation between requirements/tags to be applied to the 522 forward direction and requirements/tags to be applied to the reverse 523 direction. Not always applicable (e.g., actions have no reverse 524 direction). 525- 'transitionAtDecision' Used to separate a decision name from a 526 transition name when identifying a specific transition. 527- 'blockDelimiters' Two characters used to delimit the start and end of 528 a block of entries. Used for things like edit effects. 529""" 530 531JournalMarkerType = Union[ 532 JournalEntryType, 533 JournalTargetType, 534 base.DecisionType, 535 JournalInfoType 536] 537"Any journal marker type." 538 539 540JournalFormat = Dict[JournalMarkerType, str] 541""" 542A journal format is specified using a dictionary with keys that denote 543journal marker types and values which are one-to-several-character 544strings indicating the markup used for that entry/info type. 545""" 546 547DEFAULT_FORMAT: JournalFormat = { 548 # Toggles 549 'preference': 'P', 550 551 # Alias handling 552 'alias': '=', 553 'custom': '>', 554 555 # Debugging 556 'DEBUG': '?', 557 558 # Core entry types 559 'START': 'S', 560 'explore': 'x', 561 'return': 'r', 562 'action': 'a', 563 'retrace': 't', 564 'wait': 'w', 565 'warp': 'p', 566 'observe': 'o', 567 'END': 'E', 568 'mechanism': 'm', 569 570 # Transition properties 571 'requirement': 'q', 572 'effect': 'e', 573 'apply': 'A', 574 575 # Tags & annotations 576 'tag': 'g', 577 'annotate': 'n', 578 579 # Context management 580 'context': 'c', 581 'domain': 'd', 582 'focus': 'f', 583 'zone': 'z', 584 585 # Revisions 586 'unify': 'u', 587 'obviate': 'v', 588 'extinguish': 'X', 589 'complicate': 'C', 590 591 # Exploration status modifiers 592 'status': '.', 593 594 # Reversion 595 'revert': 'R', 596 597 # Capability discovery 598 'fulfills': 'F', 599 600 # Relative mode 601 'relative': '@', 602 603 # Target specifiers 604 'decisionPart': 'd', 605 'transitionPart': 't', 606 'reciprocalPart': 'r', 607 'bothPart': 'b', 608 'zonePart': 'z', 609 'actionPart': 'a', 610 'endingPart': 'E', 611 'unfinishedPart': '.', 612 613 # Decision types 614 'pending': '?', 615 'active': '.', 616 'unintended': '!', 617 'imposed': '>', 618 'consequence': '~', 619 620 # Info markers 621 'on': 'on', 622 'off': 'off', 623 'domainFocalizationSingular': 'singular', 624 'domainFocalizationPlural': 'plural', 625 'domainFocalizationSpreading': 'spreading', 626 'commonContext': '*', 627 'comment': '#', 628 'unknownItem': '?', 629 'notApplicable': '-', 630 'exclusiveDomain': '>', 631 'reciprocalSeparator': '/', 632 'targetSeparator': '@', 633 'transitionAtDecision': '%', 634 'blockDelimiters': '[]', 635} 636""" 637The default `JournalFormat` dictionary. 638""" 639 640 641DebugAction = Literal[ 642 'here', 643 'transition', 644 'destinations', 645 'steps', 646 'decisions', 647 'active', 648 'primary', 649 'saved', 650 'inventory', 651 'mechanisms', 652 'equivalences', 653] 654""" 655The different kinds of debugging commands. 656""" 657 658 659class JournalParseFormat(parsing.ParseFormat): 660 """ 661 A ParseFormat manages the mapping from markers to entry types and 662 vice versa. 663 """ 664 def __init__( 665 self, 666 formatDict: parsing.Format = parsing.DEFAULT_FORMAT, 667 journalMarkers: JournalFormat = DEFAULT_FORMAT 668 ): 669 """ 670 Sets up the parsing format. Accepts base and/or journal format 671 dictionaries, but they both have defaults (see `DEFAULT_FORMAT` 672 and `parsing.DEFAULT_FORMAT`). Raises a `ValueError` unless the 673 keys of the format dictionaries exactly match the required 674 values (the `parsing.Lexeme` values for the base format and the 675 `JournalMarkerType` values for the journal format). 676 """ 677 super().__init__(formatDict) 678 self.journalMarkers: JournalFormat = journalMarkers 679 680 # Build comment regular expression 681 self.commentRE = re.compile( 682 self.journalMarkers.get('comment', '#') + '.*$', 683 flags=re.MULTILINE 684 ) 685 686 # Get block delimiters 687 blockDelimiters = journalMarkers.get('blockDelimiters', '[]') 688 if len(blockDelimiters) != 2: 689 raise ValueError( 690 f"Block delimiters must be a length-2 string containing" 691 f" the start and end markers. Got: {blockDelimiters!r}." 692 ) 693 blockStart = blockDelimiters[0] 694 blockEnd = blockDelimiters[1] 695 self.blockStart = blockStart 696 self.blockEnd = blockEnd 697 698 # Add backslash for literal if it's an RE special char 699 if blockStart in '[]()*.?^$&+\\': 700 blockStart = '\\' + blockStart 701 if blockEnd in '[]()*.?^$&+\\': 702 blockEnd = '\\' + blockEnd 703 704 # Build block start and end regular expressions 705 self.blockStartRE = re.compile( 706 blockStart + r'\s*$', 707 flags=re.MULTILINE 708 ) 709 self.blockEndRE = re.compile( 710 r'^\s*' + blockEnd, 711 flags=re.MULTILINE 712 ) 713 714 # Check that journalMarkers doesn't have any extra keys 715 markerTypes = ( 716 get_args(JournalEntryType) 717 + get_args(base.DecisionType) 718 + get_args(JournalTargetType) 719 + get_args(JournalInfoType) 720 ) 721 for key in journalMarkers: 722 if key not in markerTypes: 723 raise ValueError( 724 f"Format dict has key {key!r} which is not a" 725 f" recognized entry or info type." 726 ) 727 728 # Check completeness of formatDict 729 for mtype in markerTypes: 730 if mtype not in journalMarkers: 731 raise ValueError( 732 f"Journal markers dict is missing an entry for" 733 f" marker type {mtype!r}." 734 ) 735 736 # Build reverse dictionaries from markers to entry types and 737 # from markers to target types (no reverse needed for info 738 # types). 739 self.entryMap: Dict[str, JournalEntryType] = {} 740 self.targetMap: Dict[str, JournalTargetType] = {} 741 entryTypes = set(get_args(JournalEntryType)) 742 targetTypes = set(get_args(JournalTargetType)) 743 744 # Check for duplicates and create reverse maps 745 for name, marker in journalMarkers.items(): 746 if name in entryTypes: 747 # Duplicates not allowed among entry types 748 if marker in self.entryMap: 749 raise ValueError( 750 f"Format dict entry for {name!r} duplicates" 751 f" previous format dict entry for" 752 f" {self.entryMap[marker]!r}." 753 ) 754 755 # Map markers to entry types 756 self.entryMap[marker] = cast(JournalEntryType, name) 757 elif name in targetTypes: 758 # Duplicates not allowed among entry types 759 if marker in self.targetMap: 760 raise ValueError( 761 f"Format dict entry for {name!r} duplicates" 762 f" previous format dict entry for" 763 f" {self.targetMap[marker]!r}." 764 ) 765 766 # Map markers to entry types 767 self.targetMap[marker] = cast(JournalTargetType, name) 768 769 # else ignore it since it's an info type 770 771 def markers(self) -> List[str]: 772 """ 773 Returns the list of all entry-type markers (but not other kinds 774 of markers), sorted from longest to shortest to help avoid 775 ambiguities when matching. 776 """ 777 entryTypes = get_args(JournalEntryType) 778 return sorted( 779 ( 780 m 781 for (et, m) in self.journalMarkers.items() 782 if et in entryTypes 783 ), 784 key=lambda m: -len(m) 785 ) 786 787 def markerFor(self, markerType: JournalMarkerType) -> str: 788 """ 789 Returns the marker for the specified entry/info/effect/etc. 790 type. 791 """ 792 return self.journalMarkers[markerType] 793 794 def determineEntryType(self, entryBits: List[str]) -> Tuple[ 795 JournalEntryType, 796 base.DecisionType, 797 Union[None, JournalTargetType, Tuple[JournalTargetType, int]], 798 List[str] 799 ]: 800 """ 801 Given a sequence of strings that specify a command, returns a 802 tuple containing the entry type, decision type, target part, and 803 list of arguments for that command. The default decision type is 804 'active' but others can be specified with decision type 805 modifiers. If no target type was included, the third entry of 806 the return value will be `None`, and in the special case of 807 zones, it will be an integer indicating the hierarchy level 808 according to how many times the 'zonePart' target specifier was 809 present, default 0. 810 811 For example: 812 813 >>> pf = JournalParseFormat() 814 >>> pf.determineEntryType(['retrace', 'transition']) 815 ('retrace', 'active', None, ['transition']) 816 >>> pf.determineEntryType(['t', 'transition']) 817 ('retrace', 'active', None, ['transition']) 818 >>> pf.determineEntryType(['observe@action', 'open']) 819 ('observe', 'active', 'actionPart', ['open']) 820 >>> pf.determineEntryType(['oa', 'open']) 821 ('observe', 'active', 'actionPart', ['open']) 822 >>> pf.determineEntryType(['!explore', 'down', 'pit', 'up']) 823 ('explore', 'unintended', None, ['down', 'pit', 'up']) 824 >>> pf.determineEntryType(['imposed/explore', 'down', 'pit', 'up']) 825 ('explore', 'imposed', None, ['down', 'pit', 'up']) 826 >>> pf.determineEntryType(['~x', 'down', 'pit', 'up']) 827 ('explore', 'consequence', None, ['down', 'pit', 'up']) 828 >>> pf.determineEntryType(['>x', 'down', 'pit', 'up']) 829 ('explore', 'imposed', None, ['down', 'pit', 'up']) 830 >>> pf.determineEntryType(['gzz', 'tag']) 831 ('tag', 'active', ('zonePart', 1), ['tag']) 832 >>> pf.determineEntryType(['gz4', 'tag']) 833 ('tag', 'active', ('zonePart', 4), ['tag']) 834 >>> pf.determineEntryType(['zone@z2', 'ZoneName']) 835 ('zone', 'active', ('zonePart', 2), ['ZoneName']) 836 >>> pf.determineEntryType(['zzz', 'ZoneName']) 837 ('zone', 'active', ('zonePart', 2), ['ZoneName']) 838 """ 839 # Get entry specifier 840 entrySpecifier = entryBits[0] 841 entryArgs = entryBits[1:] 842 843 # Defaults 844 entryType: Optional[JournalEntryType] = None 845 entryTarget: Union[ 846 None, 847 JournalTargetType, 848 Tuple[JournalTargetType, int] 849 ] = None 850 entryDecisionType: base.DecisionType = 'active' 851 852 # Check for a decision type specifier and process+remove it 853 for decisionType in get_args(base.DecisionType): 854 marker = self.markerFor(decisionType) 855 lm = len(marker) 856 if ( 857 entrySpecifier.startswith(marker) 858 and len(entrySpecifier) > lm 859 ): 860 entrySpecifier = entrySpecifier[lm:] 861 entryDecisionType = decisionType 862 break 863 elif entrySpecifier.startswith( 864 decisionType + self.markerFor('reciprocalSeparator') 865 ): 866 entrySpecifier = entrySpecifier[len(decisionType) + 1:] 867 entryDecisionType = decisionType 868 break 869 870 # Sets of valid types and targets 871 validEntryTypes: Set[JournalEntryType] = set( 872 get_args(JournalEntryType) 873 ) 874 validEntryTargets: Set[JournalTargetType] = set( 875 get_args(JournalTargetType) 876 ) 877 878 # Look for a long-form entry specifier with an @ sign separating 879 # the entry type from the entry target 880 targetMarker = self.markerFor('targetSeparator') 881 if ( 882 targetMarker in entrySpecifier 883 and not entrySpecifier.startswith(targetMarker) 884 # Because the targetMarker is also a valid entry type! 885 ): 886 specifierBits = entrySpecifier.split(targetMarker) 887 if len(specifierBits) != 2: 888 raise JournalParseError( 889 f"When a long-form entry specifier contains a" 890 f" target separator, it must contain exactly one (to" 891 f" split the entry type from the entry target). We got" 892 f" {entrySpecifier!r}." 893 ) 894 entryTypeGuess: str 895 entryTargetGuess: Optional[str] 896 entryTypeGuess, entryTargetGuess = specifierBits 897 if entryTypeGuess not in validEntryTypes: 898 raise JournalParseError( 899 f"Invalid long-form entry type: {entryType!r}" 900 ) 901 else: 902 entryType = cast(JournalEntryType, entryTypeGuess) 903 904 # Special logic for zone part 905 handled = False 906 if entryType in ('zone', 'tag', 'annotate'): 907 handled = True 908 if entryType == 'zone' and entryTargetGuess.isdigit(): 909 entryTarget = ('zonePart', int(entryTargetGuess)) 910 elif entryTargetGuess == 'zone': 911 entryTarget = ('zonePart', 1 if entryType == 'zone' else 0) 912 elif ( 913 entryTargetGuess.startswith('z') 914 and entryTargetGuess[1:].isdigit() 915 ): 916 entryTarget = ('zonePart', int(entryTargetGuess[1:])) 917 elif ( 918 len(entryTargetGuess) > 0 919 and set(entryTargetGuess) != {'z'} 920 ): 921 if entryType == 'zone': 922 raise JournalParseError( 923 f"Invalid target specifier for" 924 f" zone entry:\n{entryTargetGuess}" 925 ) 926 else: 927 handled = False 928 else: 929 entryTarget = ('zonePart', len(entryTargetGuess)) 930 931 if not handled: 932 if entryTargetGuess + 'Part' in validEntryTargets: 933 entryTarget = cast( 934 JournalTargetType, 935 entryTargetGuess + 'Part' 936 ) 937 else: 938 origGuess = entryTargetGuess 939 entryTargetGuess = self.targetMap.get( 940 entryTargetGuess, 941 entryTargetGuess 942 ) 943 if entryTargetGuess not in validEntryTargets: 944 raise JournalParseError( 945 f"Invalid long-form entry target:" 946 f" {origGuess!r}" 947 ) 948 else: 949 entryTarget = cast( 950 JournalTargetType, 951 entryTargetGuess 952 ) 953 954 elif entrySpecifier in validEntryTypes: 955 # Might be a long-form specifier without a separator 956 entryType = cast(JournalEntryType, entrySpecifier) 957 entryTarget = None 958 if entryType == 'zone': 959 entryTarget = ('zonePart', 0) 960 961 else: # parse a short-form entry specifier 962 typeSpecifier = entrySpecifier[0] 963 if typeSpecifier not in self.entryMap: 964 raise JournalParseError( 965 f"Entry does not begin with a recognized entry" 966 f" marker:\n{entryBits}" 967 ) 968 entryType = self.entryMap[typeSpecifier] 969 970 # Figure out the entry target from second+ character(s) 971 targetSpecifiers = entrySpecifier[1:] 972 specifiersSet = set(targetSpecifiers) 973 if entryType == 'zone': 974 if targetSpecifiers.isdigit(): 975 entryTarget = ('zonePart', int(targetSpecifiers)) 976 elif ( 977 len(specifiersSet) > 0 978 and specifiersSet != {self.journalMarkers['zonePart']} 979 ): 980 raise JournalParseError( 981 f"Invalid target specifier for zone:\n{entryBits}" 982 ) 983 else: 984 entryTarget = ('zonePart', len(targetSpecifiers)) 985 elif entryType == 'status': 986 if len(targetSpecifiers) > 0: 987 if any( 988 x != self.journalMarkers['unfinishedPart'] 989 for x in targetSpecifiers 990 ): 991 raise JournalParseError( 992 f"Invalid target specifier for" 993 f" status:\n{entryBits}" 994 ) 995 entryTarget = 'unfinishedPart' 996 else: 997 entryTarget = None 998 elif len(targetSpecifiers) > 0: 999 if ( 1000 targetSpecifiers[1:].isdigit() 1001 and targetSpecifiers[0] in self.targetMap 1002 ): 1003 entryTarget = ( 1004 self.targetMap[targetSpecifiers[0]], 1005 int(targetSpecifiers[1:]) 1006 ) 1007 elif len(specifiersSet) > 1: 1008 raise JournalParseError( 1009 f"Entry has too many target specifiers:\n{entryBits}" 1010 ) 1011 else: 1012 specifier = list(specifiersSet)[0] 1013 copies = len(targetSpecifiers) 1014 if specifier not in self.targetMap: 1015 raise JournalParseError( 1016 f"Unrecognized target specifier in:\n{entryBits}" 1017 ) 1018 entryTarget = self.targetMap[specifier] 1019 if copies > 1: 1020 entryTarget = (entryTarget, copies - 1) 1021 # else entryTarget remains None 1022 1023 return (entryType, entryDecisionType, entryTarget, entryArgs) 1024 1025 def argsString(self, pieces: List[str]) -> str: 1026 """ 1027 Recombines pieces of a journal argument (such as those produced 1028 by `unparseEffect`) into a single string. When there are 1029 multi-line or space-containing pieces, this adds block start/end 1030 delimiters and indents the piece if it's multi-line. 1031 """ 1032 result = '' 1033 for piece in pieces: 1034 if '\n' in piece: 1035 result += ( 1036 f" {self.blockStart}\n" 1037 f"{textwrap.indent(piece, ' ')}" 1038 f"{self.blockEnd}" 1039 ) 1040 elif ' ' in piece: 1041 result += f" {self.blockStart}{piece}{self.blockEnd}" 1042 else: 1043 result += ' ' + piece 1044 1045 return result[1:] # chop off extra initial space 1046 1047 def removeComments(self, text: str) -> str: 1048 """ 1049 Given one or more lines from a journal, removes all comments from 1050 it/them. Any '#' and any following characters through the end of 1051 a line counts as a comment. 1052 1053 Returns the text without comments. 1054 1055 Example: 1056 1057 >>> pf = JournalParseFormat() 1058 >>> pf.removeComments('abc # 123') 1059 'abc ' 1060 >>> pf.removeComments('''\\ 1061 ... line one # comment 1062 ... line two # comment 1063 ... line three 1064 ... line four # comment 1065 ... ''') 1066 'line one \\nline two \\nline three\\nline four \\n' 1067 """ 1068 return self.commentRE.sub('', text) 1069 1070 def findBlockEnd(self, string: str, startIndex: int) -> int: 1071 """ 1072 Given a string and a start index where a block open delimiter 1073 is, returns the index within the string of the matching block 1074 closing delimiter. 1075 1076 There are two possibilities: either both the opening and closing 1077 delimiter appear on the same line, or the block start appears at 1078 the end of a line (modulo whitespce) and the block end appears 1079 at the beginning of a line (modulo whitespace). Any other 1080 configuration is invalid and may lead to a `JournalParseError`. 1081 1082 Note that blocks may be nested within each other, including 1083 nesting single-line blocks in a multi-line block. It's also 1084 possible for several single-line blocks to appear on the same 1085 line. 1086 1087 Examples: 1088 1089 >>> pf = JournalParseFormat() 1090 >>> pf.findBlockEnd('[ A ]', 0) 1091 4 1092 >>> pf.findBlockEnd('[ A ] [ B ]', 0) 1093 4 1094 >>> pf.findBlockEnd('[ A ] [ B ]', 6) 1095 10 1096 >>> pf.findBlockEnd('[ A [ B ] ]', 0) 1097 10 1098 >>> pf.findBlockEnd('[ A [ B ] ]', 4) 1099 8 1100 >>> pf.findBlockEnd('[ [ B ]', 0) 1101 Traceback (most recent call last): 1102 ... 1103 exploration.journal.JournalParseError... 1104 >>> pf.findBlockEnd('[\\nABC\\n]', 0) 1105 6 1106 >>> pf.findBlockEnd('[\\nABC]', 0) # End marker must start line 1107 Traceback (most recent call last): 1108 ... 1109 exploration.journal.JournalParseError... 1110 >>> pf.findBlockEnd('[\\nABC\\nDEF[\\nGHI\\n]\\n ]', 0) 1111 19 1112 >>> pf.findBlockEnd('[\\nABC\\nDEF[\\nGHI\\n]\\n ]', 9) 1113 15 1114 >>> pf.findBlockEnd('[\\nABC\\nDEF[ GHI ]\\n ]', 0) 1115 19 1116 >>> pf.findBlockEnd('[\\nABC\\nDEF[ GHI ]\\n ]', 9) 1117 15 1118 >>> pf.findBlockEnd('[ \\nABC\\nDEF[\\nGHI[H]\\n ]\\n]', 0) 1119 24 1120 >>> pf.findBlockEnd('[ \\nABC\\nDEF[\\nGHI[H]\\n ]\\n]', 11) 1121 22 1122 >>> pf.findBlockEnd('[ \\nABC\\nDEF[\\nGHI[H]\\n ]\\n]', 16) 1123 18 1124 >>> pf.findBlockEnd('[ \\nABC\\nDEF[\\nGHI[H \\n ]\\n]', 16) 1125 Traceback (most recent call last): 1126 ... 1127 exploration.journal.JournalParseError... 1128 >>> pf.findBlockEnd('[ \\nABC\\nDEF[\\nGHI[H]\\n\\n]', 0) 1129 Traceback (most recent call last): 1130 ... 1131 exploration.journal.JournalParseError... 1132 """ 1133 # Find end of the line that the block opens on 1134 try: 1135 endOfLine = string.index('\n', startIndex) 1136 except ValueError: 1137 endOfLine = len(string) 1138 1139 # Determine if this is a single-line or multi-line block based 1140 # on the presence of *anything* after the opening delimiter 1141 restOfLine = string[startIndex + 1:endOfLine] 1142 if restOfLine.strip() != '': # A single-line block 1143 level = 1 1144 for restIndex, char in enumerate(restOfLine): 1145 if char == self.blockEnd: 1146 level -= 1 1147 if level <= 0: 1148 break 1149 elif char == self.blockStart: 1150 level += 1 1151 1152 if level == 0: 1153 return startIndex + 1 + restIndex 1154 else: 1155 raise JournalParseError( 1156 f"Got to end of line in single-line block without" 1157 f" finding the matching end-of-block marker." 1158 f" Remainder of line is:\n {restOfLine!r}" 1159 ) 1160 1161 else: # It's a multi-line block 1162 level = 1 1163 index = startIndex + 1 1164 while level > 0 and index < len(string): 1165 nextStart = self.blockStartRE.search(string, index) 1166 nextEnd = self.blockEndRE.search(string, index) 1167 if nextEnd is None: 1168 break # no end in sight; level won't be 0 1169 elif ( 1170 nextStart is None 1171 or nextStart.start() > nextEnd.start() 1172 ): 1173 index = nextEnd.end() 1174 level -= 1 1175 if level <= 0: 1176 break 1177 else: # They cannot be equal 1178 index = nextStart.end() 1179 level += 1 1180 1181 if level == 0: 1182 if nextEnd is None: 1183 raise RuntimeError( 1184 "Parsing got to level 0 with no valid end" 1185 " match." 1186 ) 1187 return nextEnd.end() - 1 1188 else: 1189 raise JournalParseError( 1190 f"Got to the end of the entire string and didn't" 1191 f" find a matching end-of-block marker. Started at" 1192 f" index {startIndex}." 1193 ) 1194 1195 1196#-------------------# 1197# Errors & Warnings # 1198#-------------------# 1199 1200class JournalParseError(ValueError): 1201 """ 1202 Represents a error encountered when parsing a journal. 1203 """ 1204 pass 1205 1206 1207class LocatedJournalParseError(JournalParseError): 1208 """ 1209 An error during journal parsing that includes additional location 1210 information. 1211 """ 1212 def __init__( 1213 self, 1214 src: str, 1215 index: Optional[int], 1216 cause: Exception 1217 ) -> None: 1218 """ 1219 In addition to the underlying error, the journal source text and 1220 the index within that text where the error occurred are 1221 required. 1222 """ 1223 super().__init__("localized error") 1224 self.src = src 1225 self.index = index 1226 self.cause = cause 1227 1228 def __str__(self) -> str: 1229 """ 1230 Includes information about the location of the error and the 1231 line it appeared on. 1232 """ 1233 ec = errorContext(self.src, self.index) 1234 errorCM = textwrap.indent(errorContextMessage(ec), ' ') 1235 return ( 1236 f"\n{errorCM}" 1237 f"\n Error is:" 1238 f"\n{type(self.cause).__name__}: {self.cause}" 1239 ) 1240 1241 1242def errorContext( 1243 string: str, 1244 index: Optional[int] 1245) -> Optional[Tuple[str, int, int]]: 1246 """ 1247 Returns the line of text, the line number, and the character within 1248 that line for the given absolute index into the given string. 1249 Newline characters count as the last character on their line. Lines 1250 and characters are numbered starting from 1. 1251 1252 Returns `None` for out-of-range indices. 1253 1254 Examples: 1255 1256 >>> errorContext('a\\nb\\nc', 0) 1257 ('a\\n', 1, 1) 1258 >>> errorContext('a\\nb\\nc', 1) 1259 ('a\\n', 1, 2) 1260 >>> errorContext('a\\nbcd\\ne', 2) 1261 ('bcd\\n', 2, 1) 1262 >>> errorContext('a\\nbcd\\ne', 3) 1263 ('bcd\\n', 2, 2) 1264 >>> errorContext('a\\nbcd\\ne', 4) 1265 ('bcd\\n', 2, 3) 1266 >>> errorContext('a\\nbcd\\ne', 5) 1267 ('bcd\\n', 2, 4) 1268 >>> errorContext('a\\nbcd\\ne', 6) 1269 ('e', 3, 1) 1270 >>> errorContext('a\\nbcd\\ne', -1) 1271 ('e', 3, 1) 1272 >>> errorContext('a\\nbcd\\ne', -2) 1273 ('bcd\\n', 2, 4) 1274 >>> errorContext('a\\nbcd\\ne', 7) is None 1275 True 1276 >>> errorContext('a\\nbcd\\ne', 8) is None 1277 True 1278 """ 1279 # Return None if no index is given 1280 if index is None: 1281 return None 1282 1283 # Convert negative to positive indices 1284 if index < 0: 1285 index = len(string) + index 1286 1287 # Return None for out-of-range indices 1288 if not 0 <= index < len(string): 1289 return None 1290 1291 # Count lines + look for start-of-line 1292 line = 1 1293 lineStart = 0 1294 for where, char in enumerate(string): 1295 if where >= index: 1296 break 1297 if char == '\n': 1298 line += 1 1299 lineStart = where + 1 1300 1301 try: 1302 endOfLine = string.index('\n', where) 1303 except ValueError: 1304 endOfLine = len(string) 1305 1306 return (string[lineStart:endOfLine + 1], line, index - lineStart + 1) 1307 1308 1309def errorContextMessage(context: Optional[Tuple[str, int, int]]) -> str: 1310 """ 1311 Given an error context tuple (from `errorContext`) or possibly 1312 `None`, returns a string that can be used as part of an error 1313 message, identifying where the error occurred. 1314 """ 1315 line: Union[int, str] 1316 pos: Union[int, str] 1317 if context is None: 1318 contextStr = "<context unavialable>" 1319 line = "?" 1320 pos = "?" 1321 else: 1322 contextStr, line, pos = context 1323 contextStr = contextStr.rstrip('\n') 1324 return ( 1325 f"In journal on line {line} near character {pos}:" 1326 f" {contextStr}" 1327 ) 1328 1329 1330class JournalParseWarning(Warning): 1331 """ 1332 Represents a warning encountered when parsing a journal. 1333 """ 1334 pass 1335 1336 1337class PathEllipsis: 1338 """ 1339 Represents part of a path which has been omitted from a journal and 1340 which should therefore be inferred. 1341 """ 1342 pass 1343 1344 1345#-----------------# 1346# Parsing manager # 1347#-----------------# 1348 1349class ObservationContext(TypedDict): 1350 """ 1351 The context for an observation, including which context (common or 1352 active) is being used, which domain we're focused on, which focal 1353 point is being modified for plural-focalized domains, and which 1354 decision and transition within the current domain are most relevant 1355 right now. 1356 """ 1357 context: base.ContextSpecifier 1358 domain: base.Domain 1359 # TODO: Per-domain focus/decision/transitions? 1360 focus: Optional[base.FocalPointName] 1361 decision: Optional[base.DecisionID] 1362 transition: Optional[Tuple[base.DecisionID, base.Transition]] 1363 1364 1365def observationContext( 1366 context: base.ContextSpecifier = "active", 1367 domain: base.Domain = base.DEFAULT_DOMAIN, 1368 focus: Optional[base.FocalPointName] = None, 1369 decision: Optional[base.DecisionID] = None, 1370 transition: Optional[Tuple[base.DecisionID, base.Transition]] = None 1371) -> ObservationContext: 1372 """ 1373 Creates a default/empty `ObservationContext`. 1374 """ 1375 return { 1376 'context': context, 1377 'domain': domain, 1378 'focus': focus, 1379 'decision': decision, 1380 'transition': transition 1381 } 1382 1383 1384class ObservationPreferences(TypedDict): 1385 """ 1386 Specifies global preferences for exploration observation. Values are 1387 either strings or booleans. The keys are: 1388 1389 - 'reciprocals': A boolean specifying whether transitions should 1390 come with reciprocals by default. Normally this is `True`, but 1391 it can be set to `False` instead. 1392 TODO: implement this. 1393 - 'revertAspects': A set of strings specifying which aspects of the 1394 game state should be reverted when a 'revert' action is taken and 1395 specific aspects to revert are not specified. See 1396 `base.revertedState` for a list of the available reversion 1397 aspects. 1398 """ 1399 reciprocals: bool 1400 revertAspects: Set[str] 1401 1402 1403def observationPreferences( 1404 reciprocals: bool=True, 1405 revertAspects: Optional[Set[str]] = None 1406) -> ObservationPreferences: 1407 """ 1408 Creates an observation preferences dictionary, using default values 1409 for any preferences not specified as arguments. 1410 """ 1411 return { 1412 'reciprocals': reciprocals, 1413 'revertAspects': ( 1414 revertAspects 1415 if revertAspects is not None 1416 else set() 1417 ) 1418 } 1419 1420 1421class JournalObserver: 1422 """ 1423 Keeps track of extra state needed when parsing a journal in order to 1424 produce a `core.DiscreteExploration` object. The methods of this 1425 class act as an API for constructing explorations that have several 1426 special properties. The API is designed to allow journal entries 1427 (which represent specific observations/events during an exploration) 1428 to be directly accumulated into an exploration object, including 1429 entries which apply to things like the most-recent-decision or 1430 -transition. 1431 1432 You can use the `convertJournal` function to handle things instead, 1433 since that function creates and manages a `JournalObserver` object 1434 for you. 1435 1436 The basic usage is as follows: 1437 1438 1. Create a `JournalObserver`, optionally specifying a custom 1439 `ParseFormat`. 1440 2. Repeatedly either: 1441 * Call `record*` API methods corresponding to specific entries 1442 observed or... 1443 * Call `JournalObserver.observe` to parse one or more 1444 journal blocks from a string and call the appropriate 1445 methods automatically. 1446 3. Call `JournalObserver.getExploration` to retrieve the 1447 `core.DiscreteExploration` object that's been created. 1448 1449 You can just call `convertJournal` to do all of these things at 1450 once. 1451 1452 Notes: 1453 1454 - `JournalObserver.getExploration` may be called at any time to get 1455 the exploration object constructed so far, and that that object 1456 (unless it's `None`) will always be the same object (which gets 1457 modified as entries are recorded). Modifying this object 1458 directly is possible for making changes not available via the 1459 API, but must be done carefully, as there are important 1460 conventions around things like decision names that must be 1461 respected if the API functions need to keep working. 1462 - To get the latest graph or state, simply use the 1463 `core.DiscreteExploration.getSituation()` method of the 1464 `JournalObserver.getExploration` result. 1465 1466 ## Examples 1467 1468 >>> obs = JournalObserver() 1469 >>> e = obs.getExploration() 1470 >>> len(e) # blank starting state 1471 1 1472 >>> e.getActiveDecisions(0) # no active decisions before starting 1473 set() 1474 >>> obs.definiteDecisionTarget() 1475 Traceback (most recent call last): 1476 ... 1477 exploration.core.MissingDecisionError... 1478 >>> obs.currentDecisionTarget() is None 1479 True 1480 >>> # We start by using the record* methods... 1481 >>> obs.recordStart("Start") 1482 >>> obs.definiteDecisionTarget() 1483 0 1484 >>> obs.recordObserve("bottom") 1485 >>> obs.definiteDecisionTarget() 1486 0 1487 >>> len(e) # blank + started states 1488 2 1489 >>> e.getActiveDecisions(1) 1490 {0} 1491 >>> obs.recordExplore("left", "West", "right") 1492 >>> obs.definiteDecisionTarget() 1493 2 1494 >>> len(e) # starting states + one step 1495 3 1496 >>> e.getActiveDecisions(1) 1497 {0} 1498 >>> e.movementAtStep(1) 1499 (0, 'left', 2) 1500 >>> e.getActiveDecisions(2) 1501 {2} 1502 >>> e.getActiveDecisions() 1503 {2} 1504 >>> e.getSituation().graph.nameFor(list(e.getActiveDecisions())[0]) 1505 'West' 1506 >>> obs.recordRetrace("right") # back at Start 1507 >>> obs.definiteDecisionTarget() 1508 0 1509 >>> len(e) # starting states + two steps 1510 4 1511 >>> e.getActiveDecisions(1) 1512 {0} 1513 >>> e.movementAtStep(1) 1514 (0, 'left', 2) 1515 >>> e.getActiveDecisions(2) 1516 {2} 1517 >>> e.movementAtStep(2) 1518 (2, 'right', 0) 1519 >>> e.getActiveDecisions(3) 1520 {0} 1521 >>> obs.recordRetrace("bad") # transition doesn't exist 1522 Traceback (most recent call last): 1523 ... 1524 exploration.journal.JournalParseError... 1525 >>> obs.definiteDecisionTarget() 1526 0 1527 >>> obs.recordObserve('right', 'East', 'left') 1528 >>> e.getSituation().graph.getTransitionRequirement('Start', 'right') 1529 ReqNothing() 1530 >>> obs.recordRequirement('crawl|small') 1531 >>> e.getSituation().graph.getTransitionRequirement('Start', 'right') 1532 ReqAny([ReqCapability('crawl'), ReqCapability('small')]) 1533 >>> obs.definiteDecisionTarget() 1534 0 1535 >>> obs.currentTransitionTarget() 1536 (0, 'right') 1537 >>> obs.currentReciprocalTarget() 1538 (3, 'left') 1539 >>> g = e.getSituation().graph 1540 >>> print(g.namesListing(g).rstrip('\\n')) 1541 0 (Start) 1542 1 (_u.0) 1543 2 (West) 1544 3 (East) 1545 >>> # The use of relative mode to add remote observations 1546 >>> obs.relative('East') 1547 >>> obs.definiteDecisionTarget() 1548 3 1549 >>> obs.recordObserve('top_vent') 1550 >>> obs.recordRequirement('crawl') 1551 >>> obs.recordReciprocalRequirement('crawl') 1552 >>> obs.recordMechanism('East', 'door', 'closed') # door starts closed 1553 >>> obs.recordAction('lever') 1554 >>> obs.recordTransitionConsequence( 1555 ... [base.effect(set=("door", "open")), base.effect(deactivate=True)] 1556 ... ) # lever opens the door 1557 >>> obs.recordExplore('right_door', 'Outside', 'left_door') 1558 >>> obs.definiteDecisionTarget() 1559 5 1560 >>> obs.recordRequirement('door:open') 1561 >>> obs.recordReciprocalRequirement('door:open') 1562 >>> obs.definiteDecisionTarget() 1563 5 1564 >>> obs.exploration.getExplorationStatus('East') 1565 'noticed' 1566 >>> obs.exploration.hasBeenVisited('East') 1567 False 1568 >>> obs.exploration.getExplorationStatus('Outside') 1569 'noticed' 1570 >>> obs.exploration.hasBeenVisited('Outside') 1571 False 1572 >>> obs.relative() # leave relative mode 1573 >>> len(e) # starting states + two steps, no steps happen in relative mode 1574 4 1575 >>> obs.definiteDecisionTarget() # out of relative mode; at Start 1576 0 1577 >>> g = e.getSituation().graph 1578 >>> g.getTransitionRequirement( 1579 ... g.getDestination('East', 'top_vent'), 1580 ... 'return' 1581 ... ) 1582 ReqCapability('crawl') 1583 >>> g.getTransitionRequirement('East', 'top_vent') 1584 ReqCapability('crawl') 1585 >>> g.getTransitionRequirement('East', 'right_door') 1586 ReqMechanism('door', 'open') 1587 >>> g.getTransitionRequirement('Outside', 'left_door') 1588 ReqMechanism('door', 'open') 1589 >>> print(g.namesListing(g).rstrip('\\n')) 1590 0 (Start) 1591 1 (_u.0) 1592 2 (West) 1593 3 (East) 1594 4 (_u.3) 1595 5 (Outside) 1596 >>> # Now we demonstrate the use of "observe" 1597 >>> e.getActiveDecisions() 1598 {0} 1599 >>> g.destinationsFrom(0) 1600 {'bottom': 1, 'left': 2, 'right': 3} 1601 >>> g.getDecision('Attic') is None 1602 True 1603 >>> obs.definiteDecisionTarget() 1604 0 1605 >>> obs.observe("\ 1606o up Attic down\\n\ 1607x up\\n\ 1608 n at: Attic\\n\ 1609o vent\\n\ 1610q crawl") 1611 >>> g = e.getSituation().graph 1612 >>> print(g.namesListing(g).rstrip('\\n')) 1613 0 (Start) 1614 1 (_u.0) 1615 2 (West) 1616 3 (East) 1617 4 (_u.3) 1618 5 (Outside) 1619 6 (Attic) 1620 7 (_u.6) 1621 >>> g.destinationsFrom(0) 1622 {'bottom': 1, 'left': 2, 'right': 3, 'up': 6} 1623 >>> g.nameFor(list(e.getActiveDecisions())[0]) 1624 'Attic' 1625 >>> g.getTransitionRequirement('Attic', 'vent') 1626 ReqCapability('crawl') 1627 >>> sorted(list(g.destinationsFrom('Attic').items())) 1628 [('down', 0), ('vent', 7)] 1629 >>> obs.definiteDecisionTarget() # in the Attic 1630 6 1631 >>> obs.observe("\ 1632a getCrawl\\n\ 1633 At gain crawl\\n\ 1634x vent East top_vent") # connecting to a previously-observed transition 1635 >>> g = e.getSituation().graph 1636 >>> print(g.namesListing(g).rstrip('\\n')) 1637 0 (Start) 1638 1 (_u.0) 1639 2 (West) 1640 3 (East) 1641 5 (Outside) 1642 6 (Attic) 1643 >>> g.getTransitionRequirement('East', 'top_vent') 1644 ReqCapability('crawl') 1645 >>> g.nameFor(g.getDestination('Attic', 'vent')) 1646 'East' 1647 >>> g.nameFor(g.getDestination('East', 'top_vent')) 1648 'Attic' 1649 >>> len(e) # exploration, action, and return are each 1 1650 7 1651 >>> e.getActiveDecisions(3) 1652 {0} 1653 >>> e.movementAtStep(3) 1654 (0, 'up', 6) 1655 >>> e.getActiveDecisions(4) 1656 {6} 1657 >>> g.nameFor(list(e.getActiveDecisions(4))[0]) 1658 'Attic' 1659 >>> e.movementAtStep(4) 1660 (6, 'getCrawl', 6) 1661 >>> g.nameFor(list(e.getActiveDecisions(5))[0]) 1662 'Attic' 1663 >>> e.movementAtStep(5) 1664 (6, 'vent', 3) 1665 >>> g.nameFor(list(e.getActiveDecisions(6))[0]) 1666 'East' 1667 >>> # Now let's pull the lever and go outside, but first, we'll 1668 >>> # return to the Start to demonstrate recordRetrace 1669 >>> # note that recordReturn only applies when the destination of the 1670 >>> # transition is not already known. 1671 >>> obs.recordRetrace('left') # back to Start 1672 >>> obs.definiteDecisionTarget() 1673 0 1674 >>> obs.recordRetrace('right') # and back to East 1675 >>> obs.definiteDecisionTarget() 1676 3 1677 >>> obs.exploration.mechanismState('door') 1678 'closed' 1679 >>> obs.recordRetrace('lever', isAction=True) # door is now open 1680 >>> obs.exploration.mechanismState('door') 1681 'open' 1682 >>> obs.exploration.getExplorationStatus('Outside') 1683 'noticed' 1684 >>> obs.recordExplore('right_door') 1685 >>> obs.definiteDecisionTarget() # now we're Outside 1686 5 1687 >>> obs.recordReturn('tunnelUnder', 'Start', 'bottom') 1688 >>> obs.definiteDecisionTarget() # back at the start 1689 0 1690 >>> g = e.getSituation().graph 1691 >>> print(g.namesListing(g).rstrip('\\n')) 1692 0 (Start) 1693 2 (West) 1694 3 (East) 1695 5 (Outside) 1696 6 (Attic) 1697 >>> g.destinationsFrom(0) 1698 {'left': 2, 'right': 3, 'up': 6, 'bottom': 5} 1699 >>> g.destinationsFrom(5) 1700 {'left_door': 3, 'tunnelUnder': 0} 1701 1702 An example of the use of `recordUnify` and `recordObviate`. 1703 1704 >>> obs = JournalObserver() 1705 >>> obs.observe(''' 1706 ... S start 1707 ... x right hall left 1708 ... x right room left 1709 ... x vent vents right_vent 1710 ... ''') 1711 >>> obs.recordObviate('middle_vent', 'hall', 'vent') 1712 >>> obs.recordExplore('left_vent', 'new_room', 'vent') 1713 >>> obs.recordUnify('start') 1714 >>> e = obs.getExploration() 1715 >>> len(e) 1716 6 1717 >>> e.getActiveDecisions(0) 1718 set() 1719 >>> [ 1720 ... e.getSituation(n).graph.nameFor(list(e.getActiveDecisions(n))[0]) 1721 ... for n in range(1, 6) 1722 ... ] 1723 ['start', 'hall', 'room', 'vents', 'start'] 1724 >>> g = e.getSituation().graph 1725 >>> g.getDestination('start', 'vent') 1726 3 1727 >>> g.getDestination('vents', 'left_vent') 1728 0 1729 >>> g.getReciprocal('start', 'vent') 1730 'left_vent' 1731 >>> g.getReciprocal('vents', 'left_vent') 1732 'vent' 1733 >>> 'new_room' in g 1734 False 1735 """ 1736 1737 parseFormat: JournalParseFormat 1738 """ 1739 The parse format used to parse entries supplied as text. This also 1740 ends up controlling some of the decision and transition naming 1741 conventions that are followed, so it is not safe to change it 1742 mid-journal; it should be set once before observation begins, and 1743 may be accessed but should not be changed. 1744 """ 1745 1746 exploration: core.DiscreteExploration 1747 """ 1748 This is the exploration object being built via journal observations. 1749 Note that the exploration object may be empty (i.e., have length 0) 1750 even after the first few entries have been recorded because in some 1751 cases entries are ambiguous and are not translated into exploration 1752 steps until a further entry resolves that ambiguity. 1753 """ 1754 1755 preferences: ObservationPreferences 1756 """ 1757 Preferences for the observation mechanisms. See 1758 `ObservationPreferences`. 1759 """ 1760 1761 uniqueNumber: int 1762 """ 1763 A unique number to be substituted (prefixed with '_') into 1764 underscore-substitutions within aliases. Will be incremented for each 1765 such substitution. 1766 """ 1767 1768 aliases: Dict[str, Tuple[List[str], str]] 1769 """ 1770 The defined aliases for this observer. Each alias has a name, and 1771 stored under that name is a list of parameters followed by a 1772 commands string. 1773 """ 1774 1775 def __init__(self, parseFormat: Optional[JournalParseFormat] = None): 1776 """ 1777 Sets up the observer. If a parse format is supplied, that will 1778 be used instead of the default parse format, which is just the 1779 result of creating a `ParseFormat` with default arguments. 1780 1781 A simple example: 1782 1783 >>> o = JournalObserver() 1784 >>> o.recordStart('hi') 1785 >>> o.exploration.getExplorationStatus('hi') 1786 'exploring' 1787 >>> e = o.getExploration() 1788 >>> len(e) 1789 2 1790 >>> g = e.getSituation().graph 1791 >>> len(g) 1792 1 1793 >>> e.getActiveContext() 1794 {\ 1795'capabilities': {'capabilities': set(), 'tokens': {}, 'skills': {}},\ 1796 'focalization': {'main': 'singular'},\ 1797 'activeDomains': {'main'},\ 1798 'activeDecisions': {'main': 0}\ 1799} 1800 >>> list(g.nodes)[0] 1801 0 1802 >>> o.recordObserve('option') 1803 >>> list(g.nodes) 1804 [0, 1] 1805 >>> [g.nameFor(d) for d in g.nodes] 1806 ['hi', '_u.0'] 1807 >>> o.recordZone(0, 'Lower') 1808 >>> [g.nameFor(d) for d in g.nodes] 1809 ['hi', '_u.0'] 1810 >>> e.getActiveDecisions() 1811 {0} 1812 >>> o.recordZone(1, 'Upper') 1813 >>> o.recordExplore('option', 'bye', 'back') 1814 >>> g = e.getSituation().graph 1815 >>> [g.nameFor(d) for d in g.nodes] 1816 ['hi', 'bye'] 1817 >>> o.recordObserve('option2') 1818 >>> import pytest 1819 >>> oldWarn = core.WARN_OF_NAME_COLLISIONS 1820 >>> core.WARN_OF_NAME_COLLISIONS = True 1821 >>> try: 1822 ... with pytest.warns(core.DecisionCollisionWarning): 1823 ... o.recordExplore('option2', 'Lower2::hi', 'back') 1824 ... finally: 1825 ... core.WARN_OF_NAME_COLLISIONS = oldWarn 1826 >>> g = e.getSituation().graph 1827 >>> [g.nameFor(d) for d in g.nodes] 1828 ['hi', 'bye', 'hi'] 1829 >>> # Prefix must be specified because it's ambiguous 1830 >>> o.recordWarp('Lower::hi') 1831 >>> g = e.getSituation().graph 1832 >>> [(d, g.nameFor(d)) for d in g.nodes] 1833 [(0, 'hi'), (1, 'bye'), (2, 'hi')] 1834 >>> e.getActiveDecisions() 1835 {0} 1836 >>> o.recordWarp('bye') 1837 >>> g = e.getSituation().graph 1838 >>> [(d, g.nameFor(d)) for d in g.nodes] 1839 [(0, 'hi'), (1, 'bye'), (2, 'hi')] 1840 >>> e.getActiveDecisions() 1841 {1} 1842 """ 1843 if parseFormat is None: 1844 self.parseFormat = JournalParseFormat() 1845 else: 1846 self.parseFormat = parseFormat 1847 1848 self.uniqueNumber = 0 1849 self.aliases = {} 1850 1851 # Set up default observation preferences 1852 self.preferences = observationPreferences() 1853 1854 # Create a blank exploration 1855 self.exploration = core.DiscreteExploration() 1856 1857 # Debugging support 1858 self.prevSteps: Optional[int] = None 1859 self.prevDecisions: Optional[int] = None 1860 1861 # Current context tracking focal context, domain, focus point, 1862 # decision, and/or transition that's currently most relevant: 1863 self.context = observationContext() 1864 1865 # TODO: Stack of contexts? 1866 # Stored observation context can be restored as the current 1867 # state later. This is used to support relative mode. 1868 self.storedContext: Optional[ 1869 ObservationContext 1870 ] = None 1871 1872 # Whether or not we're in relative mode. 1873 self.inRelativeMode = False 1874 1875 # Tracking which decisions we shouldn't auto-finalize 1876 self.dontFinalize: Set[base.DecisionID] = set() 1877 1878 # Tracking current parse location for errors & warnings 1879 self.journalTexts: List[str] = [] # a stack 'cause of macros 1880 self.parseIndices: List[int] = [] # also a stack 1881 1882 def getExploration(self) -> core.DiscreteExploration: 1883 """ 1884 Returns the exploration that this observer edits. 1885 """ 1886 return self.exploration 1887 1888 def nextUniqueName(self) -> str: 1889 """ 1890 Returns the next unique name for this observer, which is just an 1891 underscore followed by an integer. This increments 1892 `uniqueNumber`. 1893 """ 1894 result = '_' + str(self.uniqueNumber) 1895 self.uniqueNumber += 1 1896 return result 1897 1898 def currentDecisionTarget(self) -> Optional[base.DecisionID]: 1899 """ 1900 Returns the decision which decision-based changes should be 1901 applied to. Changes depending on whether relative mode is 1902 active. Will be `None` when there is no current position (e.g., 1903 before the exploration is started). 1904 """ 1905 return self.context['decision'] 1906 1907 def definiteDecisionTarget(self) -> base.DecisionID: 1908 """ 1909 Works like `currentDecisionTarget` but raises a 1910 `core.MissingDecisionError` instead of returning `None` if there 1911 is no current decision. 1912 """ 1913 result = self.currentDecisionTarget() 1914 1915 if result is None: 1916 raise core.MissingDecisionError("There is no current decision.") 1917 else: 1918 return result 1919 1920 def decisionTargetSpecifier(self) -> base.DecisionSpecifier: 1921 """ 1922 Returns a `base.DecisionSpecifier` which includes domain, zone, 1923 and name for the current decision. The zone used is the first 1924 alphabetical lowest-level zone that the decision is in, which 1925 *could* in some cases remain ambiguous. If you're worried about 1926 that, use `definiteDecisionTarget` instead. 1927 1928 Like `definiteDecisionTarget` this will crash if there isn't a 1929 current decision target. 1930 """ 1931 graph = self.exploration.getSituation().graph 1932 dID = self.definiteDecisionTarget() 1933 domain = graph.domainFor(dID) 1934 name = graph.nameFor(dID) 1935 inZones = graph.zoneAncestors(dID) 1936 # Alphabetical order (we have no better option) 1937 ordered = sorted( 1938 inZones, 1939 key=lambda z: ( 1940 graph.zoneHierarchyLevel(z), # level-0 first 1941 z # alphabetical as tie-breaker 1942 ) 1943 ) 1944 if len(ordered) > 0: 1945 useZone = ordered[0] 1946 else: 1947 useZone = None 1948 1949 return base.DecisionSpecifier( 1950 domain=domain, 1951 zone=useZone, 1952 name=name 1953 ) 1954 1955 def currentTransitionTarget( 1956 self 1957 ) -> Optional[Tuple[base.DecisionID, base.Transition]]: 1958 """ 1959 Returns the decision, transition pair that identifies the current 1960 transition which transition-based changes should apply to. Will 1961 be `None` when there is no current transition (e.g., just after a 1962 warp). 1963 """ 1964 transition = self.context['transition'] 1965 if transition is None: 1966 return None 1967 else: 1968 return transition 1969 1970 def currentReciprocalTarget( 1971 self 1972 ) -> Optional[Tuple[base.DecisionID, base.Transition]]: 1973 """ 1974 Returns the decision, transition pair that identifies the 1975 reciprocal of the `currentTransitionTarget`. Will be `None` when 1976 there is no current transition, or when the current transition 1977 doesn't have a reciprocal (e.g., after an ending). 1978 """ 1979 # relative mode is handled by `currentTransitionTarget` 1980 target = self.currentTransitionTarget() 1981 if target is None: 1982 return None 1983 return self.exploration.getSituation().graph.getReciprocalPair( 1984 *target 1985 ) 1986 1987 def checkFormat( 1988 self, 1989 entryType: str, 1990 decisionType: base.DecisionType, 1991 target: Union[None, JournalTargetType, Tuple[JournalTargetType, int]], 1992 pieces: List[str], 1993 expectedTargets: Union[ 1994 None, 1995 JournalTargetType, 1996 Collection[ 1997 Union[None, JournalTargetType] 1998 ] 1999 ], 2000 expectedPieces: Union[None, int, Collection[int]] 2001 ) -> None: 2002 """ 2003 Does format checking for a journal entry after 2004 `determineEntryType` is called. Checks that: 2005 2006 - A decision type other than 'active' is only used for entries 2007 where that makes sense. 2008 - The target is one from an allowed list of targets (or is `None` 2009 if `expectedTargets` is set to `None`) 2010 - The number of pieces of content is a specific number or within 2011 a specific collection of allowed numbers. If `expectedPieces` 2012 is set to None, there is no restriction on the number of 2013 pieces. 2014 2015 Raises a `JournalParseError` if its expectations are violated. 2016 """ 2017 if decisionType != 'active' and entryType not in ( 2018 'START', 2019 'explore', 2020 'retrace', 2021 'return', 2022 'action', 2023 'warp', 2024 'wait', 2025 'END', 2026 'revert' 2027 ): 2028 raise JournalParseError( 2029 f"{entryType} entry may not specify a non-standard" 2030 f" decision type (got {decisionType!r}), because it is" 2031 f" not associated with an exploration action." 2032 ) 2033 2034 if expectedTargets is None: 2035 if target is not None: 2036 raise JournalParseError( 2037 f"{entryType} entry may not specify a target." 2038 ) 2039 else: 2040 if isinstance(expectedTargets, str): 2041 expected = cast( 2042 Collection[Union[None, JournalTargetType]], 2043 [expectedTargets] 2044 ) 2045 else: 2046 expected = cast( 2047 Collection[Union[None, JournalTargetType]], 2048 expectedTargets 2049 ) 2050 tType = target 2051 if isinstance(tType, tuple): 2052 tType = tType[0] 2053 2054 if tType not in expected: 2055 raise JournalParseError( 2056 f"{entryType} entry had invalid target {target!r}." 2057 f" Expected one of:\n{expected}" 2058 ) 2059 2060 if expectedPieces is None: 2061 # No restriction 2062 pass 2063 elif isinstance(expectedPieces, int): 2064 if len(pieces) != expectedPieces: 2065 raise JournalParseError( 2066 f"{entryType} entry had {len(pieces)} arguments but" 2067 f" only {expectedPieces} argument(s) is/are allowed." 2068 ) 2069 2070 elif len(pieces) not in expectedPieces: 2071 allowed = ', '.join(str(x) for x in expectedPieces) 2072 raise JournalParseError( 2073 f"{entryType} entry had {len(pieces)} arguments but the" 2074 f" allowed argument counts are: {allowed}" 2075 ) 2076 2077 def parseOneCommand( 2078 self, 2079 journalText: str, 2080 startIndex: int 2081 ) -> Tuple[List[str], int]: 2082 """ 2083 Parses a single command from the given journal text, starting at 2084 the specified start index. Each command occupies a single line, 2085 except when blocks are present in which case it may stretch 2086 across multiple lines. This function splits the command up into a 2087 list of strings (including multi-line strings and/or strings 2088 with spaces in them when blocks are used). It returns that list 2089 of strings, along with the index after the newline at the end of 2090 the command it parsed (which could be used as the start index 2091 for the next command). If the command has no newline after it 2092 (only possible when the string ends) the returned index will be 2093 the length of the string. 2094 2095 If the line starting with the start character is empty (or just 2096 contains spaces), the result will be an empty list along with the 2097 index for the start of the next line. 2098 2099 Examples: 2100 2101 >>> o = JournalObserver() 2102 >>> commands = '''\\ 2103 ... S start 2104 ... o option 2105 ... 2106 ... x option next back 2107 ... o lever 2108 ... e edit [ 2109 ... o bridge 2110 ... q speed 2111 ... ] [ 2112 ... o bridge 2113 ... q X 2114 ... ] 2115 ... a lever 2116 ... ''' 2117 >>> o.parseOneCommand(commands, 0) 2118 (['S', 'start'], 8) 2119 >>> o.parseOneCommand(commands, 8) 2120 (['o', 'option'], 17) 2121 >>> o.parseOneCommand(commands, 17) 2122 ([], 18) 2123 >>> o.parseOneCommand(commands, 18) 2124 (['x', 'option', 'next', 'back'], 37) 2125 >>> o.parseOneCommand(commands, 37) 2126 (['o', 'lever'], 45) 2127 >>> bits, end = o.parseOneCommand(commands, 45) 2128 >>> bits[:2] 2129 ['e', 'edit'] 2130 >>> bits[2] 2131 'o bridge\\n q speed' 2132 >>> bits[3] 2133 'o bridge\\n q X' 2134 >>> len(bits) 2135 4 2136 >>> end 2137 116 2138 >>> o.parseOneCommand(commands, end) 2139 (['a', 'lever'], 124) 2140 2141 >>> o = JournalObserver() 2142 >>> s = "o up Attic down\\nx up\\no vent\\nq crawl" 2143 >>> o.parseOneCommand(s, 0) 2144 (['o', 'up', 'Attic', 'down'], 16) 2145 >>> o.parseOneCommand(s, 16) 2146 (['x', 'up'], 21) 2147 >>> o.parseOneCommand(s, 21) 2148 (['o', 'vent'], 28) 2149 >>> o.parseOneCommand(s, 28) 2150 (['q', 'crawl'], 35) 2151 """ 2152 2153 index = startIndex 2154 unit: Optional[str] = None 2155 bits: List[str] = [] 2156 pf = self.parseFormat # shortcut variable 2157 while index < len(journalText): 2158 char = journalText[index] 2159 if char.isspace(): 2160 # Space after non-spaces -> end of unit 2161 if unit is not None: 2162 bits.append(unit) 2163 unit = None 2164 # End of line -> end of command 2165 if char == '\n': 2166 index += 1 2167 break 2168 else: 2169 # Non-space -> check for block 2170 if char == pf.blockStart: 2171 if unit is not None: 2172 bits.append(unit) 2173 unit = None 2174 blockEnd = pf.findBlockEnd(journalText, index) 2175 block = journalText[index + 1:blockEnd - 1].strip() 2176 bits.append(block) 2177 index = blockEnd # +1 added below 2178 elif unit is None: # Initial non-space -> start of unit 2179 unit = char 2180 else: # Continuing non-space -> accumulate 2181 unit += char 2182 # Increment index 2183 index += 1 2184 2185 # Grab final unit if there is one hanging 2186 if unit is not None: 2187 bits.append(unit) 2188 2189 return (bits, index) 2190 2191 def warn(self, message: str) -> None: 2192 """ 2193 Issues a `JournalParseWarning`. 2194 """ 2195 if len(self.journalTexts) == 0 or len(self.parseIndices) == 0: 2196 warnings.warn(message, JournalParseWarning) 2197 else: 2198 # Note: We use the basal position info because that will 2199 # typically be much more useful when debugging 2200 ec = errorContext(self.journalTexts[0], self.parseIndices[0]) 2201 errorCM = textwrap.indent(errorContextMessage(ec), ' ') 2202 warnings.warn(errorCM + '\n' + message, JournalParseWarning) 2203 2204 def observe(self, journalText: str) -> None: 2205 """ 2206 Ingests one or more journal blocks in text format (as a 2207 multi-line string) and updates the exploration being built by 2208 this observer, as well as updating internal state. 2209 2210 This method can be called multiple times to process a longer 2211 journal incrementally including line-by-line. 2212 2213 The `journalText` and `parseIndex` fields will be updated during 2214 parsing to support contextual error messages and warnings. 2215 2216 ## Example: 2217 2218 >>> obs = JournalObserver() 2219 >>> oldWarn = core.WARN_OF_NAME_COLLISIONS 2220 >>> try: 2221 ... obs.observe('''\\ 2222 ... S Room1::start 2223 ... zz Region 2224 ... o nope 2225 ... q power|tokens*3 2226 ... o unexplored 2227 ... o onwards 2228 ... x onwards sub_room backwards 2229 ... t backwards 2230 ... o down 2231 ... 2232 ... x down Room2::middle up 2233 ... a box 2234 ... At deactivate 2235 ... At gain tokens*1 2236 ... o left 2237 ... o right 2238 ... gt blue 2239 ... 2240 ... x right Room3::middle left 2241 ... o right 2242 ... a miniboss 2243 ... At deactivate 2244 ... At gain power 2245 ... x right - left 2246 ... o ledge 2247 ... q tall 2248 ... t left 2249 ... t left 2250 ... t up 2251 ... 2252 ... x nope secret back 2253 ... ''') 2254 ... finally: 2255 ... core.WARN_OF_NAME_COLLISIONS = oldWarn 2256 >>> e = obs.getExploration() 2257 >>> len(e) 2258 13 2259 >>> g = e.getSituation().graph 2260 >>> len(g) 2261 9 2262 >>> def showDestinations(g, r): 2263 ... if isinstance(r, str): 2264 ... r = obs.parseFormat.parseDecisionSpecifier(r) 2265 ... d = g.destinationsFrom(r) 2266 ... for outgoing in sorted(d): 2267 ... req = g.getTransitionRequirement(r, outgoing) 2268 ... if req is None or req == base.ReqNothing(): 2269 ... req = '' 2270 ... else: 2271 ... req = ' ' + repr(req) 2272 ... print(outgoing, g.identityOf(d[outgoing]) + req) 2273 ... 2274 >>> "start" in g 2275 False 2276 >>> showDestinations(g, "Room1::start") 2277 down 4 (Room2::middle) 2278 nope 1 (Room1::secret) ReqAny([ReqCapability('power'),\ 2279 ReqTokens('tokens', 3)]) 2280 onwards 3 (Room1::sub_room) 2281 unexplored 2 (_u.1) 2282 >>> showDestinations(g, "Room1::secret") 2283 back 0 (Room1::start) 2284 >>> showDestinations(g, "Room1::sub_room") 2285 backwards 0 (Room1::start) 2286 >>> showDestinations(g, "Room2::middle") 2287 box 4 (Room2::middle) 2288 left 5 (_u.4) 2289 right 6 (Room3::middle) 2290 up 0 (Room1::start) 2291 >>> g.transitionTags(4, "right") 2292 {'blue': 1} 2293 >>> showDestinations(g, "Room3::middle") 2294 left 4 (Room2::middle) 2295 miniboss 6 (Room3::middle) 2296 right 7 (Room3::-) 2297 >>> showDestinations(g, "Room3::-") 2298 ledge 8 (_u.7) ReqCapability('tall') 2299 left 6 (Room3::middle) 2300 >>> showDestinations(g, "_u.7") 2301 return 7 (Room3::-) 2302 >>> e.getActiveDecisions() 2303 {1} 2304 >>> g.identityOf(1) 2305 '1 (Room1::secret)' 2306 2307 Note that there are plenty of other annotations not shown in 2308 this example; see `DEFAULT_FORMAT` for the default mapping from 2309 journal entry types to markers, and see `JournalEntryType` for 2310 the explanation for each entry type. 2311 2312 Most entries start with a marker (which includes one character 2313 for the type and possibly one for the target) followed by a 2314 single space, and everything after that is the content of the 2315 entry. 2316 """ 2317 # Normalize newlines 2318 journalText = journalText\ 2319 .replace('\r\n', '\n')\ 2320 .replace('\n\r', '\n')\ 2321 .replace('\r', '\n') 2322 2323 # Shortcut variable 2324 pf = self.parseFormat 2325 2326 # Remove comments from entire text 2327 journalText = pf.removeComments(journalText) 2328 2329 # TODO: Give access to comments in error messages? 2330 # Store for error messages 2331 self.journalTexts.append(journalText) 2332 self.parseIndices.append(0) 2333 2334 startAt = 0 2335 try: 2336 while startAt < len(journalText): 2337 self.parseIndices[-1] = startAt 2338 bits, startAt = self.parseOneCommand(journalText, startAt) 2339 2340 if len(bits) == 0: 2341 continue 2342 2343 eType, dType, eTarget, eParts = pf.determineEntryType(bits) 2344 if eType == 'preference': 2345 self.checkFormat( 2346 'preference', 2347 dType, 2348 eTarget, 2349 eParts, 2350 None, 2351 2 2352 ) 2353 pref = eParts[0] 2354 opAnn = get_type_hints(ObservationPreferences) 2355 if pref not in opAnn: 2356 raise JournalParseError( 2357 f"Invalid preference name {pref!r}." 2358 ) 2359 2360 prefVal: Union[None, str, bool, Set[str]] 2361 if opAnn[pref] is bool: 2362 prefVal = pf.onOff(eParts[1]) 2363 if prefVal is None: 2364 self.warn( 2365 f"On/off value {eParts[1]!r} is neither" 2366 f" {pf.markerFor('on')!r} nor" 2367 f" {pf.markerFor('off')!r}. Assuming" 2368 f" 'off'." 2369 ) 2370 elif opAnn[pref] == Set[str]: 2371 prefVal = set(' '.join(eParts[1:]).split()) 2372 else: # we assume it's a string 2373 assert opAnn[pref] is str 2374 prefVal = eParts[1] 2375 2376 # Set the preference value (type checked above) 2377 self.preferences[pref] = prefVal # type: ignore [literal-required] # noqa: E501 2378 2379 elif eType == 'alias': 2380 self.checkFormat( 2381 "alias", 2382 dType, 2383 eTarget, 2384 eParts, 2385 None, 2386 None 2387 ) 2388 2389 if len(eParts) < 2: 2390 raise JournalParseError( 2391 "Alias entry must include at least an alias" 2392 " name and a commands list." 2393 ) 2394 aliasName = eParts[0] 2395 parameters = eParts[1:-1] 2396 commands = eParts[-1] 2397 self.defineAlias(aliasName, parameters, commands) 2398 2399 elif eType == 'custom': 2400 self.checkFormat( 2401 "custom", 2402 dType, 2403 eTarget, 2404 eParts, 2405 None, 2406 None 2407 ) 2408 if len(eParts) == 0: 2409 raise JournalParseError( 2410 "Custom entry must include at least an alias" 2411 " name." 2412 ) 2413 self.deployAlias(eParts[0], eParts[1:]) 2414 2415 elif eType == 'DEBUG': 2416 self.checkFormat( 2417 "DEBUG", 2418 dType, 2419 eTarget, 2420 eParts, 2421 None, 2422 {1, 2} 2423 ) 2424 if eParts[0] not in get_args(DebugAction): 2425 raise JournalParseError( 2426 f"Invalid debug action: {eParts[0]!r}" 2427 ) 2428 dAction = cast(DebugAction, eParts[0]) 2429 if len(eParts) > 1: 2430 self.doDebug(dAction, eParts[1]) 2431 else: 2432 self.doDebug(dAction) 2433 2434 elif eType == 'START': 2435 self.checkFormat( 2436 "START", 2437 dType, 2438 eTarget, 2439 eParts, 2440 None, 2441 1 2442 ) 2443 2444 where = pf.parseDecisionSpecifier(eParts[0]) 2445 if isinstance(where, base.DecisionID): 2446 raise JournalParseError( 2447 f"Can't use {repr(where)} as a start" 2448 f" because the start must be a decision" 2449 f" name, not a decision ID." 2450 ) 2451 self.recordStart(where, dType) 2452 2453 elif eType == 'explore': 2454 self.checkFormat( 2455 "explore", 2456 dType, 2457 eTarget, 2458 eParts, 2459 None, 2460 {1, 2, 3} 2461 ) 2462 2463 tr = pf.parseTransitionWithOutcomes(eParts[0]) 2464 2465 if len(eParts) == 1: 2466 self.recordExplore(tr, decisionType=dType) 2467 elif len(eParts) == 2: 2468 destination = pf.parseDecisionSpecifier(eParts[1]) 2469 self.recordExplore( 2470 tr, 2471 destination, 2472 decisionType=dType 2473 ) 2474 else: 2475 destination = pf.parseDecisionSpecifier(eParts[1]) 2476 self.recordExplore( 2477 tr, 2478 destination, 2479 eParts[2], 2480 decisionType=dType 2481 ) 2482 2483 elif eType == 'return': 2484 self.checkFormat( 2485 "return", 2486 dType, 2487 eTarget, 2488 eParts, 2489 None, 2490 {1, 2, 3} 2491 ) 2492 tr = pf.parseTransitionWithOutcomes(eParts[0]) 2493 if len(eParts) > 1: 2494 destination = pf.parseDecisionSpecifier(eParts[1]) 2495 else: 2496 destination = None 2497 if len(eParts) > 2: 2498 reciprocal = eParts[2] 2499 else: 2500 reciprocal = None 2501 self.recordReturn( 2502 tr, 2503 destination, 2504 reciprocal, 2505 decisionType=dType 2506 ) 2507 2508 elif eType == 'action': 2509 self.checkFormat( 2510 "action", 2511 dType, 2512 eTarget, 2513 eParts, 2514 None, 2515 1 2516 ) 2517 tr = pf.parseTransitionWithOutcomes(eParts[0]) 2518 self.recordAction(tr, decisionType=dType) 2519 2520 elif eType == 'retrace': 2521 self.checkFormat( 2522 "retrace", 2523 dType, 2524 eTarget, 2525 eParts, 2526 (None, 'actionPart'), 2527 1 2528 ) 2529 tr = pf.parseTransitionWithOutcomes(eParts[0]) 2530 self.recordRetrace( 2531 tr, 2532 decisionType=dType, 2533 isAction=eTarget == 'actionPart' 2534 ) 2535 2536 elif eType == 'warp': 2537 self.checkFormat( 2538 "warp", 2539 dType, 2540 eTarget, 2541 eParts, 2542 None, 2543 {1} 2544 ) 2545 2546 destination = pf.parseDecisionSpecifier(eParts[0]) 2547 self.recordWarp(destination, decisionType=dType) 2548 2549 elif eType == 'wait': 2550 self.checkFormat( 2551 "wait", 2552 dType, 2553 eTarget, 2554 eParts, 2555 None, 2556 0 2557 ) 2558 self.recordWait(decisionType=dType) 2559 2560 elif eType == 'observe': 2561 self.checkFormat( 2562 "observe", 2563 dType, 2564 eTarget, 2565 eParts, 2566 (None, 'actionPart', 'endingPart'), 2567 (1, 2, 3) 2568 ) 2569 if eTarget is None: 2570 self.recordObserve(*eParts) 2571 elif eTarget == 'actionPart': 2572 if len(eParts) > 1: 2573 raise JournalParseError( 2574 f"Observing action {eParts[0]!r} at" 2575 f" {self.definiteDecisionTarget()!r}:" 2576 f" neither a destination nor a" 2577 f" reciprocal may be specified when" 2578 f" observing an action (did you mean to" 2579 f" observe a transition?)." 2580 ) 2581 self.recordObserveAction(*eParts) 2582 elif eTarget == 'endingPart': 2583 if len(eParts) > 1: 2584 raise JournalParseError( 2585 f"Observing ending {eParts[0]!r} at" 2586 f" {self.definiteDecisionTarget()!r}:" 2587 f" neither a destination nor a" 2588 f" reciprocal may be specified when" 2589 f" observing an ending (did you mean to" 2590 f" observe a transition?)." 2591 ) 2592 self.recordObserveEnding(*eParts) 2593 2594 elif eType == 'END': 2595 self.checkFormat( 2596 "END", 2597 dType, 2598 eTarget, 2599 eParts, 2600 (None, 'actionPart'), 2601 1 2602 ) 2603 self.recordEnd( 2604 eParts[0], 2605 eTarget == 'actionPart', 2606 decisionType=dType 2607 ) 2608 2609 elif eType == 'mechanism': 2610 self.checkFormat( 2611 "mechanism", 2612 dType, 2613 eTarget, 2614 eParts, 2615 None, 2616 1 2617 ) 2618 mReq = pf.parseRequirement(eParts[0]) 2619 if ( 2620 not isinstance(mReq, base.ReqMechanism) 2621 or not isinstance( 2622 mReq.mechanism, 2623 (base.MechanismName, base.MechanismSpecifier) 2624 ) 2625 ): 2626 raise JournalParseError( 2627 f"Invalid mechanism declaration" 2628 f" {eParts[0]!r}. Declaration must specify" 2629 f" mechanism name and starting state." 2630 ) 2631 mState = mReq.reqState 2632 if isinstance(mReq.mechanism, base.MechanismName): 2633 where = self.definiteDecisionTarget() 2634 mName = mReq.mechanism 2635 else: 2636 assert isinstance( 2637 mReq.mechanism, 2638 base.MechanismSpecifier 2639 ) 2640 mSpec = mReq.mechanism 2641 mName = mSpec.name 2642 if mSpec.decision is not None: 2643 where = base.DecisionSpecifier( 2644 mSpec.domain, 2645 mSpec.zone, 2646 mSpec.decision 2647 ) 2648 else: 2649 where = self.definiteDecisionTarget() 2650 graph = self.exploration.getSituation().graph 2651 thisDomain = graph.domainFor(where) 2652 theseZones = graph.zoneAncestors(where) 2653 if ( 2654 mSpec.domain is not None 2655 and mSpec.domain != thisDomain 2656 ): 2657 raise JournalParseError( 2658 f"Mechanism specifier {mSpec!r}" 2659 f" does not specify a decision but" 2660 f" includes domain {mSpec.domain!r}" 2661 f" which does not match the domain" 2662 f" {thisDomain!r} of the current" 2663 f" decision {graph.identityOf(where)}" 2664 ) 2665 if ( 2666 mSpec.zone is not None 2667 and mSpec.zone not in theseZones 2668 ): 2669 raise JournalParseError( 2670 f"Mechanism specifier {mSpec!r}" 2671 f" does not specify a decision but" 2672 f" includes zone {mSpec.zone!r}" 2673 f" which is not one of the zones" 2674 f" that the current decision" 2675 f" {graph.identityOf(where)} is in:" 2676 f"\n{theseZones!r}" 2677 ) 2678 self.recordMechanism(where, mName, mState) 2679 2680 elif eType == 'requirement': 2681 self.checkFormat( 2682 "requirement", 2683 dType, 2684 eTarget, 2685 eParts, 2686 (None, 'reciprocalPart', 'bothPart'), 2687 None 2688 ) 2689 req = pf.parseRequirement(' '.join(eParts)) 2690 if eTarget in (None, 'bothPart'): 2691 self.recordRequirement(req) 2692 if eTarget in ('reciprocalPart', 'bothPart'): 2693 self.recordReciprocalRequirement(req) 2694 2695 elif eType == 'effect': 2696 self.checkFormat( 2697 "effect", 2698 dType, 2699 eTarget, 2700 eParts, 2701 (None, 'reciprocalPart', 'bothPart'), 2702 None 2703 ) 2704 2705 consequence: base.Consequence 2706 try: 2707 consequence = pf.parseConsequence(' '.join(eParts)) 2708 except parsing.ParseError: 2709 consequence = [pf.parseEffect(' '.join(eParts))] 2710 2711 if eTarget in (None, 'bothPart'): 2712 self.recordTransitionConsequence(consequence) 2713 if eTarget in ('reciprocalPart', 'bothPart'): 2714 self.recordReciprocalConsequence(consequence) 2715 2716 elif eType == 'apply': 2717 self.checkFormat( 2718 "apply", 2719 dType, 2720 eTarget, 2721 eParts, 2722 (None, 'transitionPart'), 2723 None 2724 ) 2725 2726 toApply: base.Consequence 2727 try: 2728 toApply = pf.parseConsequence(' '.join(eParts)) 2729 except parsing.ParseError: 2730 toApply = [pf.parseEffect(' '.join(eParts))] 2731 2732 # If we targeted a transition, that means we wanted 2733 # to both apply the consequence now AND set it up as 2734 # an consequence of the transition we just took. 2735 if eTarget == 'transitionPart': 2736 if self.context['transition'] is None: 2737 raise JournalParseError( 2738 "Can't apply a consequence to a" 2739 " transition here because there is no" 2740 " current relevant transition." 2741 ) 2742 # We need to apply these consequences as part of 2743 # the transition so their trigger count will be 2744 # tracked properly, but we do not want to 2745 # re-apply the other parts of the consequence. 2746 self.recordAdditionalTransitionConsequence( 2747 toApply 2748 ) 2749 else: 2750 # Otherwise just apply the consequence 2751 self.exploration.applyExtraneousConsequence( 2752 toApply, 2753 where=self.context['transition'], 2754 moveWhich=self.context['focus'] 2755 ) 2756 # Note: no situation-based variables need 2757 # updating here 2758 2759 elif eType == 'tag': 2760 self.checkFormat( 2761 "tag", 2762 dType, 2763 eTarget, 2764 eParts, 2765 ( 2766 None, 2767 'decisionPart', 2768 'transitionPart', 2769 'reciprocalPart', 2770 'bothPart', 2771 'zonePart' 2772 ), 2773 None 2774 ) 2775 tag: base.Tag 2776 value: base.TagValue 2777 if len(eParts) == 0: 2778 raise JournalParseError( 2779 "tag entry must include at least a tag name." 2780 ) 2781 elif len(eParts) == 1: 2782 tag = eParts[0] 2783 value = 1 2784 elif len(eParts) == 2: 2785 tag, value = eParts 2786 value = pf.parseTagValue(value) 2787 else: 2788 raise JournalParseError( 2789 f"tag entry has too many parts (only a tag" 2790 f" name and a tag value are allowed). Got:" 2791 f" {eParts}" 2792 ) 2793 2794 if eTarget is None: 2795 self.recordTagStep(tag, value) 2796 elif eTarget == "decisionPart": 2797 self.recordTagDecision(tag, value) 2798 elif eTarget == "transitionPart": 2799 self.recordTagTranstion(tag, value) 2800 elif eTarget == "reciprocalPart": 2801 self.recordTagReciprocal(tag, value) 2802 elif eTarget == "bothPart": 2803 self.recordTagTranstion(tag, value) 2804 self.recordTagReciprocal(tag, value) 2805 elif eTarget == "zonePart": 2806 self.recordTagZone(0, tag, value) 2807 elif ( 2808 isinstance(eTarget, tuple) 2809 and len(eTarget) == 2 2810 and eTarget[0] == "zonePart" 2811 and isinstance(eTarget[1], int) 2812 ): 2813 self.recordTagZone(eTarget[1] - 1, tag, value) 2814 else: 2815 raise JournalParseError( 2816 f"Invalid tag target type {eTarget!r}." 2817 ) 2818 2819 elif eType == 'annotate': 2820 self.checkFormat( 2821 "annotate", 2822 dType, 2823 eTarget, 2824 eParts, 2825 ( 2826 None, 2827 'decisionPart', 2828 'transitionPart', 2829 'reciprocalPart', 2830 'bothPart' 2831 ), 2832 None 2833 ) 2834 if len(eParts) == 0: 2835 raise JournalParseError( 2836 "annotation may not be empty." 2837 ) 2838 combined = ' '.join(eParts) 2839 if eTarget is None: 2840 self.recordAnnotateStep(combined) 2841 elif eTarget == "decisionPart": 2842 self.recordAnnotateDecision(combined) 2843 elif eTarget == "transitionPart": 2844 self.recordAnnotateTranstion(combined) 2845 elif eTarget == "reciprocalPart": 2846 self.recordAnnotateReciprocal(combined) 2847 elif eTarget == "bothPart": 2848 self.recordAnnotateTranstion(combined) 2849 self.recordAnnotateReciprocal(combined) 2850 elif eTarget == "zonePart": 2851 self.recordAnnotateZone(0, combined) 2852 elif ( 2853 isinstance(eTarget, tuple) 2854 and len(eTarget) == 2 2855 and eTarget[0] == "zonePart" 2856 and isinstance(eTarget[1], int) 2857 ): 2858 self.recordAnnotateZone(eTarget[1] - 1, combined) 2859 else: 2860 raise JournalParseError( 2861 f"Invalid annotation target type {eTarget!r}." 2862 ) 2863 2864 elif eType == 'context': 2865 self.checkFormat( 2866 "context", 2867 dType, 2868 eTarget, 2869 eParts, 2870 None, 2871 1 2872 ) 2873 if eParts[0] == pf.markerFor('commonContext'): 2874 self.recordContextSwap(None) 2875 else: 2876 self.recordContextSwap(eParts[0]) 2877 2878 elif eType == 'domain': 2879 self.checkFormat( 2880 "domain", 2881 dType, 2882 eTarget, 2883 eParts, 2884 None, 2885 {1, 2, 3} 2886 ) 2887 inCommon = False 2888 if eParts[-1] == pf.markerFor('commonContext'): 2889 eParts = eParts[:-1] 2890 inCommon = True 2891 if len(eParts) == 3: 2892 raise JournalParseError( 2893 f"A domain entry may only have 1 or 2" 2894 f" arguments unless the last argument is" 2895 f" {repr(pf.markerFor('commonContext'))}" 2896 ) 2897 elif len(eParts) == 2: 2898 if eParts[0] == pf.markerFor('exclusiveDomain'): 2899 self.recordDomainFocus( 2900 eParts[1], 2901 exclusive=True, 2902 inCommon=inCommon 2903 ) 2904 elif eParts[0] == pf.markerFor('notApplicable'): 2905 # Deactivate the domain 2906 self.recordDomainUnfocus( 2907 eParts[1], 2908 inCommon=inCommon 2909 ) 2910 else: 2911 # Set up new domain w/ given focalization 2912 focalization = pf.parseFocalization(eParts[1]) 2913 self.recordNewDomain( 2914 eParts[0], 2915 focalization, 2916 inCommon=inCommon 2917 ) 2918 else: 2919 # Focus the domain (or possibly create it) 2920 self.recordDomainFocus( 2921 eParts[0], 2922 inCommon=inCommon 2923 ) 2924 2925 elif eType == 'focus': 2926 self.checkFormat( 2927 "focus", 2928 dType, 2929 eTarget, 2930 eParts, 2931 None, 2932 {1, 2} 2933 ) 2934 if len(eParts) == 2: # explicit domain 2935 self.recordFocusOn(eParts[1], eParts[0]) 2936 else: # implicit domain 2937 self.recordFocusOn(eParts[0]) 2938 2939 elif eType == 'zone': 2940 self.checkFormat( 2941 "zone", 2942 dType, 2943 eTarget, 2944 eParts, 2945 (None, 'zonePart'), 2946 1 2947 ) 2948 if eTarget is None: 2949 level = 0 2950 elif eTarget == 'zonePart': 2951 level = 1 2952 else: 2953 assert isinstance(eTarget, tuple) 2954 assert len(eTarget) == 2 2955 level = eTarget[1] 2956 self.recordZone(level, eParts[0]) 2957 2958 elif eType == 'unify': 2959 self.checkFormat( 2960 "unify", 2961 dType, 2962 eTarget, 2963 eParts, 2964 (None, 'transitionPart', 'reciprocalPart'), 2965 (1, 2) 2966 ) 2967 if eTarget is None: 2968 decisions = [ 2969 pf.parseDecisionSpecifier(p) 2970 for p in eParts 2971 ] 2972 self.recordUnify(*decisions) 2973 elif eTarget == 'transitionPart': 2974 if len(eParts) != 1: 2975 raise JournalParseError( 2976 "A transition unification entry may only" 2977 f" have one argument, but we got" 2978 f" {len(eParts)}." 2979 ) 2980 self.recordUnifyTransition(eParts[0]) 2981 elif eTarget == 'reciprocalPart': 2982 if len(eParts) != 1: 2983 raise JournalParseError( 2984 "A transition unification entry may only" 2985 f" have one argument, but we got" 2986 f" {len(eParts)}." 2987 ) 2988 self.recordUnifyReciprocal(eParts[0]) 2989 else: 2990 raise RuntimeError( 2991 f"Invalid target type {eTarget} after check" 2992 f" for unify entry!" 2993 ) 2994 2995 elif eType == 'obviate': 2996 self.checkFormat( 2997 "obviate", 2998 dType, 2999 eTarget, 3000 eParts, 3001 None, 3002 3 3003 ) 3004 transition, targetDecision, targetTransition = eParts 3005 self.recordObviate( 3006 transition, 3007 pf.parseDecisionSpecifier(targetDecision), 3008 targetTransition 3009 ) 3010 3011 elif eType == 'extinguish': 3012 self.checkFormat( 3013 "extinguish", 3014 dType, 3015 eTarget, 3016 eParts, 3017 ( 3018 None, 3019 'decisionPart', 3020 'transitionPart', 3021 'reciprocalPart', 3022 'bothPart' 3023 ), 3024 1 3025 ) 3026 if eTarget is None: 3027 eTarget = 'bothPart' 3028 if eTarget == 'decisionPart': 3029 self.recordExtinguishDecision( 3030 pf.parseDecisionSpecifier(eParts[0]) 3031 ) 3032 elif eTarget == 'transitionPart': 3033 transition = eParts[0] 3034 here = self.definiteDecisionTarget() 3035 self.recordExtinguishTransition( 3036 here, 3037 transition, 3038 False 3039 ) 3040 elif eTarget == 'bothPart': 3041 transition = eParts[0] 3042 here = self.definiteDecisionTarget() 3043 self.recordExtinguishTransition( 3044 here, 3045 transition, 3046 True 3047 ) 3048 else: # Must be reciprocalPart 3049 transition = eParts[0] 3050 here = self.definiteDecisionTarget() 3051 now = self.exploration.getSituation() 3052 rPair = now.graph.getReciprocalPair(here, transition) 3053 if rPair is None: 3054 raise JournalParseError( 3055 f"Attempted to extinguish the" 3056 f" reciprocal of transition" 3057 f" {transition!r} which " 3058 f" has no reciprocal (or which" 3059 f" doesn't exist from decision" 3060 f" {now.graph.identityOf(here)})." 3061 ) 3062 3063 self.recordExtinguishTransition( 3064 rPair[0], 3065 rPair[1], 3066 deleteReciprocal=False 3067 ) 3068 3069 elif eType == 'complicate': 3070 self.checkFormat( 3071 "complicate", 3072 dType, 3073 eTarget, 3074 eParts, 3075 None, 3076 4 3077 ) 3078 target, newName, newReciprocal, newRR = eParts 3079 self.recordComplicate( 3080 target, 3081 newName, 3082 newReciprocal, 3083 newRR 3084 ) 3085 3086 elif eType == 'status': 3087 self.checkFormat( 3088 "status", 3089 dType, 3090 eTarget, 3091 eParts, 3092 (None, 'unfinishedPart'), 3093 {0, 1} 3094 ) 3095 dID = self.definiteDecisionTarget() 3096 # Default status to use 3097 status: base.ExplorationStatus = 'explored' 3098 # Figure out whether a valid status was provided 3099 if len(eParts) > 0: 3100 assert len(eParts) == 1 3101 eArgs = get_args(base.ExplorationStatus) 3102 if eParts[0] not in eArgs: 3103 raise JournalParseError( 3104 f"Invalid explicit exploration status" 3105 f" {eParts[0]!r}. Exploration statuses" 3106 f" must be one of:\n{eArgs!r}" 3107 ) 3108 status = cast(base.ExplorationStatus, eParts[0]) 3109 # Record new status, as long as we have an explicit 3110 # status OR 'unfinishedPart' was not given. If 3111 # 'unfinishedPart' was given, also block auto updates 3112 if eTarget == 'unfinishedPart': 3113 if len(eParts) > 0: 3114 self.recordStatus(dID, status) 3115 self.recordObservationIncomplete(dID) 3116 else: 3117 self.recordStatus(dID, status) 3118 3119 elif eType == 'revert': 3120 self.checkFormat( 3121 "revert", 3122 dType, 3123 eTarget, 3124 eParts, 3125 None, 3126 None 3127 ) 3128 aspects: List[str] 3129 if len(eParts) == 0: 3130 slot = base.DEFAULT_SAVE_SLOT 3131 aspects = [] 3132 else: 3133 slot = eParts[0] 3134 aspects = eParts[1:] 3135 aspectsSet = set(aspects) 3136 if len(aspectsSet) == 0: 3137 aspectsSet = self.preferences['revertAspects'] 3138 self.recordRevert(slot, aspectsSet, decisionType=dType) 3139 3140 elif eType == 'fulfills': 3141 self.checkFormat( 3142 "fulfills", 3143 dType, 3144 eTarget, 3145 eParts, 3146 None, 3147 2 3148 ) 3149 condition = pf.parseRequirement(eParts[0]) 3150 fReq = pf.parseRequirement(eParts[1]) 3151 fulfills: Union[ 3152 base.Capability, 3153 Tuple[base.MechanismID, base.MechanismState] 3154 ] 3155 if isinstance(fReq, base.ReqCapability): 3156 fulfills = fReq.capability 3157 elif isinstance(fReq, base.ReqMechanism): 3158 mState = fReq.reqState 3159 if isinstance(fReq.mechanism, int): 3160 mID = fReq.mechanism 3161 else: 3162 graph = self.exploration.getSituation().graph 3163 mID = graph.resolveMechanism( 3164 fReq.mechanism, 3165 {self.definiteDecisionTarget()} 3166 ) 3167 fulfills = (mID, mState) 3168 else: 3169 raise JournalParseError( 3170 f"Cannot fulfill {eParts[1]!r} because it" 3171 f" doesn't specify either a capability or a" 3172 f" mechanism/state pair." 3173 ) 3174 self.recordFulfills(condition, fulfills) 3175 3176 elif eType == 'relative': 3177 self.checkFormat( 3178 "relative", 3179 dType, 3180 eTarget, 3181 eParts, 3182 (None, 'transitionPart'), 3183 (0, 1, 2) 3184 ) 3185 if ( 3186 len(eParts) == 1 3187 and eParts[0] == self.parseFormat.markerFor( 3188 'relative' 3189 ) 3190 ): 3191 self.relative() 3192 elif eTarget == 'transitionPart': 3193 self.relative(None, *eParts) 3194 else: 3195 self.relative(*eParts) 3196 3197 else: 3198 raise NotImplementedError( 3199 f"Unrecognized event type {eType!r}." 3200 ) 3201 except Exception as e: 3202 raise LocatedJournalParseError( 3203 journalText, 3204 self.parseIndices[-1], 3205 e 3206 ) 3207 finally: 3208 self.journalTexts.pop() 3209 self.parseIndices.pop() 3210 3211 def defineAlias( 3212 self, 3213 name: str, 3214 parameters: Sequence[str], 3215 commands: str 3216 ) -> None: 3217 """ 3218 Defines an alias: a block of commands that can be played back 3219 later using the 'custom' command, with parameter substitutions. 3220 3221 If an alias with the specified name already existed, it will be 3222 replaced. 3223 3224 Each of the listed parameters must be supplied when invoking the 3225 alias, and where they appear within curly braces in the commands 3226 string, they will be substituted in. Additional names starting 3227 with '_' plus an optional integer will also be substituted with 3228 unique names (see `nextUniqueName`), with the same name being 3229 used for every instance that shares the same numerical suffix 3230 within each application of the command. Substitution points must 3231 not include spaces; if an open curly brace is followed by 3232 whitesapce or where a close curly brace is proceeded by 3233 whitespace, those will be treated as normal curly braces and will 3234 not create a substitution point. 3235 3236 For example: 3237 3238 >>> o = JournalObserver() 3239 >>> o.defineAlias( 3240 ... 'hintRoom', 3241 ... ['name'], 3242 ... 'o {_5}\\nx {_5} {name} {_5}\\ngd hint\\nt {_5}' 3243 ... ) # _5 to show that the suffix doesn't matter if it's consistent 3244 >>> o.defineAlias( 3245 ... 'trade', 3246 ... ['gain', 'lose'], 3247 ... 'A { gain {gain}; lose {lose} }' 3248 ... ) # note outer curly braces 3249 >>> o.recordStart('start') 3250 >>> o.deployAlias('hintRoom', ['hint1']) 3251 >>> o.deployAlias('hintRoom', ['hint2']) 3252 >>> o.deployAlias('trade', ['flower*1', 'coin*1']) 3253 >>> e = o.getExploration() 3254 >>> e.movementAtStep(0) 3255 (None, None, 0) 3256 >>> e.movementAtStep(1) 3257 (0, '_0', 1) 3258 >>> e.movementAtStep(2) 3259 (1, '_0', 0) 3260 >>> e.movementAtStep(3) 3261 (0, '_1', 2) 3262 >>> e.movementAtStep(4) 3263 (2, '_1', 0) 3264 >>> g = e.getSituation().graph 3265 >>> len(g) 3266 3 3267 >>> g.namesListing([0, 1, 2]) 3268 ' 0 (start)\\n 1 (hint1)\\n 2 (hint2)\\n' 3269 >>> g.decisionTags('hint1') 3270 {'hint': 1} 3271 >>> g.decisionTags('hint2') 3272 {'hint': 1} 3273 >>> e.tokenCountNow('coin') 3274 -1 3275 >>> e.tokenCountNow('flower') 3276 1 3277 """ 3278 # Going to be formatted twice so {{{{ -> {{ -> { 3279 # TODO: Move this logic into deployAlias 3280 commands = re.sub(r'{(\s)', r'{{{{\1', commands) 3281 commands = re.sub(r'(\s)}', r'\1}}}}', commands) 3282 self.aliases[name] = (list(parameters), commands) 3283 3284 def deployAlias(self, name: str, arguments: Sequence[str]) -> None: 3285 """ 3286 Deploys an alias, taking its command string and substituting in 3287 the provided argument values for each of the alias' parameters, 3288 plus any unique names that it requests. Substitution happens 3289 first for named arguments and then for unique strings, so named 3290 arguments of the form '{_-n-}' where -n- is an integer will end 3291 up being substituted for unique names. Sets of curly braces that 3292 have at least one space immediately after the open brace or 3293 immediately before the closing brace will be interpreted as 3294 normal curly braces, NOT as the start/end of a substitution 3295 point. 3296 3297 There are a few automatic arguments (although these can be 3298 overridden if the alias definition uses the same argument name 3299 explicitly): 3300 - '__here__' will substitute to the ID of the current decision 3301 based on the `ObservationContext`, or will generate an error 3302 if there is none. This is the current decision at the moment 3303 the alias is deployed, NOT based on steps within the alias up 3304 to the substitution point. 3305 - '__hereName__' will substitute the name of the current 3306 decision. 3307 - '__zone__' will substitute the name of the alphabetically 3308 first level-0 zone ancestor of the current decision. 3309 - '__region__' will substitute the name of the alphabetically 3310 first level-1 zone ancestor of the current decision. 3311 - '__transition__' will substitute to the name of the current 3312 transition, or will generate an error if there is none. Note 3313 that the current transition is sometimes NOT a valid 3314 transition from the current decision, because when you take 3315 a transition, that transition's name is current but the 3316 current decision is its destination. 3317 - '__reciprocal__' will substitute to the name of the reciprocal 3318 of the current transition. 3319 - '__trBase__' will substitute to the decision from which the 3320 current transition departs. 3321 - '__trDest__' will substitute to the destination of the current 3322 transition. 3323 - '__prev__' will substitute to the ID of the primary decision in 3324 the previous exploration step, (which is NOT always the 3325 previous current decision of the `ObservationContext`, 3326 especially in relative mode). 3327 - '__across__-name-__' where '-name-' is a transition name will 3328 substitute to the decision reached by traversing that 3329 transition from the '__here__' decision. Note that the 3330 transition name used must be a valid Python identifier. 3331 3332 Raises a `JournalParseError` if the specified alias does not 3333 exist, or if the wrong number of parameters has been supplied. 3334 3335 See `defineAlias` for an example. 3336 """ 3337 # Fetch the alias 3338 alias = self.aliases.get(name) 3339 if alias is None: 3340 raise JournalParseError( 3341 f"Alias {name!r} has not been defined yet." 3342 ) 3343 paramNames, commands = alias 3344 3345 # Check arguments 3346 arguments = list(arguments) 3347 if len(arguments) != len(paramNames): 3348 raise JournalParseError( 3349 f"Alias {name!r} requires {len(paramNames)} parameters," 3350 f" but you supplied {len(arguments)}." 3351 ) 3352 3353 # Find unique names 3354 uniques = set([ 3355 match.strip('{}') 3356 for match in re.findall('{_[0-9]*}', commands) 3357 ]) 3358 3359 # Build substitution dictionary that passes through uniques 3360 firstWave = {unique: '{' + unique + '}' for unique in uniques} 3361 3362 # Fill in each non-overridden & requested auto variable: 3363 graph = self.exploration.getSituation().graph 3364 if '{__here__}' in commands and '__here__' not in firstWave: 3365 firstWave['__here__'] = self.definiteDecisionTarget() 3366 if '{__hereName__}' in commands and '__hereName__' not in firstWave: 3367 firstWave['__hereName__'] = graph.nameFor( 3368 self.definiteDecisionTarget() 3369 ) 3370 if '{__zone__}' in commands and '__zone__' not in firstWave: 3371 baseDecision = self.definiteDecisionTarget() 3372 parents = sorted( 3373 ancestor 3374 for ancestor in graph.zoneAncestors(baseDecision) 3375 if graph.zoneHierarchyLevel(ancestor) == 0 3376 ) 3377 if len(parents) == 0: 3378 raise JournalParseError( 3379 f"Used __zone__ in a macro, but the current" 3380 f" decision {graph.identityOf(baseDecision)} is not" 3381 f" in any level-0 zones." 3382 ) 3383 firstWave['__zone__'] = parents[0] 3384 if '{__region__}' in commands and '__region__' not in firstWave: 3385 baseDecision = self.definiteDecisionTarget() 3386 grandparents = sorted( 3387 ancestor 3388 for ancestor in graph.zoneAncestors(baseDecision) 3389 if graph.zoneHierarchyLevel(ancestor) == 1 3390 ) 3391 if len(grandparents) == 0: 3392 raise JournalParseError( 3393 f"Used __region__ in a macro, but the current" 3394 f" decision {graph.identityOf(baseDecision)} is not" 3395 f" in any level-1 zones." 3396 ) 3397 firstWave['__region__'] = grandparents[0] 3398 if ( 3399 '{__transition__}' in commands 3400 and '__transition__' not in firstWave 3401 ): 3402 ctxTr = self.currentTransitionTarget() 3403 if ctxTr is None: 3404 raise JournalParseError( 3405 f"Can't deploy alias {name!r} because it has a" 3406 f" __transition__ auto-slot but there is no current" 3407 f" transition at the current exploration step." 3408 ) 3409 firstWave['__transition__'] = ctxTr[1] 3410 if '{__trBase__}' in commands and '__trBase__' not in firstWave: 3411 ctxTr = self.currentTransitionTarget() 3412 if ctxTr is None: 3413 raise JournalParseError( 3414 f"Can't deploy alias {name!r} because it has a" 3415 f" __transition__ auto-slot but there is no current" 3416 f" transition at the current exploration step." 3417 ) 3418 firstWave['__trBase__'] = ctxTr[0] 3419 if '{__trDest__}' in commands and '__trDest__' not in firstWave: 3420 ctxTr = self.currentTransitionTarget() 3421 if ctxTr is None: 3422 raise JournalParseError( 3423 f"Can't deploy alias {name!r} because it has a" 3424 f" __transition__ auto-slot but there is no current" 3425 f" transition at the current exploration step." 3426 ) 3427 firstWave['__trDest__'] = graph.getDestination(*ctxTr) 3428 if ( 3429 '{__reciprocal__}' in commands 3430 and '__reciprocal__' not in firstWave 3431 ): 3432 ctxTr = self.currentTransitionTarget() 3433 if ctxTr is None: 3434 raise JournalParseError( 3435 f"Can't deploy alias {name!r} because it has a" 3436 f" __transition__ auto-slot but there is no current" 3437 f" transition at the current exploration step." 3438 ) 3439 firstWave['__reciprocal__'] = graph.getReciprocal(*ctxTr) 3440 if '{__prev__}' in commands and '__prev__' not in firstWave: 3441 try: 3442 prevPrimary = self.exploration.primaryDecision(-2) 3443 except IndexError: 3444 raise JournalParseError( 3445 f"Can't deploy alias {name!r} because it has a" 3446 f" __prev__ auto-slot but there is no previous" 3447 f" exploration step." 3448 ) 3449 if prevPrimary is None: 3450 raise JournalParseError( 3451 f"Can't deploy alias {name!r} because it has a" 3452 f" __prev__ auto-slot but there is no primary" 3453 f" decision for the previous exploration step." 3454 ) 3455 firstWave['__prev__'] = prevPrimary 3456 3457 here = self.currentDecisionTarget() 3458 for match in re.findall(r'{__across__[^ ]\+__}', commands): 3459 if here is None: 3460 raise JournalParseError( 3461 f"Can't deploy alias {name!r} because it has an" 3462 f" __across__ auto-slot but there is no current" 3463 f" decision." 3464 ) 3465 transition = match[11:-3] 3466 dest = graph.getDestination(here, transition) 3467 firstWave[f'__across__{transition}__'] = dest 3468 firstWave.update({ 3469 param: value 3470 for (param, value) in zip(paramNames, arguments) 3471 }) 3472 3473 # Substitute parameter values 3474 commands = commands.format(**firstWave) 3475 3476 uniques = set([ 3477 match.strip('{}') 3478 for match in re.findall('{_[0-9]*}', commands) 3479 ]) 3480 3481 # Substitute for remaining unique names 3482 uniqueValues = { 3483 unique: self.nextUniqueName() 3484 for unique in sorted(uniques) # sort for stability 3485 } 3486 commands = commands.format(**uniqueValues) 3487 3488 # Now run the commands 3489 self.observe(commands) 3490 3491 def doDebug(self, action: DebugAction, arg: str = "") -> None: 3492 """ 3493 Prints out a debugging message to stderr. Useful for figuring 3494 out parsing errors. See also `DebugAction` and 3495 `JournalEntryType. Certain actions allow an extra argument. The 3496 action will be one of: 3497 - 'here': prints the ID and name of the current decision, or 3498 `None` if there isn't one. 3499 - 'transition': prints the name of the current transition, or `None` 3500 if there isn't one. 3501 - 'destinations': prints the ID and name of the current decision, 3502 followed by the names of each outgoing transition and their 3503 destinations. Includes any requirements the transitions have. 3504 If an extra argument is supplied, looks up that decision and 3505 prints destinations from there. 3506 - 'steps': prints out the number of steps in the current exploration, 3507 plus the number since the most recent use of 'steps'. 3508 - 'decisions': prints out the number of decisions in the current 3509 graph, plus the number added/removed since the most recent use of 3510 'decisions'. 3511 - 'active': prints out the names listing of all currently active 3512 decisions. 3513 - 'primary': prints out the identity of the current primary 3514 decision, or None if there is none. 3515 - 'saved': prints out the primary decision for the state saved in 3516 the default save slot, or for a specific save slot if a 3517 second argument is given. 3518 - 'inventory': Displays all current capabilities, tokens, and 3519 skills. 3520 - 'mechanisms': Displays all current mechanisms and their states. 3521 - 'equivalences': Displays all current equivalences, along with 3522 whether or not they're active. 3523 """ 3524 graph = self.exploration.getSituation().graph 3525 if arg != '' and action not in ('destinations', 'saved'): 3526 raise JournalParseError( 3527 f"Invalid debug command {action!r} with arg {arg!r}:" 3528 f" Only 'destination' and 'saved' actions may include a" 3529 f" second argument." 3530 ) 3531 if action == "here": 3532 dt = self.currentDecisionTarget() 3533 print( 3534 f"Current decision is: {graph.identityOf(dt)}", 3535 file=sys.stderr 3536 ) 3537 elif action == "transition": 3538 tTarget = self.currentTransitionTarget() 3539 if tTarget is None: 3540 print("Current transition is: None", file=sys.stderr) 3541 else: 3542 tDecision, tTransition = tTarget 3543 print( 3544 ( 3545 f"Current transition is {tTransition!r} from" 3546 f" {graph.identityOf(tDecision)}." 3547 ), 3548 file=sys.stderr 3549 ) 3550 elif action == "destinations": 3551 if arg == "": 3552 here = self.currentDecisionTarget() 3553 adjective = "current" 3554 if here is None: 3555 print("There is no current decision.", file=sys.stderr) 3556 else: 3557 adjective = "target" 3558 dHint = None 3559 zHint = None 3560 tSpec = self.decisionTargetSpecifier() 3561 if tSpec is not None: 3562 dHint = tSpec.domain 3563 zHint = tSpec.zone 3564 here = self.exploration.getSituation().graph.getDecision( 3565 self.parseFormat.parseDecisionSpecifier(arg), 3566 zoneHint=zHint, 3567 domainHint=dHint, 3568 ) 3569 if here is None: 3570 print("Decision {arg!r} was not found.", file=sys.stderr) 3571 3572 if here is not None: 3573 dests = graph.destinationsFrom(here) 3574 outgoing = { 3575 route: dests[route] 3576 for route in dests 3577 if dests[route] != here 3578 } 3579 actions = { 3580 route: dests[route] 3581 for route in dests 3582 if dests[route] == here 3583 } 3584 print( 3585 f"The {adjective} decision is: {graph.identityOf(here)}", 3586 file=sys.stderr 3587 ) 3588 if len(outgoing) == 0: 3589 print( 3590 ( 3591 "There are no outgoing transitions at this" 3592 " decision." 3593 ), 3594 file=sys.stderr 3595 ) 3596 else: 3597 print( 3598 ( 3599 f"There are {len(outgoing)} outgoing" 3600 f" transition(s):" 3601 ), 3602 file=sys.stderr 3603 ) 3604 for transition in outgoing: 3605 destination = outgoing[transition] 3606 req = graph.getTransitionRequirement( 3607 here, 3608 transition 3609 ) 3610 rstring = '' 3611 if req != base.ReqNothing(): 3612 rstring = f" (requires {req})" 3613 print( 3614 ( 3615 f" {transition!r} ->" 3616 f" {graph.identityOf(destination)}{rstring}" 3617 ), 3618 file=sys.stderr 3619 ) 3620 3621 if len(actions) > 0: 3622 print( 3623 f"There are {len(actions)} actions:", 3624 file=sys.stderr 3625 ) 3626 for oneAction in actions: 3627 req = graph.getTransitionRequirement( 3628 here, 3629 oneAction 3630 ) 3631 rstring = '' 3632 if req != base.ReqNothing(): 3633 rstring = f" (requires {req})" 3634 print( 3635 f" {oneAction!r}{rstring}", 3636 file=sys.stderr 3637 ) 3638 3639 elif action == "steps": 3640 steps = len(self.getExploration()) 3641 if self.prevSteps is not None: 3642 elapsed = steps - cast(int, self.prevSteps) 3643 print( 3644 ( 3645 f"There are {steps} steps in the current" 3646 f" exploration (which is {elapsed} more than" 3647 f" there were at the previous check)." 3648 ), 3649 file=sys.stderr 3650 ) 3651 else: 3652 print( 3653 ( 3654 f"There are {steps} steps in the current" 3655 f" exploration." 3656 ), 3657 file=sys.stderr 3658 ) 3659 self.prevSteps = steps 3660 3661 elif action == "decisions": 3662 count = len(self.getExploration().getSituation().graph) 3663 if self.prevDecisions is not None: 3664 elapsed = count - self.prevDecisions 3665 print( 3666 ( 3667 f"There are {count} decisions in the current" 3668 f" graph (which is {elapsed} more than there" 3669 f" were at the previous check)." 3670 ), 3671 file=sys.stderr 3672 ) 3673 else: 3674 print( 3675 ( 3676 f"There are {count} decisions in the current" 3677 f" graph." 3678 ), 3679 file=sys.stderr 3680 ) 3681 self.prevDecisions = count 3682 elif action == "active": 3683 active = self.exploration.getActiveDecisions() 3684 now = self.exploration.getSituation() 3685 print( 3686 "Active decisions:\n", 3687 now.graph.namesListing(active), 3688 file=sys.stderr 3689 ) 3690 elif action == "primary": 3691 e = self.exploration 3692 primary = e.primaryDecision() 3693 if primary is None: 3694 pr = "None" 3695 else: 3696 pr = e.getSituation().graph.identityOf(primary) 3697 print(f"Primary decision: {pr}", file=sys.stderr) 3698 elif action == "saved": 3699 now = self.exploration.getSituation() 3700 slot = base.DEFAULT_SAVE_SLOT 3701 if arg != "": 3702 slot = arg 3703 saved = now.saves.get(slot) 3704 if saved is None: 3705 print(f"Slot {slot!r} has no saved data.", file=sys.stderr) 3706 else: 3707 savedGraph, savedState = saved 3708 savedPrimary = savedGraph.identityOf( 3709 savedState['primaryDecision'] 3710 ) 3711 print(f"Saved at decision: {savedPrimary}", file=sys.stderr) 3712 elif action == "inventory": 3713 now = self.exploration.getSituation() 3714 commonCap = now.state['common']['capabilities'] 3715 activeCap = now.state['contexts'][now.state['activeContext']][ 3716 'capabilities' 3717 ] 3718 merged = base.mergeCapabilitySets(commonCap, activeCap) 3719 capCount = len(merged['capabilities']) 3720 tokCount = len(merged['tokens']) 3721 skillCount = len(merged['skills']) 3722 print( 3723 ( 3724 f"{capCount} capability/ies, {tokCount} token type(s)," 3725 f" and {skillCount} skill(s)" 3726 ), 3727 file=sys.stderr 3728 ) 3729 if capCount > 0: 3730 print("Capabilities (alphabetical order):", file=sys.stderr) 3731 for cap in sorted(merged['capabilities']): 3732 print(f" {cap!r}", file=sys.stderr) 3733 if tokCount > 0: 3734 print("Tokens (alphabetical order):", file=sys.stderr) 3735 for tok in sorted(merged['tokens']): 3736 print( 3737 f" {tok!r}: {merged['tokens'][tok]}", 3738 file=sys.stderr 3739 ) 3740 if skillCount > 0: 3741 print("Skill levels (alphabetical order):", file=sys.stderr) 3742 for skill in sorted(merged['skills']): 3743 print( 3744 f" {skill!r}: {merged['skills'][skill]}", 3745 file=sys.stderr 3746 ) 3747 elif action == "mechanisms": 3748 now = self.exploration.getSituation() 3749 grpah = now.graph 3750 mechs = now.state['mechanisms'] 3751 inGraph = set(graph.mechanisms) - set(mechs) 3752 print( 3753 ( 3754 f"{len(mechs)} mechanism(s) in known states;" 3755 f" {len(inGraph)} additional mechanism(s) in the" 3756 f" default state" 3757 ), 3758 file=sys.stderr 3759 ) 3760 if len(mechs) > 0: 3761 print("Mechanism(s) in known state(s):", file=sys.stderr) 3762 for mID in sorted(mechs): 3763 mState = mechs[mID] 3764 whereID, mName = graph.mechanisms[mID] 3765 if whereID is None: 3766 whereStr = " (global)" 3767 else: 3768 domain = graph.domainFor(whereID) 3769 whereStr = f" at {graph.identityOf(whereID)}" 3770 print( 3771 f" {mName}:{mState!r} - {mID}{whereStr}", 3772 file=sys.stderr 3773 ) 3774 if len(inGraph) > 0: 3775 print("Mechanism(s) in the default state:", file=sys.stderr) 3776 for mID in sorted(inGraph): 3777 whereID, mName = graph.mechanisms[mID] 3778 if whereID is None: 3779 whereStr = " (global)" 3780 else: 3781 domain = graph.domainFor(whereID) 3782 whereStr = f" at {graph.identityOf(whereID)}" 3783 print(f" {mID} - {mName}){whereStr}", file=sys.stderr) 3784 elif action == "equivalences": 3785 now = self.exploration.getSituation() 3786 eqDict = now.graph.equivalences 3787 if len(eqDict) > 0: 3788 print(f"{len(eqDict)} equivalences:", file=sys.stderr) 3789 for hasEq in eqDict: 3790 if isinstance(hasEq, tuple): 3791 assert len(hasEq) == 2 3792 assert isinstance(hasEq[0], base.MechanismID) 3793 assert isinstance(hasEq[1], base.MechanismState) 3794 mID, mState = hasEq 3795 mDetails = now.graph.mechanismDetails(mID) 3796 assert mDetails is not None 3797 mWhere, mName = mDetails 3798 if mWhere is None: 3799 whereStr = " (global)" 3800 else: 3801 whereStr = f" at {graph.identityOf(mWhere)}" 3802 eqStr = f"{mName}:{mState!r} - {mID}{whereStr}" 3803 else: 3804 assert isinstance(hasEq, base.Capability) 3805 eqStr = hasEq 3806 eqSet = eqDict[hasEq] 3807 print( 3808 f" {eqStr} has {len(eqSet)} equivalence(s):", 3809 file=sys.stderr 3810 ) 3811 for eqReq in eqDict[hasEq]: 3812 print(f" {eqReq}", file=sys.stderr) 3813 else: 3814 print( 3815 "There are no equivalences right now.", 3816 file=sys.stderr 3817 ) 3818 else: 3819 raise JournalParseError( 3820 f"Invalid debug command: {action!r}" 3821 ) 3822 3823 def recordStart( 3824 self, 3825 where: Union[base.DecisionName, base.DecisionSpecifier], 3826 decisionType: base.DecisionType = 'imposed' 3827 ) -> None: 3828 """ 3829 Records the start of the exploration. Use only once in each new 3830 domain, as the very first action in that domain (possibly after 3831 some zone declarations). The contextual domain is used if the 3832 given `base.DecisionSpecifier` doesn't include a domain. 3833 3834 To create new decision points that are disconnected from the rest 3835 of the graph that aren't the first in their domain, use the 3836 `relative` method followed by `recordWarp`. 3837 3838 The default 'imposed' decision type can be overridden for the 3839 action that this generates. 3840 """ 3841 if self.inRelativeMode: 3842 raise JournalParseError( 3843 "Can't start the exploration in relative mode." 3844 ) 3845 3846 whereSpec: Union[base.DecisionID, base.DecisionSpecifier] 3847 if isinstance(where, base.DecisionName): 3848 whereSpec = self.parseFormat.parseDecisionSpecifier(where) 3849 if isinstance(whereSpec, base.DecisionID): 3850 raise JournalParseError( 3851 f"Can't use a number for a decision name. Got:" 3852 f" {where!r}" 3853 ) 3854 else: 3855 whereSpec = where 3856 3857 if whereSpec.domain is None: 3858 whereSpec = base.DecisionSpecifier( 3859 domain=self.context['domain'], 3860 zone=whereSpec.zone, 3861 name=whereSpec.name 3862 ) 3863 self.context['decision'] = self.exploration.start( 3864 whereSpec, 3865 decisionType=decisionType 3866 ) 3867 3868 def recordObserveAction(self, name: base.Transition) -> None: 3869 """ 3870 Records the observation of an action at the current decision, 3871 which has the given name. 3872 """ 3873 here = self.definiteDecisionTarget() 3874 self.exploration.getSituation().graph.addAction(here, name) 3875 self.context['transition'] = (here, name) 3876 3877 def recordObserve( 3878 self, 3879 name: base.Transition, 3880 destination: Optional[base.AnyDecisionSpecifier] = None, 3881 reciprocal: Optional[base.Transition] = None 3882 ) -> None: 3883 """ 3884 Records the observation of a new option at the current decision. 3885 3886 If two or three arguments are given, the destination is still 3887 marked as unexplored, but is given a name (with two arguments) 3888 and the reciprocal transition is named (with three arguments). 3889 3890 When a name or decision specifier is used for the destination, 3891 the domain and/or level-0 zone of the current decision are 3892 filled in if the specifier is a name or doesn't have domain 3893 and/or zone info. The first alphabetical level-0 zone is used if 3894 the current decision is in more than one. 3895 """ 3896 here = self.definiteDecisionTarget() 3897 3898 # Our observation matches `DiscreteExploration.observe` args 3899 obs: Union[ 3900 Tuple[base.Transition], 3901 Tuple[base.Transition, base.AnyDecisionSpecifier], 3902 Tuple[ 3903 base.Transition, 3904 base.AnyDecisionSpecifier, 3905 base.Transition 3906 ] 3907 ] 3908 3909 # If we have a destination, parse it as a decision specifier 3910 # (might be an ID) 3911 if isinstance(destination, str): 3912 destination = self.parseFormat.parseDecisionSpecifier( 3913 destination 3914 ) 3915 3916 # If we started with a name or some other kind of decision 3917 # specifier, replace missing domain and/or zone info with info 3918 # from the current decision. 3919 if isinstance(destination, base.DecisionSpecifier): 3920 destination = base.spliceDecisionSpecifiers( 3921 destination, 3922 self.decisionTargetSpecifier() 3923 ) 3924 # TODO: This is kinda janky because it only uses 1 zone, 3925 # whereas explore puts the new decision in all of them. 3926 3927 # Set up our observation argument 3928 if destination is not None: 3929 if reciprocal is not None: 3930 obs = (name, destination, reciprocal) 3931 else: 3932 obs = (name, destination) 3933 elif reciprocal is not None: 3934 # TODO: Allow this? (make the destination generic) 3935 raise JournalParseError( 3936 "You may not specify a reciprocal name without" 3937 " specifying a destination." 3938 ) 3939 else: 3940 obs = (name,) 3941 3942 self.exploration.observe(here, *obs) 3943 self.context['transition'] = (here, name) 3944 3945 def recordObservationIncomplete( 3946 self, 3947 decision: base.AnyDecisionSpecifier 3948 ): 3949 """ 3950 Marks a particular decision as being incompletely-observed. 3951 Normally whenever we leave a decision, we set its exploration 3952 status as 'explored' under the assumption that before moving on 3953 to another decision, we'll note down all of the options at this 3954 one first. Usually, to indicate further exploration 3955 possibilities in a room, you can include a transition, and you 3956 could even use `recordUnify` later to indicate that what seemed 3957 like a junction between two decisions really wasn't, and they 3958 should be merged. But in rare cases, it makes sense instead to 3959 indicate before you leave a decision that you expect to see more 3960 options there later, but you can't or won't observe them now. 3961 Once `recordObservationIncomplete` has been called, the default 3962 mechanism will never upgrade the decision to 'explored', and you 3963 will usually want to eventually call `recordStatus` to 3964 explicitly do that (which also removes it from the 3965 `dontFinalize` set that this method puts it in). 3966 3967 When called on a decision which already has exploration status 3968 'explored', this also sets the exploration status back to 3969 'exploring'. 3970 """ 3971 e = self.exploration 3972 dID = e.getSituation().graph.resolveDecision(decision) 3973 if e.getExplorationStatus(dID) == 'explored': 3974 e.setExplorationStatus(dID, 'exploring') 3975 self.dontFinalize.add(dID) 3976 3977 def recordStatus( 3978 self, 3979 decision: base.AnyDecisionSpecifier, 3980 status: base.ExplorationStatus = 'explored' 3981 ): 3982 """ 3983 Explicitly records that a particular decision has the specified 3984 exploration status (default 'explored' meaning we think we've 3985 seen everything there). This helps analysts look for unexpected 3986 connections. 3987 3988 Note that normally, exploration statuses will be updated 3989 automatically whenever a decision is first observed (status 3990 'noticed'), first visited (status 'exploring') and first left 3991 behind (status 'explored'). However, using 3992 `recordObservationIncomplete` can prevent the automatic 3993 'explored' update. 3994 3995 This method also removes a decision's `dontFinalize` entry, 3996 although it's probably no longer relevant in any case. 3997 TODO: Still this? 3998 3999 A basic example: 4000 4001 >>> obs = JournalObserver() 4002 >>> e = obs.getExploration() 4003 >>> obs.recordStart('A') 4004 >>> e.getExplorationStatus('A', 0) 4005 'unknown' 4006 >>> e.getExplorationStatus('A', 1) 4007 'exploring' 4008 >>> obs.recordStatus('A') 4009 >>> e.getExplorationStatus('A', 1) 4010 'explored' 4011 >>> obs.recordStatus('A', 'hypothesized') 4012 >>> e.getExplorationStatus('A', 1) 4013 'hypothesized' 4014 4015 An example of usage in journal format: 4016 4017 >>> obs = JournalObserver() 4018 >>> obs.observe(''' 4019 ... # step 0 4020 ... S A # step 1 4021 ... x right B left # step 2 4022 ... ... 4023 ... x right C left # step 3 4024 ... t left # back to B; step 4 4025 ... o up 4026 ... . # now we think we've found all options 4027 ... x up D down # step 5 4028 ... t down # back to B again; step 6 4029 ... x down E up # surprise extra option; step 7 4030 ... w # step 8 4031 ... . hypothesized # explicit value 4032 ... t up # auto-updates to 'explored'; step 9 4033 ... ''') 4034 >>> e = obs.getExploration() 4035 >>> len(e) 4036 10 4037 >>> e.getExplorationStatus('A', 1) 4038 'exploring' 4039 >>> e.getExplorationStatus('A', 2) 4040 'explored' 4041 >>> e.getExplorationStatus('B', 1) 4042 Traceback (most recent call last): 4043 ... 4044 exploration.core.MissingDecisionError... 4045 >>> e.getExplorationStatus(1, 1) # the unknown node is created 4046 'unknown' 4047 >>> e.getExplorationStatus('B', 2) 4048 'exploring' 4049 >>> e.getExplorationStatus('B', 3) # not 'explored' yet 4050 'exploring' 4051 >>> e.getExplorationStatus('B', 4) # now explored 4052 'explored' 4053 >>> e.getExplorationStatus('B', 6) # still explored 4054 'explored' 4055 >>> e.getExplorationStatus('E', 7) # initial 4056 'exploring' 4057 >>> e.getExplorationStatus('E', 8) # explicit 4058 'hypothesized' 4059 >>> e.getExplorationStatus('E', 9) # auto-update on leave 4060 'explored' 4061 >>> g2 = e.getSituation(2).graph 4062 >>> g4 = e.getSituation(4).graph 4063 >>> g7 = e.getSituation(7).graph 4064 >>> g2.destinationsFrom('B') 4065 {'left': 0, 'right': 2} 4066 >>> g4.destinationsFrom('B') 4067 {'left': 0, 'right': 2, 'up': 3} 4068 >>> g7.destinationsFrom('B') 4069 {'left': 0, 'right': 2, 'up': 3, 'down': 4} 4070 """ 4071 e = self.exploration 4072 dID = e.getSituation().graph.resolveDecision(decision) 4073 if dID in self.dontFinalize: 4074 self.dontFinalize.remove(dID) 4075 e.setExplorationStatus(decision, status) 4076 4077 def autoFinalizeExplorationStatuses(self): 4078 """ 4079 Looks at the set of nodes that were active in the previous 4080 exploration step but which are no longer active in this one, and 4081 sets their exploration statuses to 'explored' to indicate that 4082 we believe we've already at least observed all of their outgoing 4083 transitions. 4084 4085 Skips finalization for any decisions in our `dontFinalize` set 4086 (see `recordObservationIncomplete`). 4087 """ 4088 oldActive = self.exploration.getActiveDecisions(-2) 4089 newAcive = self.exploration.getActiveDecisions() 4090 for leftBehind in (oldActive - newAcive) - self.dontFinalize: 4091 self.exploration.setExplorationStatus( 4092 leftBehind, 4093 'explored' 4094 ) 4095 4096 def recordExplore( 4097 self, 4098 transition: base.AnyTransition, 4099 destination: Optional[base.AnyDecisionSpecifier] = None, 4100 reciprocal: Optional[base.Transition] = None, 4101 decisionType: base.DecisionType = 'active' 4102 ) -> None: 4103 """ 4104 Records the exploration of a transition which leads to a 4105 specific destination (possibly with outcomes specified for 4106 challenges that are part of that transition's consequences). The 4107 name of the reciprocal transition may also be specified, as can 4108 a non-default decision type (see `base.DecisionType`). Creates 4109 the transition if it needs to. 4110 4111 Note that if the destination specifier has no zone or domain 4112 information, even if a decision with that name already exists, if 4113 the current decision is in a level-0 zone and the existing 4114 decision is not in the same zone, a new decision with that name 4115 in the current level-0 zone will be created (otherwise, it would 4116 be an error to use 'explore' to connect to an already-visited 4117 decision). 4118 4119 If no destination name is specified, the destination node must 4120 already exist and the name of the destination must not begin 4121 with '_u.' otherwise a `JournalParseError` will be generated. 4122 4123 Sets the current transition to the transition taken. 4124 4125 Calls `autoFinalizeExplorationStatuses` to upgrade exploration 4126 statuses for no-longer-active nodes to 'explored'. 4127 4128 In relative mode, this makes all the same changes to the graph, 4129 without adding a new exploration step, applying transition 4130 effects, or changing exploration statuses. 4131 """ 4132 here = self.definiteDecisionTarget() 4133 4134 transitionName, outcomes = base.nameAndOutcomes(transition) 4135 4136 # Create transition if it doesn't already exist 4137 now = self.exploration.getSituation() 4138 graph = now.graph 4139 leadsTo = graph.getDestination(here, transitionName) 4140 4141 if isinstance(destination, str): 4142 destination = self.parseFormat.parseDecisionSpecifier( 4143 destination 4144 ) 4145 4146 newDomain: Optional[base.Domain] 4147 newZone: Union[ 4148 base.Zone, 4149 type[base.DefaultZone], 4150 None 4151 ] = base.DefaultZone 4152 newName: Optional[base.DecisionName] 4153 4154 # if a destination is specified, we need to check that it's not 4155 # an already-existing decision 4156 connectBack: bool = False # are we connecting to a known decision? 4157 if destination is not None: 4158 # If it's not an ID, splice in current node info: 4159 if isinstance(destination, base.DecisionName): 4160 destination = base.DecisionSpecifier(None, None, destination) 4161 if isinstance(destination, base.DecisionSpecifier): 4162 destination = base.spliceDecisionSpecifiers( 4163 destination, 4164 self.decisionTargetSpecifier() 4165 ) 4166 exists = graph.getDecision(destination) 4167 # if the specified decision doesn't exist; great. We'll 4168 # create it below 4169 if exists is not None: 4170 # If it does exist, we may have a problem. 'return' must 4171 # be used instead of 'explore' to connect to an existing 4172 # visited decision. But let's see if we really have a 4173 # conflict? 4174 otherZones = set( 4175 z 4176 for z in graph.zoneParents(exists) 4177 if graph.zoneHierarchyLevel(z) == 0 4178 ) 4179 currentZones = set( 4180 z 4181 for z in graph.zoneParents(here) 4182 if graph.zoneHierarchyLevel(z) == 0 4183 ) 4184 if ( 4185 len(otherZones & currentZones) != 0 4186 or ( 4187 len(otherZones) == 0 4188 and len(currentZones) == 0 4189 ) 4190 ): 4191 if self.exploration.hasBeenVisited(exists): 4192 # A decision by this name exists and shares at 4193 # least one level-0 zone with the current 4194 # decision. That means that 'return' should have 4195 # been used. 4196 raise JournalParseError( 4197 f"Destiation {destination} is invalid" 4198 f" because that decision has already been" 4199 f" visited in the current zone. Use" 4200 f" 'return' to record a new connection to" 4201 f" an already-visisted decision." 4202 ) 4203 else: 4204 connectBack = True 4205 else: 4206 connectBack = True 4207 # Otherwise, we can continue; the DefaultZone setting 4208 # already in place will prevail below 4209 4210 # Figure out domain & zone info for new destination 4211 if isinstance(destination, base.DecisionSpecifier): 4212 # Use current decision's domain by default 4213 if destination.domain is not None: 4214 newDomain = destination.domain 4215 else: 4216 newDomain = graph.domainFor(here) 4217 4218 # Use specified zone if there is one, else leave it as 4219 # DefaultZone to inherit zone(s) from the current decision. 4220 if destination.zone is not None: 4221 newZone = destination.zone 4222 4223 newName = destination.name 4224 # TODO: Some way to specify non-zone placement in explore? 4225 4226 elif isinstance(destination, base.DecisionID): 4227 if connectBack: 4228 newDomain = graph.domainFor(here) 4229 newZone = None 4230 newName = None 4231 else: 4232 raise JournalParseError( 4233 f"You cannot use a decision ID when specifying a" 4234 f" new name for an exploration destination (got:" 4235 f" {repr(destination)})" 4236 ) 4237 4238 elif isinstance(destination, base.DecisionName): 4239 newDomain = None 4240 newZone = base.DefaultZone 4241 newName = destination 4242 4243 else: # must be None 4244 assert destination is None 4245 newDomain = None 4246 newZone = base.DefaultZone 4247 newName = None 4248 4249 if leadsTo is None: 4250 if newName is None and not connectBack: 4251 raise JournalParseError( 4252 f"Transition {transition!r} at decision" 4253 f" {graph.identityOf(here)} does not already exist," 4254 f" so a destination name must be provided." 4255 ) 4256 else: 4257 graph.addUnexploredEdge( 4258 here, 4259 transitionName, 4260 toDomain=newDomain # None is the default anyways 4261 ) 4262 # Zone info only added in next step 4263 elif newName is None: 4264 # TODO: Generalize this... ? 4265 currentName = graph.nameFor(leadsTo) 4266 if currentName.startswith('_u.'): 4267 raise JournalParseError( 4268 f"Destination {graph.identityOf(leadsTo)} from" 4269 f" decision {graph.identityOf(here)} via transition" 4270 f" {transition!r} must be named when explored," 4271 f" because its current name is a placeholder." 4272 ) 4273 else: 4274 newName = currentName 4275 4276 # TODO: Check for incompatible domain/zone in destination 4277 # specifier? 4278 4279 if self.inRelativeMode: 4280 if connectBack: # connect to existing unconfirmed decision 4281 assert exists is not None 4282 graph.replaceUnconfirmed( 4283 here, 4284 transitionName, 4285 exists, 4286 reciprocal 4287 ) # we assume zones are already in place here 4288 self.exploration.setExplorationStatus( 4289 exists, 4290 'noticed', 4291 upgradeOnly=True 4292 ) 4293 else: # connect to a new decision 4294 graph.replaceUnconfirmed( 4295 here, 4296 transitionName, 4297 newName, 4298 reciprocal, 4299 placeInZone=newZone, 4300 forceNew=True 4301 ) 4302 destID = graph.destination(here, transitionName) 4303 self.exploration.setExplorationStatus( 4304 destID, 4305 'noticed', 4306 upgradeOnly=True 4307 ) 4308 self.context['decision'] = graph.destination( 4309 here, 4310 transitionName 4311 ) 4312 self.context['transition'] = (here, transitionName) 4313 else: 4314 if connectBack: # to a known but unvisited decision 4315 destID = self.exploration.explore( 4316 (transitionName, outcomes), 4317 exists, 4318 reciprocal, 4319 zone=newZone, 4320 decisionType=decisionType 4321 ) 4322 else: # to an entirely new decision 4323 destID = self.exploration.explore( 4324 (transitionName, outcomes), 4325 newName, 4326 reciprocal, 4327 zone=newZone, 4328 decisionType=decisionType 4329 ) 4330 self.context['decision'] = destID 4331 self.context['transition'] = (here, transitionName) 4332 self.autoFinalizeExplorationStatuses() 4333 4334 def recordRetrace( 4335 self, 4336 transition: base.AnyTransition, 4337 decisionType: base.DecisionType = 'active', 4338 isAction: Optional[bool] = None 4339 ) -> None: 4340 """ 4341 Records retracing a transition which leads to a known 4342 destination. A non-default decision type can be specified. If 4343 `isAction` is True or False, the transition must be (or must not 4344 be) an action (i.e., a transition whose destination is the same 4345 as its source). If `isAction` is left as `None` (the default) 4346 then either normal or action transitions can be retraced. 4347 4348 Sets the current transition to the transition taken. 4349 4350 Calls `autoFinalizeExplorationStatuses` unless in relative mode. 4351 4352 In relative mode, simply sets the current transition target to 4353 the transition taken and sets the current decision target to its 4354 destination (it does not apply transition effects). 4355 """ 4356 here = self.definiteDecisionTarget() 4357 4358 transitionName, outcomes = base.nameAndOutcomes(transition) 4359 4360 graph = self.exploration.getSituation().graph 4361 destination = graph.getDestination(here, transitionName) 4362 if destination is None: 4363 valid = graph.destinationsListing(graph.destinationsFrom(here)) 4364 raise JournalParseError( 4365 f"Cannot retrace transition {transitionName!r} from" 4366 f" decision {graph.identityOf(here)}: that transition" 4367 f" does not exist. Destinations available are:" 4368 f"\n{valid}" 4369 ) 4370 if isAction is True and destination != here: 4371 raise JournalParseError( 4372 f"Cannot retrace transition {transitionName!r} from" 4373 f" decision {graph.identityOf(here)}: that transition" 4374 f" leads to {graph.identityOf(destination)} but you" 4375 f" specified that an existing action should be retraced," 4376 f" not a normal transition. Use `recordAction` instead" 4377 f" to record a new action (including converting an" 4378 f" unconfirmed transition into an action). Leave" 4379 f" `isAction` unspeicfied or set it to `False` to" 4380 f" retrace a normal transition." 4381 ) 4382 elif isAction is False and destination == here: 4383 raise JournalParseError( 4384 f"Cannot retrace transition {transitionName!r} from" 4385 f" decision {graph.identityOf(here)}: that transition" 4386 f" leads back to {graph.identityOf(destination)} but you" 4387 f" specified that an outgoing transition should be" 4388 f" retraced, not an action. Use `recordAction` instead" 4389 f" to record a new action (which must not have the same" 4390 f" name as any outgoing transition). Leave `isAction`" 4391 f" unspeicfied or set it to `True` to retrace an action." 4392 ) 4393 4394 if not self.inRelativeMode: 4395 destID = self.exploration.retrace( 4396 (transitionName, outcomes), 4397 decisionType=decisionType 4398 ) 4399 self.autoFinalizeExplorationStatuses() 4400 self.context['decision'] = destID 4401 self.context['transition'] = (here, transitionName) 4402 4403 def recordAction( 4404 self, 4405 action: base.AnyTransition, 4406 decisionType: base.DecisionType = 'active' 4407 ) -> None: 4408 """ 4409 Records a new action taken at the current decision. A 4410 non-standard decision type may be specified. If a transition of 4411 that name already existed, it will be converted into an action 4412 assuming that its destination is unexplored and has no 4413 connections yet, and that its reciprocal also has no special 4414 properties yet. If those assumptions do not hold, a 4415 `JournalParseError` will be raised under the assumption that the 4416 name collision was an accident, not intentional, since the 4417 destination and reciprocal are deleted in the process of 4418 converting a normal transition into an action. 4419 4420 This cannot be used to re-triggger an existing action, use 4421 'retrace' for that. 4422 4423 In relative mode, the action is created (or the transition is 4424 converted into an action) but effects are not applied. 4425 4426 Although this does not usually change which decisions are 4427 active, it still calls `autoFinalizeExplorationStatuses` unless 4428 in relative mode. 4429 4430 Example: 4431 4432 >>> o = JournalObserver() 4433 >>> e = o.getExploration() 4434 >>> o.recordStart('start') 4435 >>> o.recordObserve('transition') 4436 >>> e.effectiveCapabilities()['capabilities'] 4437 set() 4438 >>> o.recordObserveAction('action') 4439 >>> o.recordTransitionConsequence([base.effect(gain="capability")]) 4440 >>> o.recordRetrace('action', isAction=True) 4441 >>> e.effectiveCapabilities()['capabilities'] 4442 {'capability'} 4443 >>> o.recordAction('another') # add effects after... 4444 >>> effect = base.effect(lose="capability") 4445 >>> # This applies the effect and then adds it to the 4446 >>> # transition, since we already took the transition 4447 >>> o.recordAdditionalTransitionConsequence([effect]) 4448 >>> e.effectiveCapabilities()['capabilities'] 4449 set() 4450 >>> len(e) 4451 4 4452 >>> e.getActiveDecisions(0) 4453 set() 4454 >>> e.getActiveDecisions(1) 4455 {0} 4456 >>> e.getActiveDecisions(2) 4457 {0} 4458 >>> e.getActiveDecisions(3) 4459 {0} 4460 >>> e.getSituation(0).action 4461 ('start', 0, 0, 'main', None, None, None) 4462 >>> e.getSituation(1).action 4463 ('take', 'active', 0, ('action', [])) 4464 >>> e.getSituation(2).action 4465 ('take', 'active', 0, ('another', [])) 4466 """ 4467 here = self.definiteDecisionTarget() 4468 4469 actionName, outcomes = base.nameAndOutcomes(action) 4470 4471 # Check if the transition already exists 4472 now = self.exploration.getSituation() 4473 graph = now.graph 4474 hereIdent = graph.identityOf(here) 4475 destinations = graph.destinationsFrom(here) 4476 4477 # A transition going somewhere else 4478 if actionName in destinations: 4479 if destinations[actionName] == here: 4480 raise JournalParseError( 4481 f"Action {actionName!r} already exists as an action" 4482 f" at decision {hereIdent!r}. Use 'retrace' to" 4483 " re-activate an existing action." 4484 ) 4485 else: 4486 destination = destinations[actionName] 4487 reciprocal = graph.getReciprocal(here, actionName) 4488 # To replace a transition with an action, the transition 4489 # may only have outgoing properties. Otherwise we assume 4490 # it's an error to name the action after a transition 4491 # which was intended to be a real transition. 4492 if ( 4493 graph.isConfirmed(destination) 4494 or self.exploration.hasBeenVisited(destination) 4495 or cast(int, graph.degree(destination)) > 2 4496 # TODO: Fix MultiDigraph type stubs... 4497 ): 4498 raise JournalParseError( 4499 f"Action {actionName!r} has the same name as" 4500 f" outgoing transition {actionName!r} at" 4501 f" decision {hereIdent!r}. We cannot turn that" 4502 f" transition into an action since its" 4503 f" destination is already explored or has been" 4504 f" connected to." 4505 ) 4506 if ( 4507 reciprocal is not None 4508 and graph.getTransitionProperties( 4509 destination, 4510 reciprocal 4511 ) != { 4512 'requirement': base.ReqNothing(), 4513 'effects': [], 4514 'tags': {}, 4515 'annotations': [] 4516 } 4517 ): 4518 raise JournalParseError( 4519 f"Action {actionName!r} has the same name as" 4520 f" outgoing transition {actionName!r} at" 4521 f" decision {hereIdent!r}. We cannot turn that" 4522 f" transition into an action since its" 4523 f" reciprocal has custom properties." 4524 ) 4525 4526 if ( 4527 graph.decisionAnnotations(destination) != [] 4528 or graph.decisionTags(destination) != {'unknown': 1} 4529 ): 4530 raise JournalParseError( 4531 f"Action {actionName!r} has the same name as" 4532 f" outgoing transition {actionName!r} at" 4533 f" decision {hereIdent!r}. We cannot turn that" 4534 f" transition into an action since its" 4535 f" destination has tags and/or annotations." 4536 ) 4537 4538 # If we get here, re-target the transition, and then 4539 # destroy the old destination along with the old 4540 # reciprocal edge. 4541 graph.retargetTransition( 4542 here, 4543 actionName, 4544 here, 4545 swapReciprocal=False 4546 ) 4547 graph.removeDecision(destination) 4548 4549 # This will either take the existing action OR create it if 4550 # necessary 4551 if self.inRelativeMode: 4552 if actionName not in destinations: 4553 graph.addAction(here, actionName) 4554 else: 4555 destID = self.exploration.takeAction( 4556 (actionName, outcomes), 4557 fromDecision=here, 4558 decisionType=decisionType 4559 ) 4560 self.autoFinalizeExplorationStatuses() 4561 self.context['decision'] = destID 4562 self.context['transition'] = (here, actionName) 4563 4564 def recordReturn( 4565 self, 4566 transition: base.AnyTransition, 4567 destination: Optional[base.AnyDecisionSpecifier] = None, 4568 reciprocal: Optional[base.Transition] = None, 4569 decisionType: base.DecisionType = 'active' 4570 ) -> None: 4571 """ 4572 Records an exploration which leads back to a 4573 previously-encountered decision. If a reciprocal is specified, 4574 we connect to that transition as our reciprocal (it must have 4575 led to an unknown area or not have existed) or if not, we make a 4576 new connection with an automatic reciprocal name. 4577 A non-standard decision type may be specified. 4578 4579 If no destination is specified, then the destination of the 4580 transition must already exist. 4581 4582 If the specified transition does not exist, it will be created. 4583 4584 Sets the current transition to the transition taken. 4585 4586 Calls `autoFinalizeExplorationStatuses` unless in relative mode. 4587 4588 In relative mode, does the same stuff but doesn't apply any 4589 transition effects. 4590 """ 4591 here = self.definiteDecisionTarget() 4592 now = self.exploration.getSituation() 4593 graph = now.graph 4594 4595 transitionName, outcomes = base.nameAndOutcomes(transition) 4596 4597 if destination is None: 4598 destination = graph.getDestination(here, transitionName) 4599 if destination is None: 4600 raise JournalParseError( 4601 f"Cannot 'return' across transition" 4602 f" {transitionName!r} from decision" 4603 f" {graph.identityOf(here)} without specifying a" 4604 f" destination, because that transition does not" 4605 f" already have a destination." 4606 ) 4607 4608 if isinstance(destination, str): 4609 destination = self.parseFormat.parseDecisionSpecifier( 4610 destination 4611 ) 4612 4613 # If we started with a name or some other kind of decision 4614 # specifier, replace missing domain and/or zone info with info 4615 # from the current decision. 4616 if isinstance(destination, base.DecisionSpecifier): 4617 destination = base.spliceDecisionSpecifiers( 4618 destination, 4619 self.decisionTargetSpecifier() 4620 ) 4621 4622 # Add an unexplored edge just before doing the return if the 4623 # named transition didn't already exist. 4624 if graph.getDestination(here, transitionName) is None: 4625 graph.addUnexploredEdge(here, transitionName) 4626 4627 # Works differently in relative mode 4628 if self.inRelativeMode: 4629 graph.replaceUnconfirmed( 4630 here, 4631 transitionName, 4632 destination, 4633 reciprocal 4634 ) 4635 self.context['decision'] = graph.resolveDecision(destination) 4636 self.context['transition'] = (here, transitionName) 4637 else: 4638 destID = self.exploration.returnTo( 4639 (transitionName, outcomes), 4640 destination, 4641 reciprocal, 4642 decisionType=decisionType 4643 ) 4644 self.autoFinalizeExplorationStatuses() 4645 self.context['decision'] = destID 4646 self.context['transition'] = (here, transitionName) 4647 4648 def recordWarp( 4649 self, 4650 destination: base.AnyDecisionSpecifier, 4651 decisionType: base.DecisionType = 'active' 4652 ) -> None: 4653 """ 4654 Records a warp to a specific destination without creating a 4655 transition. If the destination did not exist, it will be 4656 created (but only if a `base.DecisionName` or 4657 `base.DecisionSpecifier` was supplied; a destination cannot be 4658 created based on a non-existent `base.DecisionID`). 4659 A non-standard decision type may be specified. 4660 4661 If the destination already exists its zones won't be changed. 4662 However, if the destination gets created, it will be in the same 4663 domain and added to the same zones as the previous position, or 4664 to whichever zone was specified as the zone component of a 4665 `base.DecisionSpecifier`, if any. 4666 4667 Sets the current transition to `None`. 4668 4669 In relative mode, simply updates the current target decision and 4670 sets the current target transition to `None`. It will still 4671 create the destination if necessary, possibly putting it in a 4672 zone. In relative mode, the destination's exploration status is 4673 set to "noticed" (and no exploration step is created), while in 4674 normal mode, the exploration status is set to 'unknown' in the 4675 original current step, and then a new step is added which will 4676 set the status to 'exploring'. 4677 4678 Calls `autoFinalizeExplorationStatuses` unless in relative mode. 4679 """ 4680 now = self.exploration.getSituation() 4681 graph = now.graph 4682 4683 if isinstance(destination, str): 4684 destination = self.parseFormat.parseDecisionSpecifier( 4685 destination 4686 ) 4687 4688 destID = graph.getDecision(destination) 4689 4690 newZone: Union[ 4691 base.Zone, 4692 type[base.DefaultZone], 4693 None 4694 ] = base.DefaultZone 4695 here = self.currentDecisionTarget() 4696 newDomain: Optional[base.Domain] = None 4697 if here is not None: 4698 newDomain = graph.domainFor(here) 4699 if self.inRelativeMode: # create the decision if it didn't exist 4700 if destID not in graph: # including if it's None 4701 if isinstance(destination, base.DecisionID): 4702 raise JournalParseError( 4703 f"Cannot go to decision {destination} because that" 4704 f" decision ID does not exist, and we cannot create" 4705 f" a new decision based only on a decision ID. Use" 4706 f" a DecisionSpecifier or DecisionName to go to a" 4707 f" new decision that needs to be created." 4708 ) 4709 elif isinstance(destination, base.DecisionName): 4710 newName = destination 4711 newZone = base.DefaultZone 4712 elif isinstance(destination, base.DecisionSpecifier): 4713 specDomain, newZone, newName = destination 4714 if specDomain is not None: 4715 newDomain = specDomain 4716 else: 4717 raise JournalParseError( 4718 f"Invalid decision specifier: {repr(destination)}." 4719 f" The destination must be a decision ID, a" 4720 f" decision name, or a decision specifier." 4721 ) 4722 destID = graph.addDecision(newName, domain=newDomain) 4723 if newZone is base.DefaultZone: 4724 ctxDecision = self.context['decision'] 4725 if ctxDecision is not None: 4726 for zp in graph.zoneParents(ctxDecision): 4727 graph.addDecisionToZone(destID, zp) 4728 elif newZone is not None: 4729 graph.addDecisionToZone(destID, newZone) 4730 # TODO: If this zone is new create it & add it to 4731 # parent zones of old level-0 zone(s)? 4732 4733 base.setExplorationStatus( 4734 now, 4735 destID, 4736 'noticed', 4737 upgradeOnly=True 4738 ) 4739 # TODO: Some way to specify 'hypothesized' here instead? 4740 4741 else: 4742 # in normal mode, 'DiscreteExploration.warp' takes care of 4743 # creating the decision if needed 4744 whichFocus = None 4745 if self.context['focus'] is not None: 4746 whichFocus = ( 4747 self.context['context'], 4748 self.context['domain'], 4749 self.context['focus'] 4750 ) 4751 if destination is None: 4752 destination = destID 4753 4754 if isinstance(destination, base.DecisionSpecifier): 4755 newZone = destination.zone 4756 if destination.domain is not None: 4757 newDomain = destination.domain 4758 else: 4759 newZone = base.DefaultZone 4760 4761 destID = self.exploration.warp( 4762 destination, 4763 domain=newDomain, 4764 zone=newZone, 4765 whichFocus=whichFocus, 4766 inCommon=self.context['context'] == 'common', 4767 decisionType=decisionType 4768 ) 4769 self.autoFinalizeExplorationStatuses() 4770 4771 self.context['decision'] = destID 4772 self.context['transition'] = None 4773 4774 def recordWait( 4775 self, 4776 decisionType: base.DecisionType = 'active' 4777 ) -> None: 4778 """ 4779 Records a wait step. Does not modify the current transition. 4780 A non-standard decision type may be specified. 4781 4782 Raises a `JournalParseError` in relative mode, since it wouldn't 4783 have any effect. 4784 """ 4785 if self.inRelativeMode: 4786 raise JournalParseError("Can't wait in relative mode.") 4787 else: 4788 self.exploration.wait(decisionType=decisionType) 4789 4790 def recordObserveEnding(self, name: base.DecisionName) -> None: 4791 """ 4792 Records the observation of an action which warps to an ending, 4793 although unlike `recordEnd` we don't use that action yet. This 4794 does NOT update the current decision, although it sets the 4795 current transition to the action it creates. 4796 4797 The action created has the same name as the ending it warps to. 4798 4799 Note that normally, we just warp to endings, so there's no need 4800 to use `recordObserveEnding`. But if there's a player-controlled 4801 option to end the game at a particular node that is noticed 4802 before it's actually taken, this is the right thing to do. 4803 4804 We set up player-initiated ending transitions as actions with a 4805 goto rather than usual transitions because endings exist in a 4806 separate domain, and are active simultaneously with normal 4807 decisions. 4808 """ 4809 graph = self.exploration.getSituation().graph 4810 here = self.definiteDecisionTarget() 4811 # Add the ending decision or grab the ID of the existing ending 4812 eID = graph.endingID(name) 4813 # Create action & add goto consequence 4814 graph.addAction(here, name) 4815 graph.setConsequence(here, name, [base.effect(goto=eID)]) 4816 # Set the exploration status 4817 self.exploration.setExplorationStatus( 4818 eID, 4819 'noticed', 4820 upgradeOnly=True 4821 ) 4822 self.context['transition'] = (here, name) 4823 # TODO: Prevent things like adding unexplored nodes to the 4824 # an ending... 4825 4826 def recordEnd( 4827 self, 4828 name: base.DecisionName, 4829 voluntary: bool = False, 4830 decisionType: Optional[base.DecisionType] = None 4831 ) -> None: 4832 """ 4833 Records an ending. If `voluntary` is `False` (the default) then 4834 this becomes a warp that activates the specified ending (which 4835 is in the `core.ENDINGS_DOMAIN` domain, so that doesn't leave 4836 the current decision). 4837 4838 If `voluntary` is `True` then we also record an action with a 4839 'goto' effect that activates the specified ending, and record an 4840 exploration step that takes that action, instead of just a warp 4841 (`recordObserveEnding` would set up such an action without 4842 taking it). 4843 4844 The specified ending decision is created if it didn't already 4845 exist. If `voluntary` is True and an action that warps to the 4846 specified ending already exists with the correct name, we will 4847 simply take that action. 4848 4849 If it created an action, it sets the current transition to the 4850 action that warps to the ending. Endings are not added to zones; 4851 otherwise it sets the current transition to None. 4852 4853 In relative mode, an ending is still added, possibly with an 4854 action that warps to it, and the current decision is set to that 4855 ending node, but the transition doesn't actually get taken. 4856 4857 If not in relative mode, sets the exploration status of the 4858 current decision to `explored` if it wasn't in the 4859 `dontFinalize` set, even though we do not deactivate that 4860 transition. 4861 4862 When `voluntary` is not set, the decision type for the warp will 4863 be 'imposed', otherwise it will be 'active'. However, if an 4864 explicit `decisionType` is specified, that will override these 4865 defaults. 4866 """ 4867 graph = self.exploration.getSituation().graph 4868 here = self.definiteDecisionTarget() 4869 4870 # Add our warping action if we need to 4871 if voluntary: 4872 # If voluntary, check for an existing warp action and set 4873 # one up if we don't have one. 4874 aDest = graph.getDestination(here, name) 4875 eID = graph.getDecision( 4876 base.DecisionSpecifier(core.ENDINGS_DOMAIN, None, name) 4877 ) 4878 if aDest is None: 4879 # Okay we can just create the action 4880 self.recordObserveEnding(name) 4881 # else check if the existing transition is an action 4882 # that warps to the correct ending already 4883 elif ( 4884 aDest != here 4885 or eID is None 4886 or not any( 4887 c == base.effect(goto=eID) 4888 for c in graph.getConsequence(here, name) 4889 ) 4890 ): 4891 raise JournalParseError( 4892 f"Attempting to add voluntary ending {name!r} at" 4893 f" decision {graph.identityOf(here)} but that" 4894 f" decision already has an action with that name" 4895 f" and it's not set up to warp to that ending" 4896 f" already." 4897 ) 4898 4899 # Grab ending ID (creates the decision if necessary) 4900 eID = graph.endingID(name) 4901 4902 # Update our context variables 4903 self.context['decision'] = eID 4904 if voluntary: 4905 self.context['transition'] = (here, name) 4906 else: 4907 self.context['transition'] = None 4908 4909 # Update exploration status in relative mode, or possibly take 4910 # action in normal mode 4911 if self.inRelativeMode: 4912 self.exploration.setExplorationStatus( 4913 eID, 4914 "noticed", 4915 upgradeOnly=True 4916 ) 4917 else: 4918 # Either take the action we added above, or just warp 4919 if decisionType is None: 4920 decisionType = 'active' if voluntary else 'imposed' 4921 4922 if voluntary: 4923 # Taking the action warps us to the ending 4924 self.exploration.takeAction( 4925 name, 4926 decisionType=decisionType 4927 ) 4928 else: 4929 # We'll use a warp to get there 4930 self.exploration.warp( 4931 base.DecisionSpecifier(core.ENDINGS_DOMAIN, None, name), 4932 zone=None, 4933 decisionType=decisionType 4934 ) 4935 if ( 4936 here not in self.dontFinalize 4937 and ( 4938 self.exploration.getExplorationStatus(here) 4939 == "exploring" 4940 ) 4941 ): 4942 self.exploration.setExplorationStatus(here, "explored") 4943 # TODO: Prevent things like adding unexplored nodes to the 4944 # ending... 4945 4946 def recordMechanism( 4947 self, 4948 where: Optional[base.AnyDecisionSpecifier], 4949 name: base.MechanismName, 4950 startingState: base.MechanismState = base.DEFAULT_MECHANISM_STATE 4951 ) -> None: 4952 """ 4953 Records the existence of a mechanism at the specified decision 4954 with the specified starting state (or the default starting 4955 state). Set `where` to `None` to set up a global mechanism that's 4956 not tied to any particular decision. 4957 """ 4958 graph = self.exploration.getSituation().graph 4959 # TODO: a way to set up global mechanisms 4960 newID = graph.addMechanism(name, where) 4961 if startingState != base.DEFAULT_MECHANISM_STATE: 4962 self.exploration.setMechanismStateNow(newID, startingState) 4963 4964 def recordRequirement(self, req: Union[base.Requirement, str]) -> None: 4965 """ 4966 Records a requirement observed on the most recently 4967 defined/taken transition. If a string is given, 4968 `ParseFormat.parseRequirement` will be used to parse it. 4969 """ 4970 if isinstance(req, str): 4971 req = self.parseFormat.parseRequirement(req) 4972 target = self.currentTransitionTarget() 4973 if target is None: 4974 raise JournalParseError( 4975 "Can't set a requirement because there is no current" 4976 " transition." 4977 ) 4978 graph = self.exploration.getSituation().graph 4979 graph.setTransitionRequirement( 4980 *target, 4981 req 4982 ) 4983 4984 def recordReciprocalRequirement( 4985 self, 4986 req: Union[base.Requirement, str] 4987 ) -> None: 4988 """ 4989 Records a requirement observed on the reciprocal of the most 4990 recently defined/taken transition. If a string is given, 4991 `ParseFormat.parseRequirement` will be used to parse it. 4992 """ 4993 if isinstance(req, str): 4994 req = self.parseFormat.parseRequirement(req) 4995 target = self.currentReciprocalTarget() 4996 if target is None: 4997 raise JournalParseError( 4998 "Can't set a reciprocal requirement because there is no" 4999 " current transition or it doesn't have a reciprocal." 5000 ) 5001 graph = self.exploration.getSituation().graph 5002 graph.setTransitionRequirement(*target, req) 5003 5004 def recordTransitionConsequence( 5005 self, 5006 consequence: base.Consequence 5007 ) -> None: 5008 """ 5009 Records a transition consequence, which gets added to any 5010 existing consequences of the currently-relevant transition (the 5011 most-recently created or taken transition). A `JournalParseError` 5012 will be raised if there is no current transition. 5013 """ 5014 target = self.currentTransitionTarget() 5015 if target is None: 5016 raise JournalParseError( 5017 "Cannot apply a consequence because there is no current" 5018 " transition." 5019 ) 5020 5021 now = self.exploration.getSituation() 5022 now.graph.addConsequence(*target, consequence) 5023 5024 def recordReciprocalConsequence( 5025 self, 5026 consequence: base.Consequence 5027 ) -> None: 5028 """ 5029 Like `recordTransitionConsequence` but applies the effect to the 5030 reciprocal of the current transition. Will cause a 5031 `JournalParseError` if the current transition has no reciprocal 5032 (e.g., it's an ending transition). 5033 """ 5034 target = self.currentReciprocalTarget() 5035 if target is None: 5036 raise JournalParseError( 5037 "Cannot apply a reciprocal effect because there is no" 5038 " current transition, or it doesn't have a reciprocal." 5039 ) 5040 5041 now = self.exploration.getSituation() 5042 now.graph.addConsequence(*target, consequence) 5043 5044 def recordAdditionalTransitionConsequence( 5045 self, 5046 consequence: base.Consequence, 5047 hideEffects: bool = True 5048 ) -> None: 5049 """ 5050 Records the addition of a new consequence to the current 5051 relevant transition, while also triggering the effects of that 5052 consequence (but not the other effects of that transition, which 5053 we presume have just been applied already). 5054 5055 By default each effect added this way automatically gets the 5056 "hidden" property added to it, because the assumption is if it 5057 were a foreseeable effect, you would have added it to the 5058 transition before taking it. If you set `hideEffects` to 5059 `False`, this won't be done. 5060 5061 This modifies the current state but does not add a step to the 5062 exploration. It does NOT call `autoFinalizeExplorationStatuses`, 5063 which means that if a 'bounce' or 'goto' effect ends up making 5064 one or more decisions no-longer-active, they do NOT get their 5065 exploration statuses upgraded to 'explored'. 5066 """ 5067 # Receive begin/end indices from `addConsequence` and send them 5068 # to `applyTransitionConsequence` to limit which # parts of the 5069 # expanded consequence are actually applied. 5070 currentTransition = self.currentTransitionTarget() 5071 if currentTransition is None: 5072 consRepr = self.parseFormat.unparseConsequence(consequence) 5073 raise JournalParseError( 5074 f"Can't apply an additional consequence to a transition" 5075 f" when there is no current transition. Got" 5076 f" consequence:\n{consRepr}" 5077 ) 5078 5079 if hideEffects: 5080 for (index, item) in base.walkParts(consequence): 5081 if isinstance(item, dict) and 'value' in item: 5082 assert 'hidden' in item 5083 item = cast(base.Effect, item) 5084 item['hidden'] = True 5085 5086 now = self.exploration.getSituation() 5087 begin, end = now.graph.addConsequence( 5088 *currentTransition, 5089 consequence 5090 ) 5091 self.exploration.applyTransitionConsequence( 5092 *currentTransition, 5093 moveWhich=self.context['focus'], 5094 policy="specified", 5095 fromIndex=begin, 5096 toIndex=end 5097 ) 5098 # This tracks trigger counts and obeys 5099 # charges/delays, unlike 5100 # applyExtraneousConsequence, but some effects 5101 # like 'bounce' still can't be properly applied 5102 5103 def recordTagStep( 5104 self, 5105 tag: base.Tag, 5106 value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue 5107 ) -> None: 5108 """ 5109 Records a tag to be applied to the current exploration step. 5110 """ 5111 self.exploration.tagStep(tag, value) 5112 5113 def recordTagDecision( 5114 self, 5115 tag: base.Tag, 5116 value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue 5117 ) -> None: 5118 """ 5119 Records a tag to be applied to the current decision. 5120 """ 5121 now = self.exploration.getSituation() 5122 now.graph.tagDecision( 5123 self.definiteDecisionTarget(), 5124 tag, 5125 value 5126 ) 5127 5128 def recordTagTranstion( 5129 self, 5130 tag: base.Tag, 5131 value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue 5132 ) -> None: 5133 """ 5134 Records a tag to be applied to the most-recently-defined or 5135 -taken transition. 5136 """ 5137 target = self.currentTransitionTarget() 5138 if target is None: 5139 raise JournalParseError( 5140 "Cannot tag a transition because there is no current" 5141 " transition." 5142 ) 5143 5144 now = self.exploration.getSituation() 5145 now.graph.tagTransition(*target, tag, value) 5146 5147 def recordTagReciprocal( 5148 self, 5149 tag: base.Tag, 5150 value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue 5151 ) -> None: 5152 """ 5153 Records a tag to be applied to the reciprocal of the 5154 most-recently-defined or -taken transition. 5155 """ 5156 target = self.currentReciprocalTarget() 5157 if target is None: 5158 raise JournalParseError( 5159 "Cannot tag a transition because there is no current" 5160 " transition." 5161 ) 5162 5163 now = self.exploration.getSituation() 5164 now.graph.tagTransition(*target, tag, value) 5165 5166 def currentZoneAtLevel(self, level: int) -> base.Zone: 5167 """ 5168 Returns a zone in the current graph that applies to the current 5169 decision which is at the specified hierarchy level. If there is 5170 no such zone, raises a `JournalParseError`. If there are 5171 multiple such zones, returns the zone which includes the fewest 5172 decisions, breaking ties alphabetically by zone name. 5173 """ 5174 here = self.definiteDecisionTarget() 5175 graph = self.exploration.getSituation().graph 5176 ancestors = graph.zoneAncestors(here) 5177 candidates = [ 5178 ancestor 5179 for ancestor in ancestors 5180 if graph.zoneHierarchyLevel(ancestor) == level 5181 ] 5182 if len(candidates) == 0: 5183 raise JournalParseError( 5184 ( 5185 f"Cannot find any level-{level} zones for the" 5186 f" current decision {graph.identityOf(here)}. That" 5187 f" decision is" 5188 ) + ( 5189 " in the following zones:" 5190 + '\n'.join( 5191 f" level {graph.zoneHierarchyLevel(z)}: {z!r}" 5192 for z in ancestors 5193 ) 5194 ) if len(ancestors) > 0 else ( 5195 " not in any zones." 5196 ) 5197 ) 5198 candidates.sort( 5199 key=lambda zone: (len(graph.allDecisionsInZone(zone)), zone) 5200 ) 5201 return candidates[0] 5202 5203 def recordTagZone( 5204 self, 5205 level: int, 5206 tag: base.Tag, 5207 value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue 5208 ) -> None: 5209 """ 5210 Records a tag to be applied to one of the zones that the current 5211 decision is in, at a specific hierarchy level. There must be at 5212 least one zone ancestor of the current decision at that hierarchy 5213 level; if there are multiple then the tag is applied to the 5214 smallest one, breaking ties by alphabetical order. 5215 """ 5216 applyTo = self.currentZoneAtLevel(level) 5217 self.exploration.getSituation().graph.tagZone(applyTo, tag, value) 5218 5219 def recordAnnotateStep( 5220 self, 5221 *annotations: base.Annotation 5222 ) -> None: 5223 """ 5224 Records annotations to be applied to the current exploration 5225 step. 5226 """ 5227 self.exploration.annotateStep(annotations) 5228 pf = self.parseFormat 5229 now = self.exploration.getSituation() 5230 for a in annotations: 5231 if a.startswith("at:"): 5232 expects = pf.parseDecisionSpecifier(a[3:]) 5233 if isinstance(expects, base.DecisionSpecifier): 5234 if expects.domain is None and expects.zone is None: 5235 expects = base.spliceDecisionSpecifiers( 5236 expects, 5237 self.decisionTargetSpecifier() 5238 ) 5239 eID = now.graph.getDecision(expects) 5240 primaryNow: Optional[base.DecisionID] 5241 if self.inRelativeMode: 5242 primaryNow = self.definiteDecisionTarget() 5243 else: 5244 primaryNow = now.state['primaryDecision'] 5245 if eID is None: 5246 self.warn( 5247 f"'at' annotation expects position {expects!r}" 5248 f" but that's not a valid decision specifier in" 5249 f" the current graph." 5250 ) 5251 elif eID != primaryNow: 5252 self.warn( 5253 f"'at' annotation expects position {expects!r}" 5254 f" which is decision" 5255 f" {now.graph.identityOf(eID)}, but the current" 5256 f" primary decision is" 5257 f" {now.graph.identityOf(primaryNow)}" 5258 ) 5259 elif a.startswith("active:"): 5260 expects = pf.parseDecisionSpecifier(a[3:]) 5261 eID = now.graph.getDecision(expects) 5262 atNow = base.combinedDecisionSet(now.state) 5263 if eID is None: 5264 self.warn( 5265 f"'active' annotation expects decision {expects!r}" 5266 f" but that's not a valid decision specifier in" 5267 f" the current graph." 5268 ) 5269 elif eID not in atNow: 5270 self.warn( 5271 f"'active' annotation expects decision {expects!r}" 5272 f" which is {now.graph.identityOf(eID)}, but" 5273 f" the current active position(s) is/are:" 5274 f"\n{now.graph.namesListing(atNow)}" 5275 ) 5276 elif a.startswith("has:"): 5277 ea = pf.parseOneEffectArg(pf.lex(a[4:]))[0] 5278 if ( 5279 isinstance(ea, tuple) 5280 and len(ea) == 2 5281 and isinstance(ea[0], base.Token) 5282 and isinstance(ea[1], base.TokenCount) 5283 ): 5284 countNow = base.combinedTokenCount(now.state, ea[0]) 5285 if countNow != ea[1]: 5286 self.warn( 5287 f"'has' annotation expects {ea[1]} {ea[0]!r}" 5288 f" token(s) but the current state has" 5289 f" {countNow} of them." 5290 ) 5291 else: 5292 self.warn( 5293 f"'has' annotation expects tokens {a[4:]!r} but" 5294 f" that's not a (token, count) pair." 5295 ) 5296 elif a.startswith("level:"): 5297 ea = pf.parseOneEffectArg(pf.lex(a[6:]))[0] 5298 if ( 5299 isinstance(ea, tuple) 5300 and len(ea) == 3 5301 and ea[0] == 'skill' 5302 and isinstance(ea[1], base.Skill) 5303 and isinstance(ea[2], base.Level) 5304 ): 5305 levelNow = base.getSkillLevel(now.state, ea[1]) 5306 if levelNow != ea[2]: 5307 self.warn( 5308 f"'level' annotation expects skill {ea[1]!r}" 5309 f" to be at level {ea[2]} but the current" 5310 f" level for that skill is {levelNow}." 5311 ) 5312 else: 5313 self.warn( 5314 f"'level' annotation expects skill {a[6:]!r} but" 5315 f" that's not a (skill, level) pair." 5316 ) 5317 elif a.startswith("can:"): 5318 try: 5319 req = pf.parseRequirement(a[4:]) 5320 except parsing.ParseError: 5321 self.warn( 5322 f"'can' annotation expects requirement {a[4:]!r}" 5323 f" but that's not parsable as a requirement." 5324 ) 5325 req = None 5326 if req is not None: 5327 ctx = base.genericContextForSituation(now) 5328 if not req.satisfied(ctx): 5329 self.warn( 5330 f"'can' annotation expects requirement" 5331 f" {req!r} to be satisfied but it's not in" 5332 f" the current situation." 5333 ) 5334 elif a.startswith("state:"): 5335 ctx = base.genericContextForSituation( 5336 now 5337 ) 5338 ea = pf.parseOneEffectArg(pf.lex(a[6:]))[0] 5339 if ( 5340 isinstance(ea, tuple) 5341 and len(ea) == 2 5342 and isinstance(ea[0], tuple) 5343 and len(ea[0]) == 4 5344 and (ea[0][0] is None or isinstance(ea[0][0], base.Domain)) 5345 and (ea[0][1] is None or isinstance(ea[0][1], base.Zone)) 5346 and ( 5347 ea[0][2] is None 5348 or isinstance(ea[0][2], base.DecisionName) 5349 ) 5350 and isinstance(ea[0][3], base.MechanismName) 5351 and isinstance(ea[1], base.MechanismState) 5352 ): 5353 mID = now.graph.resolveMechanism(ea[0], ctx.searchFrom) 5354 stateNow = base.stateOfMechanism(ctx, mID) 5355 if not base.mechanismInStateOrEquivalent( 5356 mID, 5357 ea[1], 5358 ctx 5359 ): 5360 self.warn( 5361 f"'state' annotation expects mechanism {mID}" 5362 f" {ea[0]!r} to be in state {ea[1]!r} but" 5363 f" its current state is {stateNow!r} and no" 5364 f" equivalence makes it count as being in" 5365 f" state {ea[1]!r}." 5366 ) 5367 else: 5368 self.warn( 5369 f"'state' annotation expects mechanism state" 5370 f" {a[6:]!r} but that's not a mechanism/state" 5371 f" pair." 5372 ) 5373 elif a.startswith("exists:"): 5374 expects = pf.parseDecisionSpecifier(a[7:]) 5375 try: 5376 now.graph.resolveDecision(expects) 5377 except core.MissingDecisionError: 5378 self.warn( 5379 f"'exists' annotation expects decision" 5380 f" {a[7:]!r} but that decision does not exist." 5381 ) 5382 5383 def recordAnnotateDecision( 5384 self, 5385 *annotations: base.Annotation 5386 ) -> None: 5387 """ 5388 Records annotations to be applied to the current decision. 5389 """ 5390 now = self.exploration.getSituation() 5391 now.graph.annotateDecision(self.definiteDecisionTarget(), annotations) 5392 5393 def recordAnnotateTranstion( 5394 self, 5395 *annotations: base.Annotation 5396 ) -> None: 5397 """ 5398 Records annotations to be applied to the most-recently-defined 5399 or -taken transition. 5400 """ 5401 target = self.currentTransitionTarget() 5402 if target is None: 5403 raise JournalParseError( 5404 "Cannot annotate a transition because there is no" 5405 " current transition." 5406 ) 5407 5408 now = self.exploration.getSituation() 5409 now.graph.annotateTransition(*target, annotations) 5410 5411 def recordAnnotateReciprocal( 5412 self, 5413 *annotations: base.Annotation 5414 ) -> None: 5415 """ 5416 Records annotations to be applied to the reciprocal of the 5417 most-recently-defined or -taken transition. 5418 """ 5419 target = self.currentReciprocalTarget() 5420 if target is None: 5421 raise JournalParseError( 5422 "Cannot annotate a reciprocal because there is no" 5423 " current transition or because it doens't have a" 5424 " reciprocal." 5425 ) 5426 5427 now = self.exploration.getSituation() 5428 now.graph.annotateTransition(*target, annotations) 5429 5430 def recordAnnotateZone( 5431 self, 5432 level, 5433 *annotations: base.Annotation 5434 ) -> None: 5435 """ 5436 Records annotations to be applied to the zone at the specified 5437 hierarchy level which contains the current decision. If there are 5438 multiple such zones, it picks the smallest one, breaking ties 5439 alphabetically by zone name (see `currentZoneAtLevel`). 5440 """ 5441 applyTo = self.currentZoneAtLevel(level) 5442 self.exploration.getSituation().graph.annotateZone( 5443 applyTo, 5444 annotations 5445 ) 5446 5447 def recordContextSwap( 5448 self, 5449 targetContext: Optional[base.FocalContextName] 5450 ) -> None: 5451 """ 5452 Records a swap of the active focal context, and/or a swap into 5453 "common"-context mode where all effects modify the common focal 5454 context instead of the active one. Use `None` as the argument to 5455 swap to common mode; use another specific value so swap to 5456 normal mode and set that context as the active one. 5457 5458 In relative mode, swaps the active context without adding an 5459 exploration step. Swapping into the common context never results 5460 in a new exploration step. 5461 """ 5462 if targetContext is None: 5463 self.context['context'] = "common" 5464 else: 5465 self.context['context'] = "active" 5466 e = self.getExploration() 5467 if self.inRelativeMode: 5468 e.setActiveContext(targetContext) 5469 else: 5470 e.advanceSituation(('swap', targetContext)) 5471 5472 def recordZone(self, level: int, zone: base.Zone) -> None: 5473 """ 5474 Records a new current zone to be swapped with the zone(s) at the 5475 specified hierarchy level for the current decision target. See 5476 `core.DiscreteExploration.reZone` and 5477 `core.DecisionGraph.replaceZonesInHierarchy` for details on what 5478 exactly happens; the summary is that the zones at the specified 5479 hierarchy level are replaced with the provided zone (which is 5480 created if necessary) and their children are re-parented onto 5481 the provided zone, while that zone is also set as a child of 5482 their parents. 5483 5484 Does the same thing in relative mode as in normal mode. 5485 """ 5486 self.exploration.reZone( 5487 zone, 5488 self.definiteDecisionTarget(), 5489 level 5490 ) 5491 5492 def recordUnify( 5493 self, 5494 merge: base.AnyDecisionSpecifier, 5495 mergeInto: Optional[base.AnyDecisionSpecifier] = None 5496 ) -> None: 5497 """ 5498 Records a unification between two decisions. This marks an 5499 observation that they are actually the same decision and it 5500 merges them. If only one decision is given the current decision 5501 is merged into that one. After the merge, the first decision (or 5502 the current decision if only one was given) will no longer 5503 exist. 5504 5505 If one of the merged decisions was the current position in a 5506 singular-focalized domain, or one of the current positions in a 5507 plural- or spreading-focalized domain, the merged decision will 5508 replace it as a current decision after the merge, and this 5509 happens even when in relative mode. The target decision is also 5510 updated if it needs to be. 5511 5512 A `TransitionCollisionError` will be raised if the two decisions 5513 have outgoing transitions that share a name. 5514 5515 Logs a `JournalParseWarning` if the two decisions were in 5516 different zones. 5517 5518 Any transitions between the two merged decisions will remain in 5519 place as actions. 5520 5521 TODO: Option for removing self-edges after the merge? Option for 5522 doing that for just effect-less edges? 5523 """ 5524 if mergeInto is None: 5525 mergeInto = merge 5526 merge = self.definiteDecisionTarget() 5527 5528 if isinstance(merge, str): 5529 merge = self.parseFormat.parseDecisionSpecifier(merge) 5530 5531 if isinstance(mergeInto, str): 5532 mergeInto = self.parseFormat.parseDecisionSpecifier(mergeInto) 5533 5534 now = self.exploration.getSituation() 5535 5536 if not isinstance(merge, base.DecisionID): 5537 merge = now.graph.resolveDecision(merge) 5538 5539 merge = cast(base.DecisionID, merge) 5540 5541 now.graph.mergeDecisions(merge, mergeInto) 5542 5543 mergedID = now.graph.resolveDecision(mergeInto) 5544 5545 # Update FocalContexts & ObservationContexts as necessary 5546 self.cleanupContexts(remapped={merge: mergedID}) 5547 5548 def recordUnifyTransition(self, target: base.Transition) -> None: 5549 """ 5550 Records a unification between the most-recently-defined or 5551 -taken transition and the specified transition (which must be 5552 outgoing from the same decision). This marks an observation that 5553 two transitions are actually the same transition and it merges 5554 them. 5555 5556 After the merge, the target transition will still exist but the 5557 previously most-recent transition will have been deleted. 5558 5559 Their reciprocals will also be merged. 5560 5561 A `JournalParseError` is raised if there is no most-recent 5562 transition. 5563 """ 5564 now = self.exploration.getSituation() 5565 graph = now.graph 5566 affected = self.currentTransitionTarget() 5567 if affected is None or affected[1] is None: 5568 raise JournalParseError( 5569 "Cannot unify transitions: there is no current" 5570 " transition." 5571 ) 5572 5573 decision, transition = affected 5574 5575 # If they don't share a target, then the current transition must 5576 # lead to an unknown node, which we will dispose of 5577 destination = graph.getDestination(decision, transition) 5578 if destination is None: 5579 raise JournalParseError( 5580 f"Cannot unify transitions: transition" 5581 f" {transition!r} at decision" 5582 f" {graph.identityOf(decision)} has no destination." 5583 ) 5584 5585 finalDestination = graph.getDestination(decision, target) 5586 if finalDestination is None: 5587 raise JournalParseError( 5588 f"Cannot unify transitions: transition" 5589 f" {target!r} at decision {graph.identityOf(decision)}" 5590 f" has no destination." 5591 ) 5592 5593 if destination != finalDestination: 5594 if graph.isConfirmed(destination): 5595 raise JournalParseError( 5596 f"Cannot unify transitions: destination" 5597 f" {graph.identityOf(destination)} of transition" 5598 f" {transition!r} at decision" 5599 f" {graph.identityOf(decision)} is not an" 5600 f" unconfirmed decision." 5601 ) 5602 # Retarget and delete the unknown node that we abandon 5603 # TODO: Merge nodes instead? 5604 now.graph.retargetTransition( 5605 decision, 5606 transition, 5607 finalDestination 5608 ) 5609 now.graph.removeDecision(destination) 5610 5611 # Now we can merge transitions 5612 now.graph.mergeTransitions(decision, transition, target) 5613 5614 # Update targets if they were merged 5615 self.cleanupContexts( 5616 remappedTransitions={ 5617 (decision, transition): (decision, target) 5618 } 5619 ) 5620 5621 def recordUnifyReciprocal( 5622 self, 5623 target: base.Transition 5624 ) -> None: 5625 """ 5626 Records a unification between the reciprocal of the 5627 most-recently-defined or -taken transition and the specified 5628 transition, which must be outgoing from the current transition's 5629 destination. This marks an observation that two transitions are 5630 actually the same transition and it merges them, deleting the 5631 original reciprocal. Note that the current transition will also 5632 be merged with the reciprocal of the target. 5633 5634 A `JournalParseError` is raised if there is no current 5635 transition, or if it does not have a reciprocal. 5636 """ 5637 now = self.exploration.getSituation() 5638 graph = now.graph 5639 affected = self.currentReciprocalTarget() 5640 if affected is None or affected[1] is None: 5641 raise JournalParseError( 5642 "Cannot unify transitions: there is no current" 5643 " transition." 5644 ) 5645 5646 decision, transition = affected 5647 5648 destination = graph.destination(decision, transition) 5649 reciprocal = graph.getReciprocal(decision, transition) 5650 if reciprocal is None: 5651 raise JournalParseError( 5652 "Cannot unify reciprocal: there is no reciprocal of the" 5653 " current transition." 5654 ) 5655 5656 # If they don't share a target, then the current transition must 5657 # lead to an unknown node, which we will dispose of 5658 finalDestination = graph.getDestination(destination, target) 5659 if finalDestination is None: 5660 raise JournalParseError( 5661 f"Cannot unify reciprocal: transition" 5662 f" {target!r} at decision" 5663 f" {graph.identityOf(destination)} has no destination." 5664 ) 5665 5666 if decision != finalDestination: 5667 if graph.isConfirmed(decision): 5668 raise JournalParseError( 5669 f"Cannot unify reciprocal: destination" 5670 f" {graph.identityOf(decision)} of transition" 5671 f" {reciprocal!r} at decision" 5672 f" {graph.identityOf(destination)} is not an" 5673 f" unconfirmed decision." 5674 ) 5675 # Retarget and delete the unknown node that we abandon 5676 # TODO: Merge nodes instead? 5677 graph.retargetTransition( 5678 destination, 5679 reciprocal, 5680 finalDestination 5681 ) 5682 graph.removeDecision(decision) 5683 5684 # Actually merge the transitions 5685 graph.mergeTransitions(destination, reciprocal, target) 5686 5687 # Update targets if they were merged 5688 self.cleanupContexts( 5689 remappedTransitions={ 5690 (decision, transition): (decision, target) 5691 } 5692 ) 5693 5694 def recordObviate( 5695 self, 5696 transition: base.Transition, 5697 otherDecision: base.AnyDecisionSpecifier, 5698 otherTransition: base.Transition 5699 ) -> None: 5700 """ 5701 Records the obviation of a transition at another decision. This 5702 is the observation that a specific transition at the current 5703 decision is the reciprocal of a different transition at another 5704 decision which previously led to an unknown area. The difference 5705 between this and `recordReturn` is that `recordReturn` logs 5706 movement across the newly-connected transition, while this 5707 leaves the player at their original decision (and does not even 5708 add a step to the current exploration). 5709 5710 Both transitions will be created if they didn't already exist. 5711 5712 In relative mode does the same thing and doesn't move the current 5713 decision across the transition updated. 5714 5715 If the destination is unknown, it will remain unknown after this 5716 operation. 5717 """ 5718 now = self.exploration.getSituation() 5719 graph = now.graph 5720 here = self.definiteDecisionTarget() 5721 5722 if isinstance(otherDecision, str): 5723 otherDecision = self.parseFormat.parseDecisionSpecifier( 5724 otherDecision 5725 ) 5726 5727 # If we started with a name or some other kind of decision 5728 # specifier, replace missing domain and/or zone info with info 5729 # from the current decision. 5730 if isinstance(otherDecision, base.DecisionSpecifier): 5731 otherDecision = base.spliceDecisionSpecifiers( 5732 otherDecision, 5733 self.decisionTargetSpecifier() 5734 ) 5735 5736 otherDestination = graph.getDestination( 5737 otherDecision, 5738 otherTransition 5739 ) 5740 if otherDestination is not None: 5741 if graph.isConfirmed(otherDestination): 5742 raise JournalParseError( 5743 f"Cannot obviate transition {otherTransition!r} at" 5744 f" decision {graph.identityOf(otherDecision)}: that" 5745 f" transition leads to decision" 5746 f" {graph.identityOf(otherDestination)} which has" 5747 f" already been visited." 5748 ) 5749 else: 5750 # We must create the other destination 5751 graph.addUnexploredEdge(otherDecision, otherTransition) 5752 5753 destination = graph.getDestination(here, transition) 5754 if destination is not None: 5755 if graph.isConfirmed(destination): 5756 raise JournalParseError( 5757 f"Cannot obviate using transition {transition!r} at" 5758 f" decision {graph.identityOf(here)}: that" 5759 f" transition leads to decision" 5760 f" {graph.identityOf(destination)} which is not an" 5761 f" unconfirmed decision." 5762 ) 5763 else: 5764 # we need to create it 5765 graph.addUnexploredEdge(here, transition) 5766 5767 # Track exploration status of destination (because 5768 # `replaceUnconfirmed` will overwrite it but we want to preserve 5769 # it in this case. 5770 if otherDecision is not None: 5771 prevStatus = base.explorationStatusOf(now, otherDecision) 5772 5773 # Now connect the transitions and clean up the unknown nodes 5774 graph.replaceUnconfirmed( 5775 here, 5776 transition, 5777 otherDecision, 5778 otherTransition 5779 ) 5780 # Restore exploration status 5781 base.setExplorationStatus(now, otherDecision, prevStatus) 5782 5783 # Update context 5784 self.context['transition'] = (here, transition) 5785 5786 def cleanupContexts( 5787 self, 5788 remapped: Optional[Dict[base.DecisionID, base.DecisionID]] = None, 5789 remappedTransitions: Optional[ 5790 Dict[ 5791 Tuple[base.DecisionID, base.Transition], 5792 Tuple[base.DecisionID, base.Transition] 5793 ] 5794 ] = None 5795 ) -> None: 5796 """ 5797 Checks the validity of context decision and transition entries, 5798 and sets them to `None` in situations where they are no longer 5799 valid, affecting both the current and stored contexts. 5800 5801 Also updates position information in focal contexts in the 5802 current exploration step. 5803 5804 If a `remapped` dictionary is provided, decisions in the keys of 5805 that dictionary will be replaced with the corresponding value 5806 before being checked. 5807 5808 Similarly a `remappedTransitions` dicitonary may provide info on 5809 renamed transitions using (`base.DecisionID`, `base.Transition`) 5810 pairs as both keys and values. 5811 """ 5812 if remapped is None: 5813 remapped = {} 5814 5815 if remappedTransitions is None: 5816 remappedTransitions = {} 5817 5818 # Fix broken position information in the current focal contexts 5819 now = self.exploration.getSituation() 5820 graph = now.graph 5821 state = now.state 5822 for ctx in ( 5823 state['common'], 5824 state['contexts'][state['activeContext']] 5825 ): 5826 active = ctx['activeDecisions'] 5827 for domain in active: 5828 aVal = active[domain] 5829 if isinstance(aVal, base.DecisionID): 5830 if aVal in remapped: # check for remap 5831 aVal = remapped[aVal] 5832 active[domain] = aVal 5833 if graph.getDecision(aVal) is None: # Ultimately valid? 5834 active[domain] = None 5835 elif isinstance(aVal, dict): 5836 for fpName in aVal: 5837 fpVal = aVal[fpName] 5838 if fpVal is None: 5839 aVal[fpName] = None 5840 elif fpVal in remapped: # check for remap 5841 aVal[fpName] = remapped[fpVal] 5842 elif graph.getDecision(fpVal) is None: # valid? 5843 aVal[fpName] = None 5844 elif isinstance(aVal, set): 5845 for r in remapped: 5846 if r in aVal: 5847 aVal.remove(r) 5848 aVal.add(remapped[r]) 5849 discard = [] 5850 for dID in aVal: 5851 if graph.getDecision(dID) is None: 5852 discard.append(dID) 5853 for dID in discard: 5854 aVal.remove(dID) 5855 elif aVal is not None: 5856 raise RuntimeError( 5857 f"Invalid active decisions for domain" 5858 f" {repr(domain)}: {repr(aVal)}" 5859 ) 5860 5861 # Fix up our ObservationContexts 5862 fix = [self.context] 5863 if self.storedContext is not None: 5864 fix.append(self.storedContext) 5865 5866 graph = self.exploration.getSituation().graph 5867 for obsCtx in fix: 5868 cdID = obsCtx['decision'] 5869 if cdID in remapped: 5870 cdID = remapped[cdID] 5871 obsCtx['decision'] = cdID 5872 5873 if cdID not in graph: 5874 obsCtx['decision'] = None 5875 5876 transition = obsCtx['transition'] 5877 if transition is not None: 5878 tSourceID = transition[0] 5879 if tSourceID in remapped: 5880 tSourceID = remapped[tSourceID] 5881 obsCtx['transition'] = (tSourceID, transition[1]) 5882 5883 if transition in remappedTransitions: 5884 obsCtx['transition'] = remappedTransitions[transition] 5885 5886 tDestID = graph.getDestination(tSourceID, transition[1]) 5887 if tDestID is None: 5888 obsCtx['transition'] = None 5889 5890 def recordExtinguishDecision( 5891 self, 5892 target: base.AnyDecisionSpecifier 5893 ) -> None: 5894 """ 5895 Records the deletion of a decision. The decision and all 5896 transitions connected to it will be removed from the current 5897 graph. Does not create a new exploration step. If the current 5898 position is deleted, the position will be set to `None`, or if 5899 we're in relative mode, the decision target will be set to 5900 `None` if it gets deleted. Likewise, all stored and/or current 5901 transitions which no longer exist are erased to `None`. 5902 """ 5903 # Erase target if it's going to be removed 5904 now = self.exploration.getSituation() 5905 5906 if isinstance(target, str): 5907 target = self.parseFormat.parseDecisionSpecifier(target) 5908 5909 # TODO: Do we need to worry about the node being part of any 5910 # focal context data structures? 5911 5912 # Actually remove it 5913 now.graph.removeDecision(target) 5914 5915 # Clean up our contexts 5916 self.cleanupContexts() 5917 5918 def recordExtinguishTransition( 5919 self, 5920 source: base.AnyDecisionSpecifier, 5921 target: base.Transition, 5922 deleteReciprocal: bool = True 5923 ) -> None: 5924 """ 5925 Records the deletion of a named transition coming from a 5926 specific source. The reciprocal will also be removed, unless 5927 `deleteReciprocal` is set to False. If `deleteReciprocal` is 5928 used and this results in the complete isolation of an unknown 5929 node, that node will be deleted as well. Cleans up any saved 5930 transition targets that are no longer valid by setting them to 5931 `None`. Does not create a graph step. 5932 """ 5933 now = self.exploration.getSituation() 5934 graph = now.graph 5935 dest = graph.destination(source, target) 5936 5937 # Remove the transition 5938 graph.removeTransition(source, target, deleteReciprocal) 5939 5940 # Remove the old destination if it's unconfirmed and no longer 5941 # connected anywhere 5942 if ( 5943 not graph.isConfirmed(dest) 5944 and len(graph.destinationsFrom(dest)) == 0 5945 ): 5946 graph.removeDecision(dest) 5947 5948 # Clean up our contexts 5949 self.cleanupContexts() 5950 5951 def recordComplicate( 5952 self, 5953 target: base.Transition, 5954 newDecision: base.DecisionName, # TODO: Allow zones/domain here 5955 newReciprocal: Optional[base.Transition], 5956 newReciprocalReciprocal: Optional[base.Transition] 5957 ) -> base.DecisionID: 5958 """ 5959 Records the complication of a transition and its reciprocal into 5960 a new decision. The old transition and its old reciprocal (if 5961 there was one) both point to the new decision. The 5962 `newReciprocal` becomes the new reciprocal of the original 5963 transition, and the `newReciprocalReciprocal` becomes the new 5964 reciprocal of the old reciprocal. Either may be set explicitly to 5965 `None` to leave the corresponding new transition without a 5966 reciprocal (but they don't default to `None`). If there was no 5967 old reciprocal, but `newReciprocalReciprocal` is specified, then 5968 that transition is created linking the new node to the old 5969 destination, without a reciprocal. 5970 5971 The current decision & transition information is not updated. 5972 5973 Returns the decision ID for the new node. 5974 """ 5975 now = self.exploration.getSituation() 5976 graph = now.graph 5977 here = self.definiteDecisionTarget() 5978 domain = graph.domainFor(here) 5979 5980 oldDest = graph.destination(here, target) 5981 oldReciprocal = graph.getReciprocal(here, target) 5982 5983 # Create the new decision: 5984 newID = graph.addDecision(newDecision, domain=domain) 5985 # Note that the new decision is NOT an unknown decision 5986 # We copy the exploration status from the current decision 5987 self.exploration.setExplorationStatus( 5988 newID, 5989 self.exploration.getExplorationStatus(here) 5990 ) 5991 # Copy over zone info 5992 for zp in graph.zoneParents(here): 5993 graph.addDecisionToZone(newID, zp) 5994 5995 # Retarget the transitions 5996 graph.retargetTransition( 5997 here, 5998 target, 5999 newID, 6000 swapReciprocal=False 6001 ) 6002 if oldReciprocal is not None: 6003 graph.retargetTransition( 6004 oldDest, 6005 oldReciprocal, 6006 newID, 6007 swapReciprocal=False 6008 ) 6009 6010 # Add a new reciprocal edge 6011 if newReciprocal is not None: 6012 graph.addTransition(newID, newReciprocal, here) 6013 graph.setReciprocal(here, target, newReciprocal) 6014 6015 # Add a new double-reciprocal edge (even if there wasn't a 6016 # reciprocal before) 6017 if newReciprocalReciprocal is not None: 6018 graph.addTransition( 6019 newID, 6020 newReciprocalReciprocal, 6021 oldDest 6022 ) 6023 if oldReciprocal is not None: 6024 graph.setReciprocal( 6025 oldDest, 6026 oldReciprocal, 6027 newReciprocalReciprocal 6028 ) 6029 6030 return newID 6031 6032 def recordRevert( 6033 self, 6034 slot: base.SaveSlot, 6035 aspects: Set[str], 6036 decisionType: base.DecisionType = 'active' 6037 ) -> None: 6038 """ 6039 Records a reversion to a previous state (possibly for only some 6040 aspects of the current state). See `base.revertedState` for the 6041 allowed values and meanings of strings in the aspects set. 6042 Uses the specified decision type, or 'active' by default. 6043 6044 Reversion counts as an exploration step. 6045 6046 This sets the current decision to the primary decision for the 6047 reverted state (which might be `None` in some cases) and sets 6048 the current transition to None. 6049 """ 6050 self.exploration.revert(slot, aspects, decisionType=decisionType) 6051 newPrimary = self.exploration.getSituation().state['primaryDecision'] 6052 self.context['decision'] = newPrimary 6053 self.context['transition'] = None 6054 6055 def recordFulfills( 6056 self, 6057 requirement: Union[str, base.Requirement], 6058 fulfilled: Union[ 6059 base.Capability, 6060 Tuple[base.MechanismID, base.MechanismState] 6061 ] 6062 ) -> None: 6063 """ 6064 Records the observation that a certain requirement fulfills the 6065 same role as (i.e., is equivalent to) a specific capability, or a 6066 specific mechanism being in a specific state. Transitions that 6067 require that capability or mechanism state will count as 6068 traversable even if that capability is not obtained or that 6069 mechanism is in another state, as long as the requirement for the 6070 fulfillment is satisfied. If multiple equivalences are 6071 established, any one of them being satisfied will count as that 6072 capability being obtained (or the mechanism being in the 6073 specified state). Note that if a circular dependency is created, 6074 the capability or mechanism (unless actually obtained or in the 6075 target state) will be considered as not being obtained (or in the 6076 target state) during recursive checks. 6077 """ 6078 if isinstance(requirement, str): 6079 requirement = self.parseFormat.parseRequirement(requirement) 6080 6081 self.getExploration().getSituation().graph.addEquivalence( 6082 requirement, 6083 fulfilled 6084 ) 6085 6086 def recordFocusOn( 6087 self, 6088 newFocalPoint: base.FocalPointName, 6089 inDomain: Optional[base.Domain] = None, 6090 inCommon: bool = False 6091 ): 6092 """ 6093 Records a swap to a new focal point, setting that focal point as 6094 the active focal point in the observer's current domain, or in 6095 the specified domain if one is specified. 6096 6097 A `JournalParseError` is raised if the current/specified domain 6098 does not have plural focalization. If it doesn't have a focal 6099 point with that name, then one is created and positioned at the 6100 observer's current decision (which must be in the appropriate 6101 domain). 6102 6103 If `inCommon` is set to `True` (default is `False`) then the 6104 changes will be applied to the common context instead of the 6105 active context. 6106 6107 Note that this does *not* make the target domain active; use 6108 `recordDomainFocus` for that if you need to. 6109 """ 6110 if inDomain is None: 6111 inDomain = self.context['domain'] 6112 6113 if inCommon: 6114 ctx = self.getExploration().getCommonContext() 6115 else: 6116 ctx = self.getExploration().getActiveContext() 6117 6118 if ctx['focalization'].get('domain') != 'plural': 6119 raise JournalParseError( 6120 f"Domain {inDomain!r} does not exist or does not have" 6121 f" plural focalization, so we can't set a focal point" 6122 f" in it." 6123 ) 6124 6125 focalPointMap = ctx['activeDecisions'].setdefault(inDomain, {}) 6126 if not isinstance(focalPointMap, dict): 6127 raise RuntimeError( 6128 f"Plural-focalized domain {inDomain!r} has" 6129 f" non-dictionary active" 6130 f" decisions:\n{repr(focalPointMap)}" 6131 ) 6132 6133 if newFocalPoint not in focalPointMap: 6134 focalPointMap[newFocalPoint] = self.context['decision'] 6135 6136 self.context['focus'] = newFocalPoint 6137 self.context['decision'] = focalPointMap[newFocalPoint] 6138 6139 def recordDomainUnfocus( 6140 self, 6141 domain: base.Domain, 6142 inCommon: bool = False 6143 ): 6144 """ 6145 Records a domain losing focus. Does not raise an error if the 6146 target domain was not active (in that case, it doesn't need to 6147 do anything). 6148 6149 If `inCommon` is set to `True` (default is `False`) then the 6150 domain changes will be applied to the common context instead of 6151 the active context. 6152 """ 6153 if inCommon: 6154 ctx = self.getExploration().getCommonContext() 6155 else: 6156 ctx = self.getExploration().getActiveContext() 6157 6158 try: 6159 ctx['activeDomains'].remove(domain) 6160 except KeyError: 6161 pass 6162 6163 def recordDomainFocus( 6164 self, 6165 domain: base.Domain, 6166 exclusive: bool = False, 6167 inCommon: bool = False 6168 ): 6169 """ 6170 Records a domain gaining focus, activating that domain in the 6171 current focal context and setting it as the observer's current 6172 domain. If the domain named doesn't exist yet, it will be 6173 created first (with default focalization) and then focused. 6174 6175 If `exclusive` is set to `True` (default is `False`) then all 6176 other active domains will be deactivated. 6177 6178 If `inCommon` is set to `True` (default is `False`) then the 6179 domain changes will be applied to the common context instead of 6180 the active context. 6181 """ 6182 if inCommon: 6183 ctx = self.getExploration().getCommonContext() 6184 else: 6185 ctx = self.getExploration().getActiveContext() 6186 6187 if exclusive: 6188 ctx['activeDomains'] = set() 6189 6190 if domain not in ctx['focalization']: 6191 self.recordNewDomain(domain, inCommon=inCommon) 6192 else: 6193 ctx['activeDomains'].add(domain) 6194 6195 self.context['domain'] = domain 6196 6197 def recordNewDomain( 6198 self, 6199 domain: base.Domain, 6200 focalization: base.DomainFocalization = "singular", 6201 inCommon: bool = False 6202 ): 6203 """ 6204 Records a new domain, setting it up with the specified 6205 focalization. Sets that domain as an active domain and as the 6206 journal's current domain so that subsequent entries will create 6207 decisions in that domain. However, it does not activate any 6208 decisions within that domain. 6209 6210 Raises a `JournalParseError` if the specified domain already 6211 exists. 6212 6213 If `inCommon` is set to `True` (default is `False`) then the new 6214 domain will be made active in the common context instead of the 6215 active context. 6216 """ 6217 if inCommon: 6218 ctx = self.getExploration().getCommonContext() 6219 else: 6220 ctx = self.getExploration().getActiveContext() 6221 6222 if domain in ctx['focalization']: 6223 raise JournalParseError( 6224 f"Cannot create domain {domain!r}: that domain already" 6225 f" exists." 6226 ) 6227 6228 ctx['focalization'][domain] = focalization 6229 ctx['activeDecisions'][domain] = None 6230 ctx['activeDomains'].add(domain) 6231 self.context['domain'] = domain 6232 6233 def relative( 6234 self, 6235 where: Optional[base.AnyDecisionSpecifier] = None, 6236 transition: Optional[base.Transition] = None, 6237 ) -> None: 6238 """ 6239 Enters 'relative mode' where the exploration ceases to add new 6240 steps but edits can still be performed on the current graph. This 6241 also changes the current decision/transition settings so that 6242 edits can be applied anywhere. It can accept 0, 1, or 2 6243 arguments. With 0 arguments, it simply enters relative mode but 6244 maintains the current position as the target decision and the 6245 last-taken or last-created transition as the target transition 6246 (note that that transition usually originates at a different 6247 decision). With 1 argument, it sets the target decision to the 6248 decision named, and sets the target transition to None. With 2 6249 arguments, it sets the target decision to the decision named, and 6250 the target transition to the transition named, which must 6251 originate at that target decision. If the first argument is None, 6252 the current decision is used. 6253 6254 If given the name of a decision which does not yet exist, it will 6255 create that decision in the current graph, disconnected from the 6256 rest of the graph. In that case, it is an error to also supply a 6257 transition to target (you can use other commands once in relative 6258 mode to build more transitions and decisions out from the 6259 newly-created decision). 6260 6261 When called in relative mode, it updates the current position 6262 and/or decision, or if called with no arguments, it exits 6263 relative mode. When exiting relative mode, the current decision 6264 is set back to the graph's current position, and the current 6265 transition is set to whatever it was before relative mode was 6266 entered. 6267 6268 Raises a `TypeError` if a transition is specified without 6269 specifying a decision. Raises a `ValueError` if given no 6270 arguments and the exploration does not have a current position. 6271 Also raises a `ValueError` if told to target a specific 6272 transition which does not exist. 6273 6274 TODO: Example here! 6275 """ 6276 # TODO: Not this? 6277 if where is None: 6278 if transition is None and self.inRelativeMode: 6279 # If we're in relative mode, cancel it 6280 self.inRelativeMode = False 6281 6282 # Here we restore saved sate 6283 if self.storedContext is None: 6284 raise RuntimeError( 6285 "No stored context despite being in relative" 6286 "mode." 6287 ) 6288 self.context = self.storedContext 6289 self.storedContext = None 6290 6291 else: 6292 # Enter or stay in relative mode and set up the current 6293 # decision/transition as the targets 6294 6295 # Ensure relative mode 6296 self.inRelativeMode = True 6297 6298 # Store state 6299 self.storedContext = self.context 6300 where = self.storedContext['decision'] 6301 if where is None: 6302 raise ValueError( 6303 "Cannot enter relative mode at the current" 6304 " position because there is no current" 6305 " position." 6306 ) 6307 6308 self.context = observationContext( 6309 context=self.storedContext['context'], 6310 domain=self.storedContext['domain'], 6311 focus=self.storedContext['focus'], 6312 decision=where, 6313 transition=( 6314 None 6315 if transition is None 6316 else (where, transition) 6317 ) 6318 ) 6319 6320 else: # we have at least a decision to target 6321 # If we're entering relative mode instead of just changing 6322 # focus, we need to set up the current transition if no 6323 # transition was specified. 6324 entering: Optional[ 6325 Tuple[ 6326 base.ContextSpecifier, 6327 base.Domain, 6328 Optional[base.FocalPointName] 6329 ] 6330 ] = None 6331 if not self.inRelativeMode: 6332 # We'll be entering relative mode, so store state 6333 entering = ( 6334 self.context['context'], 6335 self.context['domain'], 6336 self.context['focus'] 6337 ) 6338 self.storedContext = self.context 6339 if transition is None: 6340 oldTransitionPair = self.context['transition'] 6341 if oldTransitionPair is not None: 6342 oldBase, oldTransition = oldTransitionPair 6343 if oldBase == where: 6344 transition = oldTransition 6345 6346 # Enter (or stay in) relative mode 6347 self.inRelativeMode = True 6348 6349 now = self.exploration.getSituation() 6350 whereID: Optional[base.DecisionID] 6351 whereSpec: Optional[base.DecisionSpecifier] = None 6352 if isinstance(where, str): 6353 where = self.parseFormat.parseDecisionSpecifier(where) 6354 # might turn it into a DecisionID 6355 6356 if isinstance(where, base.DecisionID): 6357 whereID = where 6358 elif isinstance(where, base.DecisionSpecifier): 6359 # Add in current zone + domain info if those things 6360 # aren't explicit 6361 if self.currentDecisionTarget() is not None: 6362 where = base.spliceDecisionSpecifiers( 6363 where, 6364 self.decisionTargetSpecifier() 6365 ) 6366 elif where.domain is None: 6367 # Splice in current domain if needed 6368 where = base.DecisionSpecifier( 6369 domain=self.context['domain'], 6370 zone=where.zone, 6371 name=where.name 6372 ) 6373 whereID = now.graph.getDecision(where) # might be None 6374 whereSpec = where 6375 else: 6376 raise TypeError(f"Invalid decision specifier: {where!r}") 6377 6378 # Create a new decision if necessary 6379 if whereID is None: 6380 if transition is not None: 6381 raise TypeError( 6382 f"Cannot specify a target transition when" 6383 f" entering relative mode at previously" 6384 f" non-existent decision" 6385 f" {now.graph.identityOf(where)}." 6386 ) 6387 assert whereSpec is not None 6388 whereID = now.graph.addDecision( 6389 whereSpec.name, 6390 domain=whereSpec.domain 6391 ) 6392 if whereSpec.zone is not None: 6393 now.graph.addDecisionToZone(whereID, whereSpec.zone) 6394 6395 # Create the new context if we're entering relative mode 6396 if entering is not None: 6397 self.context = observationContext( 6398 context=entering[0], 6399 domain=entering[1], 6400 focus=entering[2], 6401 decision=whereID, 6402 transition=( 6403 None 6404 if transition is None 6405 else (whereID, transition) 6406 ) 6407 ) 6408 6409 # Target the specified decision 6410 self.context['decision'] = whereID 6411 6412 # Target the specified transition 6413 if transition is not None: 6414 self.context['transition'] = (whereID, transition) 6415 if now.graph.getDestination(where, transition) is None: 6416 raise ValueError( 6417 f"Cannot target transition {transition!r} at" 6418 f" decision {now.graph.identityOf(where)}:" 6419 f" there is no such transition." 6420 ) 6421 # otherwise leave self.context['transition'] alone 6422 6423 6424#--------------------# 6425# Shortcut Functions # 6426#--------------------# 6427 6428def convertJournal( 6429 journal: str, 6430 fmt: Optional[JournalParseFormat] = None 6431) -> core.DiscreteExploration: 6432 """ 6433 Converts a journal in text format into a `core.DiscreteExploration` 6434 object, using a fresh `JournalObserver`. An optional `ParseFormat` 6435 may be specified if the journal doesn't follow the default parse 6436 format. 6437 """ 6438 obs = JournalObserver(fmt) 6439 obs.observe(journal) 6440 return obs.getExploration()
One of the types of entries that can be present in a journal. These can
be written out long form, or abbreviated using a single letter (see
DEFAULT_FORMAT
). Each journal line is either an entry or a continuation
of a previous entry. The available types are:
'P' / 'preference': Followed by a setting name and value, controls global preferences for journal processing.
'=' / 'alias': Followed by zero or more words and then a block of commands, this establishes an alias that can be used as a custom command. Within the command block, curly braces surrounding a word will be replaced by the argument in the same position that that word appears following the alias (for example, an alias defined using:
= redDoor name [ o {name} qb red ]
could be referenced using:
> redDoor door
and that reference would be equivalent to:
o door qb red
To help aliases be more flexible, if '_' is referenced between curly braces (or '_' followed by an integer), it will be substituted with an underscore followed by a unique number (these numbers will count up with each such reference used by a specific
JournalObserver
object). References within each alias substitution which are suffixed with the same digit (or which are unsuffixed) will get the same value. So for example, an alias:= savePoint [ o {_} x {_} {_1} {_2} gt toSavePoint a save At save t {_2} ]
when deployed twice like this:
> savePoint > savePoint
might translate to:
o _17 x _17 _18 _19 g savePoint a save At save t _19 o _20 x _20 _21 _22 g savePoint a save At save t _22
'>' / 'custom': Re-uses the code from a previously-defined alias. This command type is followed by an alias name and then one argument for each parameter of the named alias (see above for examples).
'?' / 'DEBUG': Prints out debugging information when executed. See
DebugAction
for the possible argument values anddoDebug
for more information on what they mean.'S" / 'START': Names the starting decision (or zone::decision pair). Must appear first except in journal fragments.
'x' / 'explore': Names a transition taken and the decision (or new-zone::decision) reached as a result, possibly with a name for the reciprocal transition which is created. Use 'zone' afterwards to swap around zones above level 0.
'r' / 'return': Names a transition taken and decision returned to, connecting a transition which previously connected to an unexplored area back to a known decision instead. May also include a reciprocal name.
'a' / 'action': Names an action taken at the current decision and may include effects and/or requirements. Use to declare a new action (including transforming what was thought to be a transition into an action). Use 'retrace' instead (preferably with the 'actionPart' part) to re-activate an existing action.
't' / 'retrace': Names a transition taken, where the destination is already explored. Works for actoins as well when 'actionPart' is specified, but it will raise an error if the type of transition (normal vs. action) doesn't match thid specifier.
'w' / 'wait': indicates a step of exploration where no transition is taken. You can use 'A' afterwards to apply effects in order to represent events that happen outside of player control. Use 'action' instead for player-initiated effects.
'p' / 'warp': Names a new decision (or zone::decision) to be at, but without adding a transition there from the previous decision. If no zone name is provided but the destination is a novel decision, it will be placed into the same zones as the origin.
'o' / 'observe': Names a transition observed from the current decision, or a transition plus destination if the destination is known, or a transition plus destination plus reciprocal if reciprocal information is also available. Observations don't create exploration steps.
'E' / 'END': Names an ending which is reached from the current decision via a new automatically-named transition.
'm' / 'mechanism': names a new mechanism at the current decision and puts it in a starting state.
'q' / 'requirement': Specifies a requirement to apply to the most-recently-defined transition or its reciprocal.
'e' / 'effect': Specifies a
base.Consequence
that gets added to the consequence for the currently-relevant transition (or its reciprocal or both ifreciprocalPart
orbothPart
is used). The remainder of the line (and/or the next few lines) should be parsable usingParseFormat.parseConsequence
, or if not, usingParseFormat.parseEffect
for a single effect.'A' / 'apply': Specifies an effect to be immediately applied to the current state, relative to the most-recently-taken or -defined transition. If a 'transitionPart' or 'reciprocalPart' target specifier is included, the effect will also be recorded as an effect in the current active
core.Consequence
context for the most recent transition or reciprocal, but otherwise it will just be applied without being stored in the graph. Note that effects which are hidden until activated should have their 'hidden' property set toTrue
, regardless of whether they're added to the graph before or after the transition they are associated with. Also, certain effects like 'bounce' cannot be applied retroactively.'g' / 'tag': Applies one or more tags to the current exploration step, or to the current decision if 'decisionPart' is specified, or to either the most-recently-taken transition or its reciprocal if 'transitionPart' or 'reciprocalPart' is specified. May also tag a zone by using 'zonePart'. Tags may have values associated with them; without a value provided the default value is the number 1.
'n' / 'annotate': Like 'tag' but applies an annotation, which is just a piece of text attached to. Certain annotations will trigger checks of various exploration state when applied to a step, and will emit warnings if the checks fail.Step annotations which begin with: * 'at:' - checks that the current primary decision matches the decision identified by the rest of the annotation. * 'active:' - checks that a decision is currently in the active decision set. * 'has:' - checks that the player has a specific amount of a certain token (write 'tokenamount' after 'has:', as in "has: coins3"). Will fail if the player doesn't have that amount, including if they have more. * 'level:' - checks that the level of the named skill matches a specific level (write 'skill^level', as in "level: bossFight^3"). This does not allow you to check
SkillCombination
effective levels; just individual skill levels. * 'can:' - checks that a requirement (parsed usingParseFormat.parseRequirement
) is satisfied. * 'state:' - checks that a mechanism is in a certain state (write 'mechanism:state', as in "level: doors:open"). * 'exists:' - checks that a decision exists.'c' / 'context': Specifies either 'commonContext' or the name of a specific focal context to activate. Focal contexts represent things like multiple characters or teams, and by default capabilities, tokens, and skills are all tied to a specific focal context. If the name given is anything other than the 'commonContext' value then that context will be swapped to active (and created as a blank context if necessary). TODO: THIS
'd' / 'domain': Specifies a domain name, swapping to that domain as the current domain, and setting it as an active domain in the current
core.FocalContext
. This does not de-activate other domains, but the journal has a notion of a single 'current' domain that entries will be applied to. If no focal point has been specified and we swap into a plural-focalized domain, the alphabetically-first focal point within that domain will be selected, but focal points for each domain are remembered when swapping back. Use the 'notApplicable' value after a domain name to deactivate that domain. Any other value after a domain name must be one of the 'focalizeSingular', 'focalizePlural', or 'focalizeSpreading' values to indicate that the domain uses that type of focalization. These should only be used when a domain is created, you cannot change the focalization type of a domain after creation. If no focalization type is given along with a new domain name, singular focalization will be used for that domain. If no domain is specified before performing the first action of an exploration, thecore.DEFAULT_DOMAIN
with singular focalization will be set up. TODO: THIS'f' / 'focus': Specifies a
core.FocalPointName
for the specific focal point that should be acted on by subsequent journal entries in a plural-focalized domain. Focal points represent things like individual units in a game where you have multiple units to control.May also specify a domain followed by a focal point name to change the focal point in a domain other than the current domain.
'z' / 'zone': Specifies a zone name and a level (via extra
zonePart
characters) that will replace the current zone at the given hierarchy level for the current decision. This is done using thecore.DiscreteExploration.reZone
method.'u' / 'unify': Specifies a decision with which the current decision will be unified (or two decisions that will be unified with each other), merging their transitions. The name of the merged decision is the name of the second decision specified (or the only decision specified when merging the current decision). Can instead target a transition or reciprocal to merge (which must be at the current decision), although the transition to merge with must either lead to the same destination or lead to an unknown destination (which will then be merged with the transition's destination). Any transitions between the two merged decisions will remain as actions at the new decision.
'v' / 'obviate': Specifies a transition at the current decision and a decision that it links to and updates that information, without actually crossing the transition. The reciprocal transition must also be specified, although one will be created if it didn't already exist. If the reciprocal does already exist, it must lead to an unknown decision.
'X' / 'extinguish': Deletes an transition at the current decision. If it leads to an unknown decision which is not otherwise connected to anything, this will also delete that decision (even if it already has tags or annotations or the like). Can also be used (with a decision target) to delete a decision, which will delete all transitions touching that decision. Note that usually, 'unify' is easier to manage than extinguish for manipulating decisions.
'C' / 'complicate': Takes a transition between two confirmed decisions and adds a new confirmed decision in the middle of it. The old ends of the transition both connect to the new decision, and new names are given to their new reciprocals. Does not change the player's position.
'.' / 'status': Sets the exploration status of the current decision (argument should be a
base.ExplorationStatus
). Without an argument, sets the status to 'explored'. When 'unfinishedPart' is given as a target specifier (once or twice), this instead prevents the decision from being automatically marked as 'explored' when we leave it.'R' / 'revert': Reverts some or all of the current state to a previously saved state. Saving happens via the 'save' effect type, but reverting is an explicit action. The first argument names the save slot to revert to, while the rest are interpreted as the set of aspects to revert (see
base.revertedState
).'F' / 'fulfills': Specifies a requirement and a capability, and adds an equivalence to the current graph such that if that requirement is fulfilled, the specified capability is considered to be active. This allows for later discovery of one or more powers which allow traversal of previously-marked transitions whose true requirements were unknown when they were discovered.
'@' / 'relative': Specifies a decision to be treated as the 'current decision' without actually setting the position there. Use the marker alone or twice (default '@ @') to enter relative mode at the current decision (or to exit it). Until used to reverse this effect, all position-changing entries change this relative position value instead of the actual position in the graph, and updates are applied to the current graph without creating new exploration steps or applying any effects. Useful for doing things like noting information about far-away locations disclosed in a cutscene. Can target a transition at the current node by specifying 'transitionPart' or two arguments for a decision and transition. In that case, the specified transition is counted as the 'most-recent-transition' for entry purposes and the same relative mode is entered.
The different parts that an entry can target. The signifiers for these
target types will be concatenated with a journal entry signifier in some
cases. For example, by default 'g' as an entry type means 'tag', and 't'
as a target type means 'transition'. So 'gt' as an entry type means 'tag
transition' and applies the relevant tag to the most-recently-created
transition instead of the most-recently-created decision. The
targetSeparator
character (default '@') is used to combine an entry
type with a target type when the entry type is written without
abbreviation. In that case, the target specifier may drop the suffix
'Part' (e.g., tag@transition
in place of gt
). The available target
parts are each valid only for specific entry types. The target parts are:
- 'decisionPart' - Use to specify that the entry applies to a decision when it would normally apply to something else.
- 'transitionPart' - Use to specify that the entry applies to a transition instead of a decision.
- 'reciprocalPart' - Use to specify that the entry applies to a reciprocal transition instead of a decision or the normal transition.
- 'bothPart' - Use to specify that the entry applies to both of two possibilities, such as to a transition and its reciprocal.
- 'zonePart' - Use for re-zoning to indicate the hierarchy level. May be repeated; each instance increases the hierarchy level by 1 starting from 0. In the long form to specify a hierarchy level, use the letter 'z' followed by an integer, as in 'z3' for level 3. Also used in the same way for tagging or annotating zones.
- 'actionPart' - Use for the 'observe' or 'retrace' entries to specify that the observed/retraced transition is an action (i.e., its destination is the same as its source) rather than a real transition (whose destination would be a new, unknown node).
- 'endingPart' - Use only for the 'observe' entry to specify that the observed transition goes to an ending rather than a normal decision.
- 'unfinishedPart' - Use only for the 'status' entry (and use either once or twice in the short form) to specify that a decision should NOT be finalized when we leave it.
The entry types where a target specifier can be applied are:
- 'requirement': By default these are applied to transitions, but the
'reciprocalPart' target can be used to apply to a reciprocal
instead. Use
bothPart
to apply the same requirement to both the transition and its reciprocal. - 'effect': Same as 'requirement'.
- 'apply': Same as 'effect' (and see above).
- 'tag': Applies the tag to the specified target instead of the current exploration step. When targeting zones using 'zonePart', if there are multiple zones that apply at a certain hierarchy level we target the smallest one (breaking ties alphabetically by name). TODO: target the most-recently asserted one. The 'zonePart' may be repeated to specify a hierarchy level, as in 'gzz' for level 1 instead of level 0, and you may also use 'z' followed by an integer, as in 'gz3' for level 3.
- 'annotation': Same as 'tag'.
- 'unify': By default applies to a decision, but can be applied to a transition or reciprocal instead.
- 'extinguish': By default applies to a transition and its reciprocal, but can be applied to just one or the other, or to a decision.
- 'relative': Only 'transition' applies here and changes the most-recent-transition value when entering relative mode instead of just changing the current-decision value. Can be used within relative mode to pick out an existing transition as well.
- 'zone': This is the main place where the 'zonePart' target type applies, and it can actually be applied as many times as you want. Each application makes the zone specified apply to a higher level in the hierarchy of zones, so that instead of swapping the level-0 zone using 'z', the level-1 zone can be changed using 'zz' or the level 2 zone using 'zzz', etc. In lieu of using multiple 'z's, you can also just write one 'z' followed by an integer for the level you want to use (e.g., z0 for a level-0 zone, or z1 for a level-1 zone). When using a long-form entry type, the target may be given as the string 'zone' in which case the level-1 zone is used. To use a different zone level with a long-form entry type, repeat the 'z' followed by an integer, or use multiple 'z's.
- 'observe': Uses the 'actionPart' and 'endingPart' target types, and
those are the only applicable target types. Applying
actionPart
turns the observed transition into an action; applyingendingPart
turns it into an transition to an ending. - 'retrace': Uses 'actionPart' or not to distinguish what kind of
transition is being taken. Riases a
JournalParseError
if the type of edge (external vs. self destination) doesn't match this distinction. - 'status': The only place where 'unfinishedPart' target type applies. Using it once or twice signifies that the decision should NOT be marked as completely-explored when we leave it.
Represents a part of the journal syntax which isn't an entry type but is used to mark something else. For example, the character denoting an unknown item. The available values are:
- 'on' / 'off': Used to indicate on/off status for preferences.
- 'domainFocalizationSingular' / 'domainFocalizationPlural'
/ 'domainFocalizationSpreading': Used as markers after a domain for
the
core.DomainFocalization
values. - 'commonContext': Used with 'context' in place of a
core.FocalContextName
to indicate that we are targeting the common focal context. - 'comment': Indicates extraneous text that should be ignored by the journal parser. Note that tags and/or annotations should usually be used to apply comments that will be accessible when viewing the exploration object.
- 'unknownItem': Used in place of an item name to indicate that although an item is known to exist, it's not yet know what that item is. Note that when journaling, you should make up names for items you pick up, even if you don't know what they do yet. This notation should only be used for items that you haven't picked up because they're inaccessible, and despite being apparent, you don't know what they are because they come in a container (e.g., you see a sealed chest, but you don't know what's in it).
- 'notApplicable': Used in certain positions to indicate that something is missing entirely or otherwise that a piece of information normally supplied is unnecessary. For example, when used as the reciprocal name for a transition, this will cause the reciprocal transition to be deleted entirely, or when used before a domain name with the 'domain' entry type it deactivates that domain. TODO
- 'exclusiveDomain': Used to indicate that a domain being activated should deactivate other domains, instead of being activated along with them.
- 'targetSeparator': Used in long-form entry types to separate the entry type from a target specifier when a target is specified. Default is '@'. For example, a 'gt' entry (tag transition) would be expressed as 'tag@transition' in the long form.
- 'reciprocalSeparator': Used to indicate, within a requirement or a tag set, a separation between requirements/tags to be applied to the forward direction and requirements/tags to be applied to the reverse direction. Not always applicable (e.g., actions have no reverse direction).
- 'transitionAtDecision' Used to separate a decision name from a transition name when identifying a specific transition.
- 'blockDelimiters' Two characters used to delimit the start and end of a block of entries. Used for things like edit effects.
Any journal marker type.
A journal format is specified using a dictionary with keys that denote journal marker types and values which are one-to-several-character strings indicating the markup used for that entry/info type.
The default JournalFormat
dictionary.
The different kinds of debugging commands.
660class JournalParseFormat(parsing.ParseFormat): 661 """ 662 A ParseFormat manages the mapping from markers to entry types and 663 vice versa. 664 """ 665 def __init__( 666 self, 667 formatDict: parsing.Format = parsing.DEFAULT_FORMAT, 668 journalMarkers: JournalFormat = DEFAULT_FORMAT 669 ): 670 """ 671 Sets up the parsing format. Accepts base and/or journal format 672 dictionaries, but they both have defaults (see `DEFAULT_FORMAT` 673 and `parsing.DEFAULT_FORMAT`). Raises a `ValueError` unless the 674 keys of the format dictionaries exactly match the required 675 values (the `parsing.Lexeme` values for the base format and the 676 `JournalMarkerType` values for the journal format). 677 """ 678 super().__init__(formatDict) 679 self.journalMarkers: JournalFormat = journalMarkers 680 681 # Build comment regular expression 682 self.commentRE = re.compile( 683 self.journalMarkers.get('comment', '#') + '.*$', 684 flags=re.MULTILINE 685 ) 686 687 # Get block delimiters 688 blockDelimiters = journalMarkers.get('blockDelimiters', '[]') 689 if len(blockDelimiters) != 2: 690 raise ValueError( 691 f"Block delimiters must be a length-2 string containing" 692 f" the start and end markers. Got: {blockDelimiters!r}." 693 ) 694 blockStart = blockDelimiters[0] 695 blockEnd = blockDelimiters[1] 696 self.blockStart = blockStart 697 self.blockEnd = blockEnd 698 699 # Add backslash for literal if it's an RE special char 700 if blockStart in '[]()*.?^$&+\\': 701 blockStart = '\\' + blockStart 702 if blockEnd in '[]()*.?^$&+\\': 703 blockEnd = '\\' + blockEnd 704 705 # Build block start and end regular expressions 706 self.blockStartRE = re.compile( 707 blockStart + r'\s*$', 708 flags=re.MULTILINE 709 ) 710 self.blockEndRE = re.compile( 711 r'^\s*' + blockEnd, 712 flags=re.MULTILINE 713 ) 714 715 # Check that journalMarkers doesn't have any extra keys 716 markerTypes = ( 717 get_args(JournalEntryType) 718 + get_args(base.DecisionType) 719 + get_args(JournalTargetType) 720 + get_args(JournalInfoType) 721 ) 722 for key in journalMarkers: 723 if key not in markerTypes: 724 raise ValueError( 725 f"Format dict has key {key!r} which is not a" 726 f" recognized entry or info type." 727 ) 728 729 # Check completeness of formatDict 730 for mtype in markerTypes: 731 if mtype not in journalMarkers: 732 raise ValueError( 733 f"Journal markers dict is missing an entry for" 734 f" marker type {mtype!r}." 735 ) 736 737 # Build reverse dictionaries from markers to entry types and 738 # from markers to target types (no reverse needed for info 739 # types). 740 self.entryMap: Dict[str, JournalEntryType] = {} 741 self.targetMap: Dict[str, JournalTargetType] = {} 742 entryTypes = set(get_args(JournalEntryType)) 743 targetTypes = set(get_args(JournalTargetType)) 744 745 # Check for duplicates and create reverse maps 746 for name, marker in journalMarkers.items(): 747 if name in entryTypes: 748 # Duplicates not allowed among entry types 749 if marker in self.entryMap: 750 raise ValueError( 751 f"Format dict entry for {name!r} duplicates" 752 f" previous format dict entry for" 753 f" {self.entryMap[marker]!r}." 754 ) 755 756 # Map markers to entry types 757 self.entryMap[marker] = cast(JournalEntryType, name) 758 elif name in targetTypes: 759 # Duplicates not allowed among entry types 760 if marker in self.targetMap: 761 raise ValueError( 762 f"Format dict entry for {name!r} duplicates" 763 f" previous format dict entry for" 764 f" {self.targetMap[marker]!r}." 765 ) 766 767 # Map markers to entry types 768 self.targetMap[marker] = cast(JournalTargetType, name) 769 770 # else ignore it since it's an info type 771 772 def markers(self) -> List[str]: 773 """ 774 Returns the list of all entry-type markers (but not other kinds 775 of markers), sorted from longest to shortest to help avoid 776 ambiguities when matching. 777 """ 778 entryTypes = get_args(JournalEntryType) 779 return sorted( 780 ( 781 m 782 for (et, m) in self.journalMarkers.items() 783 if et in entryTypes 784 ), 785 key=lambda m: -len(m) 786 ) 787 788 def markerFor(self, markerType: JournalMarkerType) -> str: 789 """ 790 Returns the marker for the specified entry/info/effect/etc. 791 type. 792 """ 793 return self.journalMarkers[markerType] 794 795 def determineEntryType(self, entryBits: List[str]) -> Tuple[ 796 JournalEntryType, 797 base.DecisionType, 798 Union[None, JournalTargetType, Tuple[JournalTargetType, int]], 799 List[str] 800 ]: 801 """ 802 Given a sequence of strings that specify a command, returns a 803 tuple containing the entry type, decision type, target part, and 804 list of arguments for that command. The default decision type is 805 'active' but others can be specified with decision type 806 modifiers. If no target type was included, the third entry of 807 the return value will be `None`, and in the special case of 808 zones, it will be an integer indicating the hierarchy level 809 according to how many times the 'zonePart' target specifier was 810 present, default 0. 811 812 For example: 813 814 >>> pf = JournalParseFormat() 815 >>> pf.determineEntryType(['retrace', 'transition']) 816 ('retrace', 'active', None, ['transition']) 817 >>> pf.determineEntryType(['t', 'transition']) 818 ('retrace', 'active', None, ['transition']) 819 >>> pf.determineEntryType(['observe@action', 'open']) 820 ('observe', 'active', 'actionPart', ['open']) 821 >>> pf.determineEntryType(['oa', 'open']) 822 ('observe', 'active', 'actionPart', ['open']) 823 >>> pf.determineEntryType(['!explore', 'down', 'pit', 'up']) 824 ('explore', 'unintended', None, ['down', 'pit', 'up']) 825 >>> pf.determineEntryType(['imposed/explore', 'down', 'pit', 'up']) 826 ('explore', 'imposed', None, ['down', 'pit', 'up']) 827 >>> pf.determineEntryType(['~x', 'down', 'pit', 'up']) 828 ('explore', 'consequence', None, ['down', 'pit', 'up']) 829 >>> pf.determineEntryType(['>x', 'down', 'pit', 'up']) 830 ('explore', 'imposed', None, ['down', 'pit', 'up']) 831 >>> pf.determineEntryType(['gzz', 'tag']) 832 ('tag', 'active', ('zonePart', 1), ['tag']) 833 >>> pf.determineEntryType(['gz4', 'tag']) 834 ('tag', 'active', ('zonePart', 4), ['tag']) 835 >>> pf.determineEntryType(['zone@z2', 'ZoneName']) 836 ('zone', 'active', ('zonePart', 2), ['ZoneName']) 837 >>> pf.determineEntryType(['zzz', 'ZoneName']) 838 ('zone', 'active', ('zonePart', 2), ['ZoneName']) 839 """ 840 # Get entry specifier 841 entrySpecifier = entryBits[0] 842 entryArgs = entryBits[1:] 843 844 # Defaults 845 entryType: Optional[JournalEntryType] = None 846 entryTarget: Union[ 847 None, 848 JournalTargetType, 849 Tuple[JournalTargetType, int] 850 ] = None 851 entryDecisionType: base.DecisionType = 'active' 852 853 # Check for a decision type specifier and process+remove it 854 for decisionType in get_args(base.DecisionType): 855 marker = self.markerFor(decisionType) 856 lm = len(marker) 857 if ( 858 entrySpecifier.startswith(marker) 859 and len(entrySpecifier) > lm 860 ): 861 entrySpecifier = entrySpecifier[lm:] 862 entryDecisionType = decisionType 863 break 864 elif entrySpecifier.startswith( 865 decisionType + self.markerFor('reciprocalSeparator') 866 ): 867 entrySpecifier = entrySpecifier[len(decisionType) + 1:] 868 entryDecisionType = decisionType 869 break 870 871 # Sets of valid types and targets 872 validEntryTypes: Set[JournalEntryType] = set( 873 get_args(JournalEntryType) 874 ) 875 validEntryTargets: Set[JournalTargetType] = set( 876 get_args(JournalTargetType) 877 ) 878 879 # Look for a long-form entry specifier with an @ sign separating 880 # the entry type from the entry target 881 targetMarker = self.markerFor('targetSeparator') 882 if ( 883 targetMarker in entrySpecifier 884 and not entrySpecifier.startswith(targetMarker) 885 # Because the targetMarker is also a valid entry type! 886 ): 887 specifierBits = entrySpecifier.split(targetMarker) 888 if len(specifierBits) != 2: 889 raise JournalParseError( 890 f"When a long-form entry specifier contains a" 891 f" target separator, it must contain exactly one (to" 892 f" split the entry type from the entry target). We got" 893 f" {entrySpecifier!r}." 894 ) 895 entryTypeGuess: str 896 entryTargetGuess: Optional[str] 897 entryTypeGuess, entryTargetGuess = specifierBits 898 if entryTypeGuess not in validEntryTypes: 899 raise JournalParseError( 900 f"Invalid long-form entry type: {entryType!r}" 901 ) 902 else: 903 entryType = cast(JournalEntryType, entryTypeGuess) 904 905 # Special logic for zone part 906 handled = False 907 if entryType in ('zone', 'tag', 'annotate'): 908 handled = True 909 if entryType == 'zone' and entryTargetGuess.isdigit(): 910 entryTarget = ('zonePart', int(entryTargetGuess)) 911 elif entryTargetGuess == 'zone': 912 entryTarget = ('zonePart', 1 if entryType == 'zone' else 0) 913 elif ( 914 entryTargetGuess.startswith('z') 915 and entryTargetGuess[1:].isdigit() 916 ): 917 entryTarget = ('zonePart', int(entryTargetGuess[1:])) 918 elif ( 919 len(entryTargetGuess) > 0 920 and set(entryTargetGuess) != {'z'} 921 ): 922 if entryType == 'zone': 923 raise JournalParseError( 924 f"Invalid target specifier for" 925 f" zone entry:\n{entryTargetGuess}" 926 ) 927 else: 928 handled = False 929 else: 930 entryTarget = ('zonePart', len(entryTargetGuess)) 931 932 if not handled: 933 if entryTargetGuess + 'Part' in validEntryTargets: 934 entryTarget = cast( 935 JournalTargetType, 936 entryTargetGuess + 'Part' 937 ) 938 else: 939 origGuess = entryTargetGuess 940 entryTargetGuess = self.targetMap.get( 941 entryTargetGuess, 942 entryTargetGuess 943 ) 944 if entryTargetGuess not in validEntryTargets: 945 raise JournalParseError( 946 f"Invalid long-form entry target:" 947 f" {origGuess!r}" 948 ) 949 else: 950 entryTarget = cast( 951 JournalTargetType, 952 entryTargetGuess 953 ) 954 955 elif entrySpecifier in validEntryTypes: 956 # Might be a long-form specifier without a separator 957 entryType = cast(JournalEntryType, entrySpecifier) 958 entryTarget = None 959 if entryType == 'zone': 960 entryTarget = ('zonePart', 0) 961 962 else: # parse a short-form entry specifier 963 typeSpecifier = entrySpecifier[0] 964 if typeSpecifier not in self.entryMap: 965 raise JournalParseError( 966 f"Entry does not begin with a recognized entry" 967 f" marker:\n{entryBits}" 968 ) 969 entryType = self.entryMap[typeSpecifier] 970 971 # Figure out the entry target from second+ character(s) 972 targetSpecifiers = entrySpecifier[1:] 973 specifiersSet = set(targetSpecifiers) 974 if entryType == 'zone': 975 if targetSpecifiers.isdigit(): 976 entryTarget = ('zonePart', int(targetSpecifiers)) 977 elif ( 978 len(specifiersSet) > 0 979 and specifiersSet != {self.journalMarkers['zonePart']} 980 ): 981 raise JournalParseError( 982 f"Invalid target specifier for zone:\n{entryBits}" 983 ) 984 else: 985 entryTarget = ('zonePart', len(targetSpecifiers)) 986 elif entryType == 'status': 987 if len(targetSpecifiers) > 0: 988 if any( 989 x != self.journalMarkers['unfinishedPart'] 990 for x in targetSpecifiers 991 ): 992 raise JournalParseError( 993 f"Invalid target specifier for" 994 f" status:\n{entryBits}" 995 ) 996 entryTarget = 'unfinishedPart' 997 else: 998 entryTarget = None 999 elif len(targetSpecifiers) > 0: 1000 if ( 1001 targetSpecifiers[1:].isdigit() 1002 and targetSpecifiers[0] in self.targetMap 1003 ): 1004 entryTarget = ( 1005 self.targetMap[targetSpecifiers[0]], 1006 int(targetSpecifiers[1:]) 1007 ) 1008 elif len(specifiersSet) > 1: 1009 raise JournalParseError( 1010 f"Entry has too many target specifiers:\n{entryBits}" 1011 ) 1012 else: 1013 specifier = list(specifiersSet)[0] 1014 copies = len(targetSpecifiers) 1015 if specifier not in self.targetMap: 1016 raise JournalParseError( 1017 f"Unrecognized target specifier in:\n{entryBits}" 1018 ) 1019 entryTarget = self.targetMap[specifier] 1020 if copies > 1: 1021 entryTarget = (entryTarget, copies - 1) 1022 # else entryTarget remains None 1023 1024 return (entryType, entryDecisionType, entryTarget, entryArgs) 1025 1026 def argsString(self, pieces: List[str]) -> str: 1027 """ 1028 Recombines pieces of a journal argument (such as those produced 1029 by `unparseEffect`) into a single string. When there are 1030 multi-line or space-containing pieces, this adds block start/end 1031 delimiters and indents the piece if it's multi-line. 1032 """ 1033 result = '' 1034 for piece in pieces: 1035 if '\n' in piece: 1036 result += ( 1037 f" {self.blockStart}\n" 1038 f"{textwrap.indent(piece, ' ')}" 1039 f"{self.blockEnd}" 1040 ) 1041 elif ' ' in piece: 1042 result += f" {self.blockStart}{piece}{self.blockEnd}" 1043 else: 1044 result += ' ' + piece 1045 1046 return result[1:] # chop off extra initial space 1047 1048 def removeComments(self, text: str) -> str: 1049 """ 1050 Given one or more lines from a journal, removes all comments from 1051 it/them. Any '#' and any following characters through the end of 1052 a line counts as a comment. 1053 1054 Returns the text without comments. 1055 1056 Example: 1057 1058 >>> pf = JournalParseFormat() 1059 >>> pf.removeComments('abc # 123') 1060 'abc ' 1061 >>> pf.removeComments('''\\ 1062 ... line one # comment 1063 ... line two # comment 1064 ... line three 1065 ... line four # comment 1066 ... ''') 1067 'line one \\nline two \\nline three\\nline four \\n' 1068 """ 1069 return self.commentRE.sub('', text) 1070 1071 def findBlockEnd(self, string: str, startIndex: int) -> int: 1072 """ 1073 Given a string and a start index where a block open delimiter 1074 is, returns the index within the string of the matching block 1075 closing delimiter. 1076 1077 There are two possibilities: either both the opening and closing 1078 delimiter appear on the same line, or the block start appears at 1079 the end of a line (modulo whitespce) and the block end appears 1080 at the beginning of a line (modulo whitespace). Any other 1081 configuration is invalid and may lead to a `JournalParseError`. 1082 1083 Note that blocks may be nested within each other, including 1084 nesting single-line blocks in a multi-line block. It's also 1085 possible for several single-line blocks to appear on the same 1086 line. 1087 1088 Examples: 1089 1090 >>> pf = JournalParseFormat() 1091 >>> pf.findBlockEnd('[ A ]', 0) 1092 4 1093 >>> pf.findBlockEnd('[ A ] [ B ]', 0) 1094 4 1095 >>> pf.findBlockEnd('[ A ] [ B ]', 6) 1096 10 1097 >>> pf.findBlockEnd('[ A [ B ] ]', 0) 1098 10 1099 >>> pf.findBlockEnd('[ A [ B ] ]', 4) 1100 8 1101 >>> pf.findBlockEnd('[ [ B ]', 0) 1102 Traceback (most recent call last): 1103 ... 1104 exploration.journal.JournalParseError... 1105 >>> pf.findBlockEnd('[\\nABC\\n]', 0) 1106 6 1107 >>> pf.findBlockEnd('[\\nABC]', 0) # End marker must start line 1108 Traceback (most recent call last): 1109 ... 1110 exploration.journal.JournalParseError... 1111 >>> pf.findBlockEnd('[\\nABC\\nDEF[\\nGHI\\n]\\n ]', 0) 1112 19 1113 >>> pf.findBlockEnd('[\\nABC\\nDEF[\\nGHI\\n]\\n ]', 9) 1114 15 1115 >>> pf.findBlockEnd('[\\nABC\\nDEF[ GHI ]\\n ]', 0) 1116 19 1117 >>> pf.findBlockEnd('[\\nABC\\nDEF[ GHI ]\\n ]', 9) 1118 15 1119 >>> pf.findBlockEnd('[ \\nABC\\nDEF[\\nGHI[H]\\n ]\\n]', 0) 1120 24 1121 >>> pf.findBlockEnd('[ \\nABC\\nDEF[\\nGHI[H]\\n ]\\n]', 11) 1122 22 1123 >>> pf.findBlockEnd('[ \\nABC\\nDEF[\\nGHI[H]\\n ]\\n]', 16) 1124 18 1125 >>> pf.findBlockEnd('[ \\nABC\\nDEF[\\nGHI[H \\n ]\\n]', 16) 1126 Traceback (most recent call last): 1127 ... 1128 exploration.journal.JournalParseError... 1129 >>> pf.findBlockEnd('[ \\nABC\\nDEF[\\nGHI[H]\\n\\n]', 0) 1130 Traceback (most recent call last): 1131 ... 1132 exploration.journal.JournalParseError... 1133 """ 1134 # Find end of the line that the block opens on 1135 try: 1136 endOfLine = string.index('\n', startIndex) 1137 except ValueError: 1138 endOfLine = len(string) 1139 1140 # Determine if this is a single-line or multi-line block based 1141 # on the presence of *anything* after the opening delimiter 1142 restOfLine = string[startIndex + 1:endOfLine] 1143 if restOfLine.strip() != '': # A single-line block 1144 level = 1 1145 for restIndex, char in enumerate(restOfLine): 1146 if char == self.blockEnd: 1147 level -= 1 1148 if level <= 0: 1149 break 1150 elif char == self.blockStart: 1151 level += 1 1152 1153 if level == 0: 1154 return startIndex + 1 + restIndex 1155 else: 1156 raise JournalParseError( 1157 f"Got to end of line in single-line block without" 1158 f" finding the matching end-of-block marker." 1159 f" Remainder of line is:\n {restOfLine!r}" 1160 ) 1161 1162 else: # It's a multi-line block 1163 level = 1 1164 index = startIndex + 1 1165 while level > 0 and index < len(string): 1166 nextStart = self.blockStartRE.search(string, index) 1167 nextEnd = self.blockEndRE.search(string, index) 1168 if nextEnd is None: 1169 break # no end in sight; level won't be 0 1170 elif ( 1171 nextStart is None 1172 or nextStart.start() > nextEnd.start() 1173 ): 1174 index = nextEnd.end() 1175 level -= 1 1176 if level <= 0: 1177 break 1178 else: # They cannot be equal 1179 index = nextStart.end() 1180 level += 1 1181 1182 if level == 0: 1183 if nextEnd is None: 1184 raise RuntimeError( 1185 "Parsing got to level 0 with no valid end" 1186 " match." 1187 ) 1188 return nextEnd.end() - 1 1189 else: 1190 raise JournalParseError( 1191 f"Got to the end of the entire string and didn't" 1192 f" find a matching end-of-block marker. Started at" 1193 f" index {startIndex}." 1194 )
A ParseFormat manages the mapping from markers to entry types and vice versa.
665 def __init__( 666 self, 667 formatDict: parsing.Format = parsing.DEFAULT_FORMAT, 668 journalMarkers: JournalFormat = DEFAULT_FORMAT 669 ): 670 """ 671 Sets up the parsing format. Accepts base and/or journal format 672 dictionaries, but they both have defaults (see `DEFAULT_FORMAT` 673 and `parsing.DEFAULT_FORMAT`). Raises a `ValueError` unless the 674 keys of the format dictionaries exactly match the required 675 values (the `parsing.Lexeme` values for the base format and the 676 `JournalMarkerType` values for the journal format). 677 """ 678 super().__init__(formatDict) 679 self.journalMarkers: JournalFormat = journalMarkers 680 681 # Build comment regular expression 682 self.commentRE = re.compile( 683 self.journalMarkers.get('comment', '#') + '.*$', 684 flags=re.MULTILINE 685 ) 686 687 # Get block delimiters 688 blockDelimiters = journalMarkers.get('blockDelimiters', '[]') 689 if len(blockDelimiters) != 2: 690 raise ValueError( 691 f"Block delimiters must be a length-2 string containing" 692 f" the start and end markers. Got: {blockDelimiters!r}." 693 ) 694 blockStart = blockDelimiters[0] 695 blockEnd = blockDelimiters[1] 696 self.blockStart = blockStart 697 self.blockEnd = blockEnd 698 699 # Add backslash for literal if it's an RE special char 700 if blockStart in '[]()*.?^$&+\\': 701 blockStart = '\\' + blockStart 702 if blockEnd in '[]()*.?^$&+\\': 703 blockEnd = '\\' + blockEnd 704 705 # Build block start and end regular expressions 706 self.blockStartRE = re.compile( 707 blockStart + r'\s*$', 708 flags=re.MULTILINE 709 ) 710 self.blockEndRE = re.compile( 711 r'^\s*' + blockEnd, 712 flags=re.MULTILINE 713 ) 714 715 # Check that journalMarkers doesn't have any extra keys 716 markerTypes = ( 717 get_args(JournalEntryType) 718 + get_args(base.DecisionType) 719 + get_args(JournalTargetType) 720 + get_args(JournalInfoType) 721 ) 722 for key in journalMarkers: 723 if key not in markerTypes: 724 raise ValueError( 725 f"Format dict has key {key!r} which is not a" 726 f" recognized entry or info type." 727 ) 728 729 # Check completeness of formatDict 730 for mtype in markerTypes: 731 if mtype not in journalMarkers: 732 raise ValueError( 733 f"Journal markers dict is missing an entry for" 734 f" marker type {mtype!r}." 735 ) 736 737 # Build reverse dictionaries from markers to entry types and 738 # from markers to target types (no reverse needed for info 739 # types). 740 self.entryMap: Dict[str, JournalEntryType] = {} 741 self.targetMap: Dict[str, JournalTargetType] = {} 742 entryTypes = set(get_args(JournalEntryType)) 743 targetTypes = set(get_args(JournalTargetType)) 744 745 # Check for duplicates and create reverse maps 746 for name, marker in journalMarkers.items(): 747 if name in entryTypes: 748 # Duplicates not allowed among entry types 749 if marker in self.entryMap: 750 raise ValueError( 751 f"Format dict entry for {name!r} duplicates" 752 f" previous format dict entry for" 753 f" {self.entryMap[marker]!r}." 754 ) 755 756 # Map markers to entry types 757 self.entryMap[marker] = cast(JournalEntryType, name) 758 elif name in targetTypes: 759 # Duplicates not allowed among entry types 760 if marker in self.targetMap: 761 raise ValueError( 762 f"Format dict entry for {name!r} duplicates" 763 f" previous format dict entry for" 764 f" {self.targetMap[marker]!r}." 765 ) 766 767 # Map markers to entry types 768 self.targetMap[marker] = cast(JournalTargetType, name) 769 770 # else ignore it since it's an info type
Sets up the parsing format. Accepts base and/or journal format
dictionaries, but they both have defaults (see DEFAULT_FORMAT
and parsing.DEFAULT_FORMAT
). Raises a ValueError
unless the
keys of the format dictionaries exactly match the required
values (the parsing.Lexeme
values for the base format and the
JournalMarkerType
values for the journal format).
772 def markers(self) -> List[str]: 773 """ 774 Returns the list of all entry-type markers (but not other kinds 775 of markers), sorted from longest to shortest to help avoid 776 ambiguities when matching. 777 """ 778 entryTypes = get_args(JournalEntryType) 779 return sorted( 780 ( 781 m 782 for (et, m) in self.journalMarkers.items() 783 if et in entryTypes 784 ), 785 key=lambda m: -len(m) 786 )
Returns the list of all entry-type markers (but not other kinds of markers), sorted from longest to shortest to help avoid ambiguities when matching.
788 def markerFor(self, markerType: JournalMarkerType) -> str: 789 """ 790 Returns the marker for the specified entry/info/effect/etc. 791 type. 792 """ 793 return self.journalMarkers[markerType]
Returns the marker for the specified entry/info/effect/etc. type.
795 def determineEntryType(self, entryBits: List[str]) -> Tuple[ 796 JournalEntryType, 797 base.DecisionType, 798 Union[None, JournalTargetType, Tuple[JournalTargetType, int]], 799 List[str] 800 ]: 801 """ 802 Given a sequence of strings that specify a command, returns a 803 tuple containing the entry type, decision type, target part, and 804 list of arguments for that command. The default decision type is 805 'active' but others can be specified with decision type 806 modifiers. If no target type was included, the third entry of 807 the return value will be `None`, and in the special case of 808 zones, it will be an integer indicating the hierarchy level 809 according to how many times the 'zonePart' target specifier was 810 present, default 0. 811 812 For example: 813 814 >>> pf = JournalParseFormat() 815 >>> pf.determineEntryType(['retrace', 'transition']) 816 ('retrace', 'active', None, ['transition']) 817 >>> pf.determineEntryType(['t', 'transition']) 818 ('retrace', 'active', None, ['transition']) 819 >>> pf.determineEntryType(['observe@action', 'open']) 820 ('observe', 'active', 'actionPart', ['open']) 821 >>> pf.determineEntryType(['oa', 'open']) 822 ('observe', 'active', 'actionPart', ['open']) 823 >>> pf.determineEntryType(['!explore', 'down', 'pit', 'up']) 824 ('explore', 'unintended', None, ['down', 'pit', 'up']) 825 >>> pf.determineEntryType(['imposed/explore', 'down', 'pit', 'up']) 826 ('explore', 'imposed', None, ['down', 'pit', 'up']) 827 >>> pf.determineEntryType(['~x', 'down', 'pit', 'up']) 828 ('explore', 'consequence', None, ['down', 'pit', 'up']) 829 >>> pf.determineEntryType(['>x', 'down', 'pit', 'up']) 830 ('explore', 'imposed', None, ['down', 'pit', 'up']) 831 >>> pf.determineEntryType(['gzz', 'tag']) 832 ('tag', 'active', ('zonePart', 1), ['tag']) 833 >>> pf.determineEntryType(['gz4', 'tag']) 834 ('tag', 'active', ('zonePart', 4), ['tag']) 835 >>> pf.determineEntryType(['zone@z2', 'ZoneName']) 836 ('zone', 'active', ('zonePart', 2), ['ZoneName']) 837 >>> pf.determineEntryType(['zzz', 'ZoneName']) 838 ('zone', 'active', ('zonePart', 2), ['ZoneName']) 839 """ 840 # Get entry specifier 841 entrySpecifier = entryBits[0] 842 entryArgs = entryBits[1:] 843 844 # Defaults 845 entryType: Optional[JournalEntryType] = None 846 entryTarget: Union[ 847 None, 848 JournalTargetType, 849 Tuple[JournalTargetType, int] 850 ] = None 851 entryDecisionType: base.DecisionType = 'active' 852 853 # Check for a decision type specifier and process+remove it 854 for decisionType in get_args(base.DecisionType): 855 marker = self.markerFor(decisionType) 856 lm = len(marker) 857 if ( 858 entrySpecifier.startswith(marker) 859 and len(entrySpecifier) > lm 860 ): 861 entrySpecifier = entrySpecifier[lm:] 862 entryDecisionType = decisionType 863 break 864 elif entrySpecifier.startswith( 865 decisionType + self.markerFor('reciprocalSeparator') 866 ): 867 entrySpecifier = entrySpecifier[len(decisionType) + 1:] 868 entryDecisionType = decisionType 869 break 870 871 # Sets of valid types and targets 872 validEntryTypes: Set[JournalEntryType] = set( 873 get_args(JournalEntryType) 874 ) 875 validEntryTargets: Set[JournalTargetType] = set( 876 get_args(JournalTargetType) 877 ) 878 879 # Look for a long-form entry specifier with an @ sign separating 880 # the entry type from the entry target 881 targetMarker = self.markerFor('targetSeparator') 882 if ( 883 targetMarker in entrySpecifier 884 and not entrySpecifier.startswith(targetMarker) 885 # Because the targetMarker is also a valid entry type! 886 ): 887 specifierBits = entrySpecifier.split(targetMarker) 888 if len(specifierBits) != 2: 889 raise JournalParseError( 890 f"When a long-form entry specifier contains a" 891 f" target separator, it must contain exactly one (to" 892 f" split the entry type from the entry target). We got" 893 f" {entrySpecifier!r}." 894 ) 895 entryTypeGuess: str 896 entryTargetGuess: Optional[str] 897 entryTypeGuess, entryTargetGuess = specifierBits 898 if entryTypeGuess not in validEntryTypes: 899 raise JournalParseError( 900 f"Invalid long-form entry type: {entryType!r}" 901 ) 902 else: 903 entryType = cast(JournalEntryType, entryTypeGuess) 904 905 # Special logic for zone part 906 handled = False 907 if entryType in ('zone', 'tag', 'annotate'): 908 handled = True 909 if entryType == 'zone' and entryTargetGuess.isdigit(): 910 entryTarget = ('zonePart', int(entryTargetGuess)) 911 elif entryTargetGuess == 'zone': 912 entryTarget = ('zonePart', 1 if entryType == 'zone' else 0) 913 elif ( 914 entryTargetGuess.startswith('z') 915 and entryTargetGuess[1:].isdigit() 916 ): 917 entryTarget = ('zonePart', int(entryTargetGuess[1:])) 918 elif ( 919 len(entryTargetGuess) > 0 920 and set(entryTargetGuess) != {'z'} 921 ): 922 if entryType == 'zone': 923 raise JournalParseError( 924 f"Invalid target specifier for" 925 f" zone entry:\n{entryTargetGuess}" 926 ) 927 else: 928 handled = False 929 else: 930 entryTarget = ('zonePart', len(entryTargetGuess)) 931 932 if not handled: 933 if entryTargetGuess + 'Part' in validEntryTargets: 934 entryTarget = cast( 935 JournalTargetType, 936 entryTargetGuess + 'Part' 937 ) 938 else: 939 origGuess = entryTargetGuess 940 entryTargetGuess = self.targetMap.get( 941 entryTargetGuess, 942 entryTargetGuess 943 ) 944 if entryTargetGuess not in validEntryTargets: 945 raise JournalParseError( 946 f"Invalid long-form entry target:" 947 f" {origGuess!r}" 948 ) 949 else: 950 entryTarget = cast( 951 JournalTargetType, 952 entryTargetGuess 953 ) 954 955 elif entrySpecifier in validEntryTypes: 956 # Might be a long-form specifier without a separator 957 entryType = cast(JournalEntryType, entrySpecifier) 958 entryTarget = None 959 if entryType == 'zone': 960 entryTarget = ('zonePart', 0) 961 962 else: # parse a short-form entry specifier 963 typeSpecifier = entrySpecifier[0] 964 if typeSpecifier not in self.entryMap: 965 raise JournalParseError( 966 f"Entry does not begin with a recognized entry" 967 f" marker:\n{entryBits}" 968 ) 969 entryType = self.entryMap[typeSpecifier] 970 971 # Figure out the entry target from second+ character(s) 972 targetSpecifiers = entrySpecifier[1:] 973 specifiersSet = set(targetSpecifiers) 974 if entryType == 'zone': 975 if targetSpecifiers.isdigit(): 976 entryTarget = ('zonePart', int(targetSpecifiers)) 977 elif ( 978 len(specifiersSet) > 0 979 and specifiersSet != {self.journalMarkers['zonePart']} 980 ): 981 raise JournalParseError( 982 f"Invalid target specifier for zone:\n{entryBits}" 983 ) 984 else: 985 entryTarget = ('zonePart', len(targetSpecifiers)) 986 elif entryType == 'status': 987 if len(targetSpecifiers) > 0: 988 if any( 989 x != self.journalMarkers['unfinishedPart'] 990 for x in targetSpecifiers 991 ): 992 raise JournalParseError( 993 f"Invalid target specifier for" 994 f" status:\n{entryBits}" 995 ) 996 entryTarget = 'unfinishedPart' 997 else: 998 entryTarget = None 999 elif len(targetSpecifiers) > 0: 1000 if ( 1001 targetSpecifiers[1:].isdigit() 1002 and targetSpecifiers[0] in self.targetMap 1003 ): 1004 entryTarget = ( 1005 self.targetMap[targetSpecifiers[0]], 1006 int(targetSpecifiers[1:]) 1007 ) 1008 elif len(specifiersSet) > 1: 1009 raise JournalParseError( 1010 f"Entry has too many target specifiers:\n{entryBits}" 1011 ) 1012 else: 1013 specifier = list(specifiersSet)[0] 1014 copies = len(targetSpecifiers) 1015 if specifier not in self.targetMap: 1016 raise JournalParseError( 1017 f"Unrecognized target specifier in:\n{entryBits}" 1018 ) 1019 entryTarget = self.targetMap[specifier] 1020 if copies > 1: 1021 entryTarget = (entryTarget, copies - 1) 1022 # else entryTarget remains None 1023 1024 return (entryType, entryDecisionType, entryTarget, entryArgs)
Given a sequence of strings that specify a command, returns a
tuple containing the entry type, decision type, target part, and
list of arguments for that command. The default decision type is
'active' but others can be specified with decision type
modifiers. If no target type was included, the third entry of
the return value will be None
, and in the special case of
zones, it will be an integer indicating the hierarchy level
according to how many times the 'zonePart' target specifier was
present, default 0.
For example:
>>> pf = JournalParseFormat()
>>> pf.determineEntryType(['retrace', 'transition'])
('retrace', 'active', None, ['transition'])
>>> pf.determineEntryType(['t', 'transition'])
('retrace', 'active', None, ['transition'])
>>> pf.determineEntryType(['observe@action', 'open'])
('observe', 'active', 'actionPart', ['open'])
>>> pf.determineEntryType(['oa', 'open'])
('observe', 'active', 'actionPart', ['open'])
>>> pf.determineEntryType(['!explore', 'down', 'pit', 'up'])
('explore', 'unintended', None, ['down', 'pit', 'up'])
>>> pf.determineEntryType(['imposed/explore', 'down', 'pit', 'up'])
('explore', 'imposed', None, ['down', 'pit', 'up'])
>>> pf.determineEntryType(['~x', 'down', 'pit', 'up'])
('explore', 'consequence', None, ['down', 'pit', 'up'])
>>> pf.determineEntryType(['>x', 'down', 'pit', 'up'])
('explore', 'imposed', None, ['down', 'pit', 'up'])
>>> pf.determineEntryType(['gzz', 'tag'])
('tag', 'active', ('zonePart', 1), ['tag'])
>>> pf.determineEntryType(['gz4', 'tag'])
('tag', 'active', ('zonePart', 4), ['tag'])
>>> pf.determineEntryType(['zone@z2', 'ZoneName'])
('zone', 'active', ('zonePart', 2), ['ZoneName'])
>>> pf.determineEntryType(['zzz', 'ZoneName'])
('zone', 'active', ('zonePart', 2), ['ZoneName'])
1026 def argsString(self, pieces: List[str]) -> str: 1027 """ 1028 Recombines pieces of a journal argument (such as those produced 1029 by `unparseEffect`) into a single string. When there are 1030 multi-line or space-containing pieces, this adds block start/end 1031 delimiters and indents the piece if it's multi-line. 1032 """ 1033 result = '' 1034 for piece in pieces: 1035 if '\n' in piece: 1036 result += ( 1037 f" {self.blockStart}\n" 1038 f"{textwrap.indent(piece, ' ')}" 1039 f"{self.blockEnd}" 1040 ) 1041 elif ' ' in piece: 1042 result += f" {self.blockStart}{piece}{self.blockEnd}" 1043 else: 1044 result += ' ' + piece 1045 1046 return result[1:] # chop off extra initial space
Recombines pieces of a journal argument (such as those produced
by unparseEffect
) into a single string. When there are
multi-line or space-containing pieces, this adds block start/end
delimiters and indents the piece if it's multi-line.
1048 def removeComments(self, text: str) -> str: 1049 """ 1050 Given one or more lines from a journal, removes all comments from 1051 it/them. Any '#' and any following characters through the end of 1052 a line counts as a comment. 1053 1054 Returns the text without comments. 1055 1056 Example: 1057 1058 >>> pf = JournalParseFormat() 1059 >>> pf.removeComments('abc # 123') 1060 'abc ' 1061 >>> pf.removeComments('''\\ 1062 ... line one # comment 1063 ... line two # comment 1064 ... line three 1065 ... line four # comment 1066 ... ''') 1067 'line one \\nline two \\nline three\\nline four \\n' 1068 """ 1069 return self.commentRE.sub('', text)
Given one or more lines from a journal, removes all comments from it/them. Any '#' and any following characters through the end of a line counts as a comment.
Returns the text without comments.
Example:
>>> pf = JournalParseFormat()
>>> pf.removeComments('abc # 123')
'abc '
>>> pf.removeComments('''\
... line one # comment
... line two # comment
... line three
... line four # comment
... ''')
'line one \nline two \nline three\nline four \n'
1071 def findBlockEnd(self, string: str, startIndex: int) -> int: 1072 """ 1073 Given a string and a start index where a block open delimiter 1074 is, returns the index within the string of the matching block 1075 closing delimiter. 1076 1077 There are two possibilities: either both the opening and closing 1078 delimiter appear on the same line, or the block start appears at 1079 the end of a line (modulo whitespce) and the block end appears 1080 at the beginning of a line (modulo whitespace). Any other 1081 configuration is invalid and may lead to a `JournalParseError`. 1082 1083 Note that blocks may be nested within each other, including 1084 nesting single-line blocks in a multi-line block. It's also 1085 possible for several single-line blocks to appear on the same 1086 line. 1087 1088 Examples: 1089 1090 >>> pf = JournalParseFormat() 1091 >>> pf.findBlockEnd('[ A ]', 0) 1092 4 1093 >>> pf.findBlockEnd('[ A ] [ B ]', 0) 1094 4 1095 >>> pf.findBlockEnd('[ A ] [ B ]', 6) 1096 10 1097 >>> pf.findBlockEnd('[ A [ B ] ]', 0) 1098 10 1099 >>> pf.findBlockEnd('[ A [ B ] ]', 4) 1100 8 1101 >>> pf.findBlockEnd('[ [ B ]', 0) 1102 Traceback (most recent call last): 1103 ... 1104 exploration.journal.JournalParseError... 1105 >>> pf.findBlockEnd('[\\nABC\\n]', 0) 1106 6 1107 >>> pf.findBlockEnd('[\\nABC]', 0) # End marker must start line 1108 Traceback (most recent call last): 1109 ... 1110 exploration.journal.JournalParseError... 1111 >>> pf.findBlockEnd('[\\nABC\\nDEF[\\nGHI\\n]\\n ]', 0) 1112 19 1113 >>> pf.findBlockEnd('[\\nABC\\nDEF[\\nGHI\\n]\\n ]', 9) 1114 15 1115 >>> pf.findBlockEnd('[\\nABC\\nDEF[ GHI ]\\n ]', 0) 1116 19 1117 >>> pf.findBlockEnd('[\\nABC\\nDEF[ GHI ]\\n ]', 9) 1118 15 1119 >>> pf.findBlockEnd('[ \\nABC\\nDEF[\\nGHI[H]\\n ]\\n]', 0) 1120 24 1121 >>> pf.findBlockEnd('[ \\nABC\\nDEF[\\nGHI[H]\\n ]\\n]', 11) 1122 22 1123 >>> pf.findBlockEnd('[ \\nABC\\nDEF[\\nGHI[H]\\n ]\\n]', 16) 1124 18 1125 >>> pf.findBlockEnd('[ \\nABC\\nDEF[\\nGHI[H \\n ]\\n]', 16) 1126 Traceback (most recent call last): 1127 ... 1128 exploration.journal.JournalParseError... 1129 >>> pf.findBlockEnd('[ \\nABC\\nDEF[\\nGHI[H]\\n\\n]', 0) 1130 Traceback (most recent call last): 1131 ... 1132 exploration.journal.JournalParseError... 1133 """ 1134 # Find end of the line that the block opens on 1135 try: 1136 endOfLine = string.index('\n', startIndex) 1137 except ValueError: 1138 endOfLine = len(string) 1139 1140 # Determine if this is a single-line or multi-line block based 1141 # on the presence of *anything* after the opening delimiter 1142 restOfLine = string[startIndex + 1:endOfLine] 1143 if restOfLine.strip() != '': # A single-line block 1144 level = 1 1145 for restIndex, char in enumerate(restOfLine): 1146 if char == self.blockEnd: 1147 level -= 1 1148 if level <= 0: 1149 break 1150 elif char == self.blockStart: 1151 level += 1 1152 1153 if level == 0: 1154 return startIndex + 1 + restIndex 1155 else: 1156 raise JournalParseError( 1157 f"Got to end of line in single-line block without" 1158 f" finding the matching end-of-block marker." 1159 f" Remainder of line is:\n {restOfLine!r}" 1160 ) 1161 1162 else: # It's a multi-line block 1163 level = 1 1164 index = startIndex + 1 1165 while level > 0 and index < len(string): 1166 nextStart = self.blockStartRE.search(string, index) 1167 nextEnd = self.blockEndRE.search(string, index) 1168 if nextEnd is None: 1169 break # no end in sight; level won't be 0 1170 elif ( 1171 nextStart is None 1172 or nextStart.start() > nextEnd.start() 1173 ): 1174 index = nextEnd.end() 1175 level -= 1 1176 if level <= 0: 1177 break 1178 else: # They cannot be equal 1179 index = nextStart.end() 1180 level += 1 1181 1182 if level == 0: 1183 if nextEnd is None: 1184 raise RuntimeError( 1185 "Parsing got to level 0 with no valid end" 1186 " match." 1187 ) 1188 return nextEnd.end() - 1 1189 else: 1190 raise JournalParseError( 1191 f"Got to the end of the entire string and didn't" 1192 f" find a matching end-of-block marker. Started at" 1193 f" index {startIndex}." 1194 )
Given a string and a start index where a block open delimiter is, returns the index within the string of the matching block closing delimiter.
There are two possibilities: either both the opening and closing
delimiter appear on the same line, or the block start appears at
the end of a line (modulo whitespce) and the block end appears
at the beginning of a line (modulo whitespace). Any other
configuration is invalid and may lead to a JournalParseError
.
Note that blocks may be nested within each other, including nesting single-line blocks in a multi-line block. It's also possible for several single-line blocks to appear on the same line.
Examples:
>>> pf = JournalParseFormat()
>>> pf.findBlockEnd('[ A ]', 0)
4
>>> pf.findBlockEnd('[ A ] [ B ]', 0)
4
>>> pf.findBlockEnd('[ A ] [ B ]', 6)
10
>>> pf.findBlockEnd('[ A [ B ] ]', 0)
10
>>> pf.findBlockEnd('[ A [ B ] ]', 4)
8
>>> pf.findBlockEnd('[ [ B ]', 0)
Traceback (most recent call last):
...
JournalParseError...
>>> pf.findBlockEnd('[\nABC\n]', 0)
6
>>> pf.findBlockEnd('[\nABC]', 0) # End marker must start line
Traceback (most recent call last):
...
JournalParseError...
>>> pf.findBlockEnd('[\nABC\nDEF[\nGHI\n]\n ]', 0)
19
>>> pf.findBlockEnd('[\nABC\nDEF[\nGHI\n]\n ]', 9)
15
>>> pf.findBlockEnd('[\nABC\nDEF[ GHI ]\n ]', 0)
19
>>> pf.findBlockEnd('[\nABC\nDEF[ GHI ]\n ]', 9)
15
>>> pf.findBlockEnd('[ \nABC\nDEF[\nGHI[H]\n ]\n]', 0)
24
>>> pf.findBlockEnd('[ \nABC\nDEF[\nGHI[H]\n ]\n]', 11)
22
>>> pf.findBlockEnd('[ \nABC\nDEF[\nGHI[H]\n ]\n]', 16)
18
>>> pf.findBlockEnd('[ \nABC\nDEF[\nGHI[H \n ]\n]', 16)
Traceback (most recent call last):
...
JournalParseError...
>>> pf.findBlockEnd('[ \nABC\nDEF[\nGHI[H]\n\n]', 0)
Traceback (most recent call last):
...
JournalParseError...
Inherited Members
- exploration.parsing.ParseFormat
- formatDict
- effectNames
- focalizationNames
- reverseFormat
- effectModMap
- lex
- onOff
- matchingBrace
- parseFocalization
- parseTagValue
- unparseTagValue
- hasZoneParts
- splitZone
- prefixWithZone
- parseAnyTransitionFromTokens
- parseTransitionWithOutcomes
- unparseTransitionWithOutocmes
- parseSpecificTransition
- splitDirections
- parseItem
- unparseDecisionSpecifier
- unparseMechanismSpecifier
- effectType
- parseCommandFromTokens
- unparseCommand
- unparseCommandList
- parseCommandListFromTokens
- parseOneEffectArg
- coalesceEffectArgs
- parseEffectFromTokens
- parseEffect
- unparseEffect
- parseDecisionSpecifierFromTokens
- parseDecisionSpecifier
- parseFeatureSpecifierFromTokens
- parseFeatureSpecifier
- normalizeFeatureSpecifier
- unparseChallenge
- unparseCondition
- unparseConsequence
- parseMechanismSpecifierFromTokens
- groupReqTokens
- groupReqTokensByPrecedence
- parseRequirementFromRegroupedTokens
- parseRequirementFromGroupedTokens
- parseRequirementFromTokens
- parseRequirement
- parseSkillCombinationFromTokens
- parseSkillCombination
- parseConditionFromTokens
- parseCondition
- parseChallengeFromTokens
- parseChallenge
- parseConsequenceFromTokens
- parseConsequence
1201class JournalParseError(ValueError): 1202 """ 1203 Represents a error encountered when parsing a journal. 1204 """ 1205 pass
Represents a error encountered when parsing a journal.
Inherited Members
- builtins.ValueError
- ValueError
- builtins.BaseException
- with_traceback
- add_note
- args
1208class LocatedJournalParseError(JournalParseError): 1209 """ 1210 An error during journal parsing that includes additional location 1211 information. 1212 """ 1213 def __init__( 1214 self, 1215 src: str, 1216 index: Optional[int], 1217 cause: Exception 1218 ) -> None: 1219 """ 1220 In addition to the underlying error, the journal source text and 1221 the index within that text where the error occurred are 1222 required. 1223 """ 1224 super().__init__("localized error") 1225 self.src = src 1226 self.index = index 1227 self.cause = cause 1228 1229 def __str__(self) -> str: 1230 """ 1231 Includes information about the location of the error and the 1232 line it appeared on. 1233 """ 1234 ec = errorContext(self.src, self.index) 1235 errorCM = textwrap.indent(errorContextMessage(ec), ' ') 1236 return ( 1237 f"\n{errorCM}" 1238 f"\n Error is:" 1239 f"\n{type(self.cause).__name__}: {self.cause}" 1240 )
An error during journal parsing that includes additional location information.
1213 def __init__( 1214 self, 1215 src: str, 1216 index: Optional[int], 1217 cause: Exception 1218 ) -> None: 1219 """ 1220 In addition to the underlying error, the journal source text and 1221 the index within that text where the error occurred are 1222 required. 1223 """ 1224 super().__init__("localized error") 1225 self.src = src 1226 self.index = index 1227 self.cause = cause
In addition to the underlying error, the journal source text and the index within that text where the error occurred are required.
Inherited Members
- builtins.BaseException
- with_traceback
- add_note
- args
1243def errorContext( 1244 string: str, 1245 index: Optional[int] 1246) -> Optional[Tuple[str, int, int]]: 1247 """ 1248 Returns the line of text, the line number, and the character within 1249 that line for the given absolute index into the given string. 1250 Newline characters count as the last character on their line. Lines 1251 and characters are numbered starting from 1. 1252 1253 Returns `None` for out-of-range indices. 1254 1255 Examples: 1256 1257 >>> errorContext('a\\nb\\nc', 0) 1258 ('a\\n', 1, 1) 1259 >>> errorContext('a\\nb\\nc', 1) 1260 ('a\\n', 1, 2) 1261 >>> errorContext('a\\nbcd\\ne', 2) 1262 ('bcd\\n', 2, 1) 1263 >>> errorContext('a\\nbcd\\ne', 3) 1264 ('bcd\\n', 2, 2) 1265 >>> errorContext('a\\nbcd\\ne', 4) 1266 ('bcd\\n', 2, 3) 1267 >>> errorContext('a\\nbcd\\ne', 5) 1268 ('bcd\\n', 2, 4) 1269 >>> errorContext('a\\nbcd\\ne', 6) 1270 ('e', 3, 1) 1271 >>> errorContext('a\\nbcd\\ne', -1) 1272 ('e', 3, 1) 1273 >>> errorContext('a\\nbcd\\ne', -2) 1274 ('bcd\\n', 2, 4) 1275 >>> errorContext('a\\nbcd\\ne', 7) is None 1276 True 1277 >>> errorContext('a\\nbcd\\ne', 8) is None 1278 True 1279 """ 1280 # Return None if no index is given 1281 if index is None: 1282 return None 1283 1284 # Convert negative to positive indices 1285 if index < 0: 1286 index = len(string) + index 1287 1288 # Return None for out-of-range indices 1289 if not 0 <= index < len(string): 1290 return None 1291 1292 # Count lines + look for start-of-line 1293 line = 1 1294 lineStart = 0 1295 for where, char in enumerate(string): 1296 if where >= index: 1297 break 1298 if char == '\n': 1299 line += 1 1300 lineStart = where + 1 1301 1302 try: 1303 endOfLine = string.index('\n', where) 1304 except ValueError: 1305 endOfLine = len(string) 1306 1307 return (string[lineStart:endOfLine + 1], line, index - lineStart + 1)
Returns the line of text, the line number, and the character within that line for the given absolute index into the given string. Newline characters count as the last character on their line. Lines and characters are numbered starting from 1.
Returns None
for out-of-range indices.
Examples:
>>> errorContext('a\nb\nc', 0)
('a\n', 1, 1)
>>> errorContext('a\nb\nc', 1)
('a\n', 1, 2)
>>> errorContext('a\nbcd\ne', 2)
('bcd\n', 2, 1)
>>> errorContext('a\nbcd\ne', 3)
('bcd\n', 2, 2)
>>> errorContext('a\nbcd\ne', 4)
('bcd\n', 2, 3)
>>> errorContext('a\nbcd\ne', 5)
('bcd\n', 2, 4)
>>> errorContext('a\nbcd\ne', 6)
('e', 3, 1)
>>> errorContext('a\nbcd\ne', -1)
('e', 3, 1)
>>> errorContext('a\nbcd\ne', -2)
('bcd\n', 2, 4)
>>> errorContext('a\nbcd\ne', 7) is None
True
>>> errorContext('a\nbcd\ne', 8) is None
True
1310def errorContextMessage(context: Optional[Tuple[str, int, int]]) -> str: 1311 """ 1312 Given an error context tuple (from `errorContext`) or possibly 1313 `None`, returns a string that can be used as part of an error 1314 message, identifying where the error occurred. 1315 """ 1316 line: Union[int, str] 1317 pos: Union[int, str] 1318 if context is None: 1319 contextStr = "<context unavialable>" 1320 line = "?" 1321 pos = "?" 1322 else: 1323 contextStr, line, pos = context 1324 contextStr = contextStr.rstrip('\n') 1325 return ( 1326 f"In journal on line {line} near character {pos}:" 1327 f" {contextStr}" 1328 )
Given an error context tuple (from errorContext
) or possibly
None
, returns a string that can be used as part of an error
message, identifying where the error occurred.
1331class JournalParseWarning(Warning): 1332 """ 1333 Represents a warning encountered when parsing a journal. 1334 """ 1335 pass
Represents a warning encountered when parsing a journal.
Inherited Members
- builtins.Warning
- Warning
- builtins.BaseException
- with_traceback
- add_note
- args
1338class PathEllipsis: 1339 """ 1340 Represents part of a path which has been omitted from a journal and 1341 which should therefore be inferred. 1342 """ 1343 pass
Represents part of a path which has been omitted from a journal and which should therefore be inferred.
1350class ObservationContext(TypedDict): 1351 """ 1352 The context for an observation, including which context (common or 1353 active) is being used, which domain we're focused on, which focal 1354 point is being modified for plural-focalized domains, and which 1355 decision and transition within the current domain are most relevant 1356 right now. 1357 """ 1358 context: base.ContextSpecifier 1359 domain: base.Domain 1360 # TODO: Per-domain focus/decision/transitions? 1361 focus: Optional[base.FocalPointName] 1362 decision: Optional[base.DecisionID] 1363 transition: Optional[Tuple[base.DecisionID, base.Transition]]
The context for an observation, including which context (common or active) is being used, which domain we're focused on, which focal point is being modified for plural-focalized domains, and which decision and transition within the current domain are most relevant right now.
1366def observationContext( 1367 context: base.ContextSpecifier = "active", 1368 domain: base.Domain = base.DEFAULT_DOMAIN, 1369 focus: Optional[base.FocalPointName] = None, 1370 decision: Optional[base.DecisionID] = None, 1371 transition: Optional[Tuple[base.DecisionID, base.Transition]] = None 1372) -> ObservationContext: 1373 """ 1374 Creates a default/empty `ObservationContext`. 1375 """ 1376 return { 1377 'context': context, 1378 'domain': domain, 1379 'focus': focus, 1380 'decision': decision, 1381 'transition': transition 1382 }
Creates a default/empty ObservationContext
.
1385class ObservationPreferences(TypedDict): 1386 """ 1387 Specifies global preferences for exploration observation. Values are 1388 either strings or booleans. The keys are: 1389 1390 - 'reciprocals': A boolean specifying whether transitions should 1391 come with reciprocals by default. Normally this is `True`, but 1392 it can be set to `False` instead. 1393 TODO: implement this. 1394 - 'revertAspects': A set of strings specifying which aspects of the 1395 game state should be reverted when a 'revert' action is taken and 1396 specific aspects to revert are not specified. See 1397 `base.revertedState` for a list of the available reversion 1398 aspects. 1399 """ 1400 reciprocals: bool 1401 revertAspects: Set[str]
Specifies global preferences for exploration observation. Values are either strings or booleans. The keys are:
- 'reciprocals': A boolean specifying whether transitions should
come with reciprocals by default. Normally this is
True
, but it can be set toFalse
instead. TODO: implement this. - 'revertAspects': A set of strings specifying which aspects of the
game state should be reverted when a 'revert' action is taken and
specific aspects to revert are not specified. See
base.revertedState
for a list of the available reversion aspects.
1404def observationPreferences( 1405 reciprocals: bool=True, 1406 revertAspects: Optional[Set[str]] = None 1407) -> ObservationPreferences: 1408 """ 1409 Creates an observation preferences dictionary, using default values 1410 for any preferences not specified as arguments. 1411 """ 1412 return { 1413 'reciprocals': reciprocals, 1414 'revertAspects': ( 1415 revertAspects 1416 if revertAspects is not None 1417 else set() 1418 ) 1419 }
Creates an observation preferences dictionary, using default values for any preferences not specified as arguments.
1422class JournalObserver: 1423 """ 1424 Keeps track of extra state needed when parsing a journal in order to 1425 produce a `core.DiscreteExploration` object. The methods of this 1426 class act as an API for constructing explorations that have several 1427 special properties. The API is designed to allow journal entries 1428 (which represent specific observations/events during an exploration) 1429 to be directly accumulated into an exploration object, including 1430 entries which apply to things like the most-recent-decision or 1431 -transition. 1432 1433 You can use the `convertJournal` function to handle things instead, 1434 since that function creates and manages a `JournalObserver` object 1435 for you. 1436 1437 The basic usage is as follows: 1438 1439 1. Create a `JournalObserver`, optionally specifying a custom 1440 `ParseFormat`. 1441 2. Repeatedly either: 1442 * Call `record*` API methods corresponding to specific entries 1443 observed or... 1444 * Call `JournalObserver.observe` to parse one or more 1445 journal blocks from a string and call the appropriate 1446 methods automatically. 1447 3. Call `JournalObserver.getExploration` to retrieve the 1448 `core.DiscreteExploration` object that's been created. 1449 1450 You can just call `convertJournal` to do all of these things at 1451 once. 1452 1453 Notes: 1454 1455 - `JournalObserver.getExploration` may be called at any time to get 1456 the exploration object constructed so far, and that that object 1457 (unless it's `None`) will always be the same object (which gets 1458 modified as entries are recorded). Modifying this object 1459 directly is possible for making changes not available via the 1460 API, but must be done carefully, as there are important 1461 conventions around things like decision names that must be 1462 respected if the API functions need to keep working. 1463 - To get the latest graph or state, simply use the 1464 `core.DiscreteExploration.getSituation()` method of the 1465 `JournalObserver.getExploration` result. 1466 1467 ## Examples 1468 1469 >>> obs = JournalObserver() 1470 >>> e = obs.getExploration() 1471 >>> len(e) # blank starting state 1472 1 1473 >>> e.getActiveDecisions(0) # no active decisions before starting 1474 set() 1475 >>> obs.definiteDecisionTarget() 1476 Traceback (most recent call last): 1477 ... 1478 exploration.core.MissingDecisionError... 1479 >>> obs.currentDecisionTarget() is None 1480 True 1481 >>> # We start by using the record* methods... 1482 >>> obs.recordStart("Start") 1483 >>> obs.definiteDecisionTarget() 1484 0 1485 >>> obs.recordObserve("bottom") 1486 >>> obs.definiteDecisionTarget() 1487 0 1488 >>> len(e) # blank + started states 1489 2 1490 >>> e.getActiveDecisions(1) 1491 {0} 1492 >>> obs.recordExplore("left", "West", "right") 1493 >>> obs.definiteDecisionTarget() 1494 2 1495 >>> len(e) # starting states + one step 1496 3 1497 >>> e.getActiveDecisions(1) 1498 {0} 1499 >>> e.movementAtStep(1) 1500 (0, 'left', 2) 1501 >>> e.getActiveDecisions(2) 1502 {2} 1503 >>> e.getActiveDecisions() 1504 {2} 1505 >>> e.getSituation().graph.nameFor(list(e.getActiveDecisions())[0]) 1506 'West' 1507 >>> obs.recordRetrace("right") # back at Start 1508 >>> obs.definiteDecisionTarget() 1509 0 1510 >>> len(e) # starting states + two steps 1511 4 1512 >>> e.getActiveDecisions(1) 1513 {0} 1514 >>> e.movementAtStep(1) 1515 (0, 'left', 2) 1516 >>> e.getActiveDecisions(2) 1517 {2} 1518 >>> e.movementAtStep(2) 1519 (2, 'right', 0) 1520 >>> e.getActiveDecisions(3) 1521 {0} 1522 >>> obs.recordRetrace("bad") # transition doesn't exist 1523 Traceback (most recent call last): 1524 ... 1525 exploration.journal.JournalParseError... 1526 >>> obs.definiteDecisionTarget() 1527 0 1528 >>> obs.recordObserve('right', 'East', 'left') 1529 >>> e.getSituation().graph.getTransitionRequirement('Start', 'right') 1530 ReqNothing() 1531 >>> obs.recordRequirement('crawl|small') 1532 >>> e.getSituation().graph.getTransitionRequirement('Start', 'right') 1533 ReqAny([ReqCapability('crawl'), ReqCapability('small')]) 1534 >>> obs.definiteDecisionTarget() 1535 0 1536 >>> obs.currentTransitionTarget() 1537 (0, 'right') 1538 >>> obs.currentReciprocalTarget() 1539 (3, 'left') 1540 >>> g = e.getSituation().graph 1541 >>> print(g.namesListing(g).rstrip('\\n')) 1542 0 (Start) 1543 1 (_u.0) 1544 2 (West) 1545 3 (East) 1546 >>> # The use of relative mode to add remote observations 1547 >>> obs.relative('East') 1548 >>> obs.definiteDecisionTarget() 1549 3 1550 >>> obs.recordObserve('top_vent') 1551 >>> obs.recordRequirement('crawl') 1552 >>> obs.recordReciprocalRequirement('crawl') 1553 >>> obs.recordMechanism('East', 'door', 'closed') # door starts closed 1554 >>> obs.recordAction('lever') 1555 >>> obs.recordTransitionConsequence( 1556 ... [base.effect(set=("door", "open")), base.effect(deactivate=True)] 1557 ... ) # lever opens the door 1558 >>> obs.recordExplore('right_door', 'Outside', 'left_door') 1559 >>> obs.definiteDecisionTarget() 1560 5 1561 >>> obs.recordRequirement('door:open') 1562 >>> obs.recordReciprocalRequirement('door:open') 1563 >>> obs.definiteDecisionTarget() 1564 5 1565 >>> obs.exploration.getExplorationStatus('East') 1566 'noticed' 1567 >>> obs.exploration.hasBeenVisited('East') 1568 False 1569 >>> obs.exploration.getExplorationStatus('Outside') 1570 'noticed' 1571 >>> obs.exploration.hasBeenVisited('Outside') 1572 False 1573 >>> obs.relative() # leave relative mode 1574 >>> len(e) # starting states + two steps, no steps happen in relative mode 1575 4 1576 >>> obs.definiteDecisionTarget() # out of relative mode; at Start 1577 0 1578 >>> g = e.getSituation().graph 1579 >>> g.getTransitionRequirement( 1580 ... g.getDestination('East', 'top_vent'), 1581 ... 'return' 1582 ... ) 1583 ReqCapability('crawl') 1584 >>> g.getTransitionRequirement('East', 'top_vent') 1585 ReqCapability('crawl') 1586 >>> g.getTransitionRequirement('East', 'right_door') 1587 ReqMechanism('door', 'open') 1588 >>> g.getTransitionRequirement('Outside', 'left_door') 1589 ReqMechanism('door', 'open') 1590 >>> print(g.namesListing(g).rstrip('\\n')) 1591 0 (Start) 1592 1 (_u.0) 1593 2 (West) 1594 3 (East) 1595 4 (_u.3) 1596 5 (Outside) 1597 >>> # Now we demonstrate the use of "observe" 1598 >>> e.getActiveDecisions() 1599 {0} 1600 >>> g.destinationsFrom(0) 1601 {'bottom': 1, 'left': 2, 'right': 3} 1602 >>> g.getDecision('Attic') is None 1603 True 1604 >>> obs.definiteDecisionTarget() 1605 0 1606 >>> obs.observe("\ 1607o up Attic down\\n\ 1608x up\\n\ 1609 n at: Attic\\n\ 1610o vent\\n\ 1611q crawl") 1612 >>> g = e.getSituation().graph 1613 >>> print(g.namesListing(g).rstrip('\\n')) 1614 0 (Start) 1615 1 (_u.0) 1616 2 (West) 1617 3 (East) 1618 4 (_u.3) 1619 5 (Outside) 1620 6 (Attic) 1621 7 (_u.6) 1622 >>> g.destinationsFrom(0) 1623 {'bottom': 1, 'left': 2, 'right': 3, 'up': 6} 1624 >>> g.nameFor(list(e.getActiveDecisions())[0]) 1625 'Attic' 1626 >>> g.getTransitionRequirement('Attic', 'vent') 1627 ReqCapability('crawl') 1628 >>> sorted(list(g.destinationsFrom('Attic').items())) 1629 [('down', 0), ('vent', 7)] 1630 >>> obs.definiteDecisionTarget() # in the Attic 1631 6 1632 >>> obs.observe("\ 1633a getCrawl\\n\ 1634 At gain crawl\\n\ 1635x vent East top_vent") # connecting to a previously-observed transition 1636 >>> g = e.getSituation().graph 1637 >>> print(g.namesListing(g).rstrip('\\n')) 1638 0 (Start) 1639 1 (_u.0) 1640 2 (West) 1641 3 (East) 1642 5 (Outside) 1643 6 (Attic) 1644 >>> g.getTransitionRequirement('East', 'top_vent') 1645 ReqCapability('crawl') 1646 >>> g.nameFor(g.getDestination('Attic', 'vent')) 1647 'East' 1648 >>> g.nameFor(g.getDestination('East', 'top_vent')) 1649 'Attic' 1650 >>> len(e) # exploration, action, and return are each 1 1651 7 1652 >>> e.getActiveDecisions(3) 1653 {0} 1654 >>> e.movementAtStep(3) 1655 (0, 'up', 6) 1656 >>> e.getActiveDecisions(4) 1657 {6} 1658 >>> g.nameFor(list(e.getActiveDecisions(4))[0]) 1659 'Attic' 1660 >>> e.movementAtStep(4) 1661 (6, 'getCrawl', 6) 1662 >>> g.nameFor(list(e.getActiveDecisions(5))[0]) 1663 'Attic' 1664 >>> e.movementAtStep(5) 1665 (6, 'vent', 3) 1666 >>> g.nameFor(list(e.getActiveDecisions(6))[0]) 1667 'East' 1668 >>> # Now let's pull the lever and go outside, but first, we'll 1669 >>> # return to the Start to demonstrate recordRetrace 1670 >>> # note that recordReturn only applies when the destination of the 1671 >>> # transition is not already known. 1672 >>> obs.recordRetrace('left') # back to Start 1673 >>> obs.definiteDecisionTarget() 1674 0 1675 >>> obs.recordRetrace('right') # and back to East 1676 >>> obs.definiteDecisionTarget() 1677 3 1678 >>> obs.exploration.mechanismState('door') 1679 'closed' 1680 >>> obs.recordRetrace('lever', isAction=True) # door is now open 1681 >>> obs.exploration.mechanismState('door') 1682 'open' 1683 >>> obs.exploration.getExplorationStatus('Outside') 1684 'noticed' 1685 >>> obs.recordExplore('right_door') 1686 >>> obs.definiteDecisionTarget() # now we're Outside 1687 5 1688 >>> obs.recordReturn('tunnelUnder', 'Start', 'bottom') 1689 >>> obs.definiteDecisionTarget() # back at the start 1690 0 1691 >>> g = e.getSituation().graph 1692 >>> print(g.namesListing(g).rstrip('\\n')) 1693 0 (Start) 1694 2 (West) 1695 3 (East) 1696 5 (Outside) 1697 6 (Attic) 1698 >>> g.destinationsFrom(0) 1699 {'left': 2, 'right': 3, 'up': 6, 'bottom': 5} 1700 >>> g.destinationsFrom(5) 1701 {'left_door': 3, 'tunnelUnder': 0} 1702 1703 An example of the use of `recordUnify` and `recordObviate`. 1704 1705 >>> obs = JournalObserver() 1706 >>> obs.observe(''' 1707 ... S start 1708 ... x right hall left 1709 ... x right room left 1710 ... x vent vents right_vent 1711 ... ''') 1712 >>> obs.recordObviate('middle_vent', 'hall', 'vent') 1713 >>> obs.recordExplore('left_vent', 'new_room', 'vent') 1714 >>> obs.recordUnify('start') 1715 >>> e = obs.getExploration() 1716 >>> len(e) 1717 6 1718 >>> e.getActiveDecisions(0) 1719 set() 1720 >>> [ 1721 ... e.getSituation(n).graph.nameFor(list(e.getActiveDecisions(n))[0]) 1722 ... for n in range(1, 6) 1723 ... ] 1724 ['start', 'hall', 'room', 'vents', 'start'] 1725 >>> g = e.getSituation().graph 1726 >>> g.getDestination('start', 'vent') 1727 3 1728 >>> g.getDestination('vents', 'left_vent') 1729 0 1730 >>> g.getReciprocal('start', 'vent') 1731 'left_vent' 1732 >>> g.getReciprocal('vents', 'left_vent') 1733 'vent' 1734 >>> 'new_room' in g 1735 False 1736 """ 1737 1738 parseFormat: JournalParseFormat 1739 """ 1740 The parse format used to parse entries supplied as text. This also 1741 ends up controlling some of the decision and transition naming 1742 conventions that are followed, so it is not safe to change it 1743 mid-journal; it should be set once before observation begins, and 1744 may be accessed but should not be changed. 1745 """ 1746 1747 exploration: core.DiscreteExploration 1748 """ 1749 This is the exploration object being built via journal observations. 1750 Note that the exploration object may be empty (i.e., have length 0) 1751 even after the first few entries have been recorded because in some 1752 cases entries are ambiguous and are not translated into exploration 1753 steps until a further entry resolves that ambiguity. 1754 """ 1755 1756 preferences: ObservationPreferences 1757 """ 1758 Preferences for the observation mechanisms. See 1759 `ObservationPreferences`. 1760 """ 1761 1762 uniqueNumber: int 1763 """ 1764 A unique number to be substituted (prefixed with '_') into 1765 underscore-substitutions within aliases. Will be incremented for each 1766 such substitution. 1767 """ 1768 1769 aliases: Dict[str, Tuple[List[str], str]] 1770 """ 1771 The defined aliases for this observer. Each alias has a name, and 1772 stored under that name is a list of parameters followed by a 1773 commands string. 1774 """ 1775 1776 def __init__(self, parseFormat: Optional[JournalParseFormat] = None): 1777 """ 1778 Sets up the observer. If a parse format is supplied, that will 1779 be used instead of the default parse format, which is just the 1780 result of creating a `ParseFormat` with default arguments. 1781 1782 A simple example: 1783 1784 >>> o = JournalObserver() 1785 >>> o.recordStart('hi') 1786 >>> o.exploration.getExplorationStatus('hi') 1787 'exploring' 1788 >>> e = o.getExploration() 1789 >>> len(e) 1790 2 1791 >>> g = e.getSituation().graph 1792 >>> len(g) 1793 1 1794 >>> e.getActiveContext() 1795 {\ 1796'capabilities': {'capabilities': set(), 'tokens': {}, 'skills': {}},\ 1797 'focalization': {'main': 'singular'},\ 1798 'activeDomains': {'main'},\ 1799 'activeDecisions': {'main': 0}\ 1800} 1801 >>> list(g.nodes)[0] 1802 0 1803 >>> o.recordObserve('option') 1804 >>> list(g.nodes) 1805 [0, 1] 1806 >>> [g.nameFor(d) for d in g.nodes] 1807 ['hi', '_u.0'] 1808 >>> o.recordZone(0, 'Lower') 1809 >>> [g.nameFor(d) for d in g.nodes] 1810 ['hi', '_u.0'] 1811 >>> e.getActiveDecisions() 1812 {0} 1813 >>> o.recordZone(1, 'Upper') 1814 >>> o.recordExplore('option', 'bye', 'back') 1815 >>> g = e.getSituation().graph 1816 >>> [g.nameFor(d) for d in g.nodes] 1817 ['hi', 'bye'] 1818 >>> o.recordObserve('option2') 1819 >>> import pytest 1820 >>> oldWarn = core.WARN_OF_NAME_COLLISIONS 1821 >>> core.WARN_OF_NAME_COLLISIONS = True 1822 >>> try: 1823 ... with pytest.warns(core.DecisionCollisionWarning): 1824 ... o.recordExplore('option2', 'Lower2::hi', 'back') 1825 ... finally: 1826 ... core.WARN_OF_NAME_COLLISIONS = oldWarn 1827 >>> g = e.getSituation().graph 1828 >>> [g.nameFor(d) for d in g.nodes] 1829 ['hi', 'bye', 'hi'] 1830 >>> # Prefix must be specified because it's ambiguous 1831 >>> o.recordWarp('Lower::hi') 1832 >>> g = e.getSituation().graph 1833 >>> [(d, g.nameFor(d)) for d in g.nodes] 1834 [(0, 'hi'), (1, 'bye'), (2, 'hi')] 1835 >>> e.getActiveDecisions() 1836 {0} 1837 >>> o.recordWarp('bye') 1838 >>> g = e.getSituation().graph 1839 >>> [(d, g.nameFor(d)) for d in g.nodes] 1840 [(0, 'hi'), (1, 'bye'), (2, 'hi')] 1841 >>> e.getActiveDecisions() 1842 {1} 1843 """ 1844 if parseFormat is None: 1845 self.parseFormat = JournalParseFormat() 1846 else: 1847 self.parseFormat = parseFormat 1848 1849 self.uniqueNumber = 0 1850 self.aliases = {} 1851 1852 # Set up default observation preferences 1853 self.preferences = observationPreferences() 1854 1855 # Create a blank exploration 1856 self.exploration = core.DiscreteExploration() 1857 1858 # Debugging support 1859 self.prevSteps: Optional[int] = None 1860 self.prevDecisions: Optional[int] = None 1861 1862 # Current context tracking focal context, domain, focus point, 1863 # decision, and/or transition that's currently most relevant: 1864 self.context = observationContext() 1865 1866 # TODO: Stack of contexts? 1867 # Stored observation context can be restored as the current 1868 # state later. This is used to support relative mode. 1869 self.storedContext: Optional[ 1870 ObservationContext 1871 ] = None 1872 1873 # Whether or not we're in relative mode. 1874 self.inRelativeMode = False 1875 1876 # Tracking which decisions we shouldn't auto-finalize 1877 self.dontFinalize: Set[base.DecisionID] = set() 1878 1879 # Tracking current parse location for errors & warnings 1880 self.journalTexts: List[str] = [] # a stack 'cause of macros 1881 self.parseIndices: List[int] = [] # also a stack 1882 1883 def getExploration(self) -> core.DiscreteExploration: 1884 """ 1885 Returns the exploration that this observer edits. 1886 """ 1887 return self.exploration 1888 1889 def nextUniqueName(self) -> str: 1890 """ 1891 Returns the next unique name for this observer, which is just an 1892 underscore followed by an integer. This increments 1893 `uniqueNumber`. 1894 """ 1895 result = '_' + str(self.uniqueNumber) 1896 self.uniqueNumber += 1 1897 return result 1898 1899 def currentDecisionTarget(self) -> Optional[base.DecisionID]: 1900 """ 1901 Returns the decision which decision-based changes should be 1902 applied to. Changes depending on whether relative mode is 1903 active. Will be `None` when there is no current position (e.g., 1904 before the exploration is started). 1905 """ 1906 return self.context['decision'] 1907 1908 def definiteDecisionTarget(self) -> base.DecisionID: 1909 """ 1910 Works like `currentDecisionTarget` but raises a 1911 `core.MissingDecisionError` instead of returning `None` if there 1912 is no current decision. 1913 """ 1914 result = self.currentDecisionTarget() 1915 1916 if result is None: 1917 raise core.MissingDecisionError("There is no current decision.") 1918 else: 1919 return result 1920 1921 def decisionTargetSpecifier(self) -> base.DecisionSpecifier: 1922 """ 1923 Returns a `base.DecisionSpecifier` which includes domain, zone, 1924 and name for the current decision. The zone used is the first 1925 alphabetical lowest-level zone that the decision is in, which 1926 *could* in some cases remain ambiguous. If you're worried about 1927 that, use `definiteDecisionTarget` instead. 1928 1929 Like `definiteDecisionTarget` this will crash if there isn't a 1930 current decision target. 1931 """ 1932 graph = self.exploration.getSituation().graph 1933 dID = self.definiteDecisionTarget() 1934 domain = graph.domainFor(dID) 1935 name = graph.nameFor(dID) 1936 inZones = graph.zoneAncestors(dID) 1937 # Alphabetical order (we have no better option) 1938 ordered = sorted( 1939 inZones, 1940 key=lambda z: ( 1941 graph.zoneHierarchyLevel(z), # level-0 first 1942 z # alphabetical as tie-breaker 1943 ) 1944 ) 1945 if len(ordered) > 0: 1946 useZone = ordered[0] 1947 else: 1948 useZone = None 1949 1950 return base.DecisionSpecifier( 1951 domain=domain, 1952 zone=useZone, 1953 name=name 1954 ) 1955 1956 def currentTransitionTarget( 1957 self 1958 ) -> Optional[Tuple[base.DecisionID, base.Transition]]: 1959 """ 1960 Returns the decision, transition pair that identifies the current 1961 transition which transition-based changes should apply to. Will 1962 be `None` when there is no current transition (e.g., just after a 1963 warp). 1964 """ 1965 transition = self.context['transition'] 1966 if transition is None: 1967 return None 1968 else: 1969 return transition 1970 1971 def currentReciprocalTarget( 1972 self 1973 ) -> Optional[Tuple[base.DecisionID, base.Transition]]: 1974 """ 1975 Returns the decision, transition pair that identifies the 1976 reciprocal of the `currentTransitionTarget`. Will be `None` when 1977 there is no current transition, or when the current transition 1978 doesn't have a reciprocal (e.g., after an ending). 1979 """ 1980 # relative mode is handled by `currentTransitionTarget` 1981 target = self.currentTransitionTarget() 1982 if target is None: 1983 return None 1984 return self.exploration.getSituation().graph.getReciprocalPair( 1985 *target 1986 ) 1987 1988 def checkFormat( 1989 self, 1990 entryType: str, 1991 decisionType: base.DecisionType, 1992 target: Union[None, JournalTargetType, Tuple[JournalTargetType, int]], 1993 pieces: List[str], 1994 expectedTargets: Union[ 1995 None, 1996 JournalTargetType, 1997 Collection[ 1998 Union[None, JournalTargetType] 1999 ] 2000 ], 2001 expectedPieces: Union[None, int, Collection[int]] 2002 ) -> None: 2003 """ 2004 Does format checking for a journal entry after 2005 `determineEntryType` is called. Checks that: 2006 2007 - A decision type other than 'active' is only used for entries 2008 where that makes sense. 2009 - The target is one from an allowed list of targets (or is `None` 2010 if `expectedTargets` is set to `None`) 2011 - The number of pieces of content is a specific number or within 2012 a specific collection of allowed numbers. If `expectedPieces` 2013 is set to None, there is no restriction on the number of 2014 pieces. 2015 2016 Raises a `JournalParseError` if its expectations are violated. 2017 """ 2018 if decisionType != 'active' and entryType not in ( 2019 'START', 2020 'explore', 2021 'retrace', 2022 'return', 2023 'action', 2024 'warp', 2025 'wait', 2026 'END', 2027 'revert' 2028 ): 2029 raise JournalParseError( 2030 f"{entryType} entry may not specify a non-standard" 2031 f" decision type (got {decisionType!r}), because it is" 2032 f" not associated with an exploration action." 2033 ) 2034 2035 if expectedTargets is None: 2036 if target is not None: 2037 raise JournalParseError( 2038 f"{entryType} entry may not specify a target." 2039 ) 2040 else: 2041 if isinstance(expectedTargets, str): 2042 expected = cast( 2043 Collection[Union[None, JournalTargetType]], 2044 [expectedTargets] 2045 ) 2046 else: 2047 expected = cast( 2048 Collection[Union[None, JournalTargetType]], 2049 expectedTargets 2050 ) 2051 tType = target 2052 if isinstance(tType, tuple): 2053 tType = tType[0] 2054 2055 if tType not in expected: 2056 raise JournalParseError( 2057 f"{entryType} entry had invalid target {target!r}." 2058 f" Expected one of:\n{expected}" 2059 ) 2060 2061 if expectedPieces is None: 2062 # No restriction 2063 pass 2064 elif isinstance(expectedPieces, int): 2065 if len(pieces) != expectedPieces: 2066 raise JournalParseError( 2067 f"{entryType} entry had {len(pieces)} arguments but" 2068 f" only {expectedPieces} argument(s) is/are allowed." 2069 ) 2070 2071 elif len(pieces) not in expectedPieces: 2072 allowed = ', '.join(str(x) for x in expectedPieces) 2073 raise JournalParseError( 2074 f"{entryType} entry had {len(pieces)} arguments but the" 2075 f" allowed argument counts are: {allowed}" 2076 ) 2077 2078 def parseOneCommand( 2079 self, 2080 journalText: str, 2081 startIndex: int 2082 ) -> Tuple[List[str], int]: 2083 """ 2084 Parses a single command from the given journal text, starting at 2085 the specified start index. Each command occupies a single line, 2086 except when blocks are present in which case it may stretch 2087 across multiple lines. This function splits the command up into a 2088 list of strings (including multi-line strings and/or strings 2089 with spaces in them when blocks are used). It returns that list 2090 of strings, along with the index after the newline at the end of 2091 the command it parsed (which could be used as the start index 2092 for the next command). If the command has no newline after it 2093 (only possible when the string ends) the returned index will be 2094 the length of the string. 2095 2096 If the line starting with the start character is empty (or just 2097 contains spaces), the result will be an empty list along with the 2098 index for the start of the next line. 2099 2100 Examples: 2101 2102 >>> o = JournalObserver() 2103 >>> commands = '''\\ 2104 ... S start 2105 ... o option 2106 ... 2107 ... x option next back 2108 ... o lever 2109 ... e edit [ 2110 ... o bridge 2111 ... q speed 2112 ... ] [ 2113 ... o bridge 2114 ... q X 2115 ... ] 2116 ... a lever 2117 ... ''' 2118 >>> o.parseOneCommand(commands, 0) 2119 (['S', 'start'], 8) 2120 >>> o.parseOneCommand(commands, 8) 2121 (['o', 'option'], 17) 2122 >>> o.parseOneCommand(commands, 17) 2123 ([], 18) 2124 >>> o.parseOneCommand(commands, 18) 2125 (['x', 'option', 'next', 'back'], 37) 2126 >>> o.parseOneCommand(commands, 37) 2127 (['o', 'lever'], 45) 2128 >>> bits, end = o.parseOneCommand(commands, 45) 2129 >>> bits[:2] 2130 ['e', 'edit'] 2131 >>> bits[2] 2132 'o bridge\\n q speed' 2133 >>> bits[3] 2134 'o bridge\\n q X' 2135 >>> len(bits) 2136 4 2137 >>> end 2138 116 2139 >>> o.parseOneCommand(commands, end) 2140 (['a', 'lever'], 124) 2141 2142 >>> o = JournalObserver() 2143 >>> s = "o up Attic down\\nx up\\no vent\\nq crawl" 2144 >>> o.parseOneCommand(s, 0) 2145 (['o', 'up', 'Attic', 'down'], 16) 2146 >>> o.parseOneCommand(s, 16) 2147 (['x', 'up'], 21) 2148 >>> o.parseOneCommand(s, 21) 2149 (['o', 'vent'], 28) 2150 >>> o.parseOneCommand(s, 28) 2151 (['q', 'crawl'], 35) 2152 """ 2153 2154 index = startIndex 2155 unit: Optional[str] = None 2156 bits: List[str] = [] 2157 pf = self.parseFormat # shortcut variable 2158 while index < len(journalText): 2159 char = journalText[index] 2160 if char.isspace(): 2161 # Space after non-spaces -> end of unit 2162 if unit is not None: 2163 bits.append(unit) 2164 unit = None 2165 # End of line -> end of command 2166 if char == '\n': 2167 index += 1 2168 break 2169 else: 2170 # Non-space -> check for block 2171 if char == pf.blockStart: 2172 if unit is not None: 2173 bits.append(unit) 2174 unit = None 2175 blockEnd = pf.findBlockEnd(journalText, index) 2176 block = journalText[index + 1:blockEnd - 1].strip() 2177 bits.append(block) 2178 index = blockEnd # +1 added below 2179 elif unit is None: # Initial non-space -> start of unit 2180 unit = char 2181 else: # Continuing non-space -> accumulate 2182 unit += char 2183 # Increment index 2184 index += 1 2185 2186 # Grab final unit if there is one hanging 2187 if unit is not None: 2188 bits.append(unit) 2189 2190 return (bits, index) 2191 2192 def warn(self, message: str) -> None: 2193 """ 2194 Issues a `JournalParseWarning`. 2195 """ 2196 if len(self.journalTexts) == 0 or len(self.parseIndices) == 0: 2197 warnings.warn(message, JournalParseWarning) 2198 else: 2199 # Note: We use the basal position info because that will 2200 # typically be much more useful when debugging 2201 ec = errorContext(self.journalTexts[0], self.parseIndices[0]) 2202 errorCM = textwrap.indent(errorContextMessage(ec), ' ') 2203 warnings.warn(errorCM + '\n' + message, JournalParseWarning) 2204 2205 def observe(self, journalText: str) -> None: 2206 """ 2207 Ingests one or more journal blocks in text format (as a 2208 multi-line string) and updates the exploration being built by 2209 this observer, as well as updating internal state. 2210 2211 This method can be called multiple times to process a longer 2212 journal incrementally including line-by-line. 2213 2214 The `journalText` and `parseIndex` fields will be updated during 2215 parsing to support contextual error messages and warnings. 2216 2217 ## Example: 2218 2219 >>> obs = JournalObserver() 2220 >>> oldWarn = core.WARN_OF_NAME_COLLISIONS 2221 >>> try: 2222 ... obs.observe('''\\ 2223 ... S Room1::start 2224 ... zz Region 2225 ... o nope 2226 ... q power|tokens*3 2227 ... o unexplored 2228 ... o onwards 2229 ... x onwards sub_room backwards 2230 ... t backwards 2231 ... o down 2232 ... 2233 ... x down Room2::middle up 2234 ... a box 2235 ... At deactivate 2236 ... At gain tokens*1 2237 ... o left 2238 ... o right 2239 ... gt blue 2240 ... 2241 ... x right Room3::middle left 2242 ... o right 2243 ... a miniboss 2244 ... At deactivate 2245 ... At gain power 2246 ... x right - left 2247 ... o ledge 2248 ... q tall 2249 ... t left 2250 ... t left 2251 ... t up 2252 ... 2253 ... x nope secret back 2254 ... ''') 2255 ... finally: 2256 ... core.WARN_OF_NAME_COLLISIONS = oldWarn 2257 >>> e = obs.getExploration() 2258 >>> len(e) 2259 13 2260 >>> g = e.getSituation().graph 2261 >>> len(g) 2262 9 2263 >>> def showDestinations(g, r): 2264 ... if isinstance(r, str): 2265 ... r = obs.parseFormat.parseDecisionSpecifier(r) 2266 ... d = g.destinationsFrom(r) 2267 ... for outgoing in sorted(d): 2268 ... req = g.getTransitionRequirement(r, outgoing) 2269 ... if req is None or req == base.ReqNothing(): 2270 ... req = '' 2271 ... else: 2272 ... req = ' ' + repr(req) 2273 ... print(outgoing, g.identityOf(d[outgoing]) + req) 2274 ... 2275 >>> "start" in g 2276 False 2277 >>> showDestinations(g, "Room1::start") 2278 down 4 (Room2::middle) 2279 nope 1 (Room1::secret) ReqAny([ReqCapability('power'),\ 2280 ReqTokens('tokens', 3)]) 2281 onwards 3 (Room1::sub_room) 2282 unexplored 2 (_u.1) 2283 >>> showDestinations(g, "Room1::secret") 2284 back 0 (Room1::start) 2285 >>> showDestinations(g, "Room1::sub_room") 2286 backwards 0 (Room1::start) 2287 >>> showDestinations(g, "Room2::middle") 2288 box 4 (Room2::middle) 2289 left 5 (_u.4) 2290 right 6 (Room3::middle) 2291 up 0 (Room1::start) 2292 >>> g.transitionTags(4, "right") 2293 {'blue': 1} 2294 >>> showDestinations(g, "Room3::middle") 2295 left 4 (Room2::middle) 2296 miniboss 6 (Room3::middle) 2297 right 7 (Room3::-) 2298 >>> showDestinations(g, "Room3::-") 2299 ledge 8 (_u.7) ReqCapability('tall') 2300 left 6 (Room3::middle) 2301 >>> showDestinations(g, "_u.7") 2302 return 7 (Room3::-) 2303 >>> e.getActiveDecisions() 2304 {1} 2305 >>> g.identityOf(1) 2306 '1 (Room1::secret)' 2307 2308 Note that there are plenty of other annotations not shown in 2309 this example; see `DEFAULT_FORMAT` for the default mapping from 2310 journal entry types to markers, and see `JournalEntryType` for 2311 the explanation for each entry type. 2312 2313 Most entries start with a marker (which includes one character 2314 for the type and possibly one for the target) followed by a 2315 single space, and everything after that is the content of the 2316 entry. 2317 """ 2318 # Normalize newlines 2319 journalText = journalText\ 2320 .replace('\r\n', '\n')\ 2321 .replace('\n\r', '\n')\ 2322 .replace('\r', '\n') 2323 2324 # Shortcut variable 2325 pf = self.parseFormat 2326 2327 # Remove comments from entire text 2328 journalText = pf.removeComments(journalText) 2329 2330 # TODO: Give access to comments in error messages? 2331 # Store for error messages 2332 self.journalTexts.append(journalText) 2333 self.parseIndices.append(0) 2334 2335 startAt = 0 2336 try: 2337 while startAt < len(journalText): 2338 self.parseIndices[-1] = startAt 2339 bits, startAt = self.parseOneCommand(journalText, startAt) 2340 2341 if len(bits) == 0: 2342 continue 2343 2344 eType, dType, eTarget, eParts = pf.determineEntryType(bits) 2345 if eType == 'preference': 2346 self.checkFormat( 2347 'preference', 2348 dType, 2349 eTarget, 2350 eParts, 2351 None, 2352 2 2353 ) 2354 pref = eParts[0] 2355 opAnn = get_type_hints(ObservationPreferences) 2356 if pref not in opAnn: 2357 raise JournalParseError( 2358 f"Invalid preference name {pref!r}." 2359 ) 2360 2361 prefVal: Union[None, str, bool, Set[str]] 2362 if opAnn[pref] is bool: 2363 prefVal = pf.onOff(eParts[1]) 2364 if prefVal is None: 2365 self.warn( 2366 f"On/off value {eParts[1]!r} is neither" 2367 f" {pf.markerFor('on')!r} nor" 2368 f" {pf.markerFor('off')!r}. Assuming" 2369 f" 'off'." 2370 ) 2371 elif opAnn[pref] == Set[str]: 2372 prefVal = set(' '.join(eParts[1:]).split()) 2373 else: # we assume it's a string 2374 assert opAnn[pref] is str 2375 prefVal = eParts[1] 2376 2377 # Set the preference value (type checked above) 2378 self.preferences[pref] = prefVal # type: ignore [literal-required] # noqa: E501 2379 2380 elif eType == 'alias': 2381 self.checkFormat( 2382 "alias", 2383 dType, 2384 eTarget, 2385 eParts, 2386 None, 2387 None 2388 ) 2389 2390 if len(eParts) < 2: 2391 raise JournalParseError( 2392 "Alias entry must include at least an alias" 2393 " name and a commands list." 2394 ) 2395 aliasName = eParts[0] 2396 parameters = eParts[1:-1] 2397 commands = eParts[-1] 2398 self.defineAlias(aliasName, parameters, commands) 2399 2400 elif eType == 'custom': 2401 self.checkFormat( 2402 "custom", 2403 dType, 2404 eTarget, 2405 eParts, 2406 None, 2407 None 2408 ) 2409 if len(eParts) == 0: 2410 raise JournalParseError( 2411 "Custom entry must include at least an alias" 2412 " name." 2413 ) 2414 self.deployAlias(eParts[0], eParts[1:]) 2415 2416 elif eType == 'DEBUG': 2417 self.checkFormat( 2418 "DEBUG", 2419 dType, 2420 eTarget, 2421 eParts, 2422 None, 2423 {1, 2} 2424 ) 2425 if eParts[0] not in get_args(DebugAction): 2426 raise JournalParseError( 2427 f"Invalid debug action: {eParts[0]!r}" 2428 ) 2429 dAction = cast(DebugAction, eParts[0]) 2430 if len(eParts) > 1: 2431 self.doDebug(dAction, eParts[1]) 2432 else: 2433 self.doDebug(dAction) 2434 2435 elif eType == 'START': 2436 self.checkFormat( 2437 "START", 2438 dType, 2439 eTarget, 2440 eParts, 2441 None, 2442 1 2443 ) 2444 2445 where = pf.parseDecisionSpecifier(eParts[0]) 2446 if isinstance(where, base.DecisionID): 2447 raise JournalParseError( 2448 f"Can't use {repr(where)} as a start" 2449 f" because the start must be a decision" 2450 f" name, not a decision ID." 2451 ) 2452 self.recordStart(where, dType) 2453 2454 elif eType == 'explore': 2455 self.checkFormat( 2456 "explore", 2457 dType, 2458 eTarget, 2459 eParts, 2460 None, 2461 {1, 2, 3} 2462 ) 2463 2464 tr = pf.parseTransitionWithOutcomes(eParts[0]) 2465 2466 if len(eParts) == 1: 2467 self.recordExplore(tr, decisionType=dType) 2468 elif len(eParts) == 2: 2469 destination = pf.parseDecisionSpecifier(eParts[1]) 2470 self.recordExplore( 2471 tr, 2472 destination, 2473 decisionType=dType 2474 ) 2475 else: 2476 destination = pf.parseDecisionSpecifier(eParts[1]) 2477 self.recordExplore( 2478 tr, 2479 destination, 2480 eParts[2], 2481 decisionType=dType 2482 ) 2483 2484 elif eType == 'return': 2485 self.checkFormat( 2486 "return", 2487 dType, 2488 eTarget, 2489 eParts, 2490 None, 2491 {1, 2, 3} 2492 ) 2493 tr = pf.parseTransitionWithOutcomes(eParts[0]) 2494 if len(eParts) > 1: 2495 destination = pf.parseDecisionSpecifier(eParts[1]) 2496 else: 2497 destination = None 2498 if len(eParts) > 2: 2499 reciprocal = eParts[2] 2500 else: 2501 reciprocal = None 2502 self.recordReturn( 2503 tr, 2504 destination, 2505 reciprocal, 2506 decisionType=dType 2507 ) 2508 2509 elif eType == 'action': 2510 self.checkFormat( 2511 "action", 2512 dType, 2513 eTarget, 2514 eParts, 2515 None, 2516 1 2517 ) 2518 tr = pf.parseTransitionWithOutcomes(eParts[0]) 2519 self.recordAction(tr, decisionType=dType) 2520 2521 elif eType == 'retrace': 2522 self.checkFormat( 2523 "retrace", 2524 dType, 2525 eTarget, 2526 eParts, 2527 (None, 'actionPart'), 2528 1 2529 ) 2530 tr = pf.parseTransitionWithOutcomes(eParts[0]) 2531 self.recordRetrace( 2532 tr, 2533 decisionType=dType, 2534 isAction=eTarget == 'actionPart' 2535 ) 2536 2537 elif eType == 'warp': 2538 self.checkFormat( 2539 "warp", 2540 dType, 2541 eTarget, 2542 eParts, 2543 None, 2544 {1} 2545 ) 2546 2547 destination = pf.parseDecisionSpecifier(eParts[0]) 2548 self.recordWarp(destination, decisionType=dType) 2549 2550 elif eType == 'wait': 2551 self.checkFormat( 2552 "wait", 2553 dType, 2554 eTarget, 2555 eParts, 2556 None, 2557 0 2558 ) 2559 self.recordWait(decisionType=dType) 2560 2561 elif eType == 'observe': 2562 self.checkFormat( 2563 "observe", 2564 dType, 2565 eTarget, 2566 eParts, 2567 (None, 'actionPart', 'endingPart'), 2568 (1, 2, 3) 2569 ) 2570 if eTarget is None: 2571 self.recordObserve(*eParts) 2572 elif eTarget == 'actionPart': 2573 if len(eParts) > 1: 2574 raise JournalParseError( 2575 f"Observing action {eParts[0]!r} at" 2576 f" {self.definiteDecisionTarget()!r}:" 2577 f" neither a destination nor a" 2578 f" reciprocal may be specified when" 2579 f" observing an action (did you mean to" 2580 f" observe a transition?)." 2581 ) 2582 self.recordObserveAction(*eParts) 2583 elif eTarget == 'endingPart': 2584 if len(eParts) > 1: 2585 raise JournalParseError( 2586 f"Observing ending {eParts[0]!r} at" 2587 f" {self.definiteDecisionTarget()!r}:" 2588 f" neither a destination nor a" 2589 f" reciprocal may be specified when" 2590 f" observing an ending (did you mean to" 2591 f" observe a transition?)." 2592 ) 2593 self.recordObserveEnding(*eParts) 2594 2595 elif eType == 'END': 2596 self.checkFormat( 2597 "END", 2598 dType, 2599 eTarget, 2600 eParts, 2601 (None, 'actionPart'), 2602 1 2603 ) 2604 self.recordEnd( 2605 eParts[0], 2606 eTarget == 'actionPart', 2607 decisionType=dType 2608 ) 2609 2610 elif eType == 'mechanism': 2611 self.checkFormat( 2612 "mechanism", 2613 dType, 2614 eTarget, 2615 eParts, 2616 None, 2617 1 2618 ) 2619 mReq = pf.parseRequirement(eParts[0]) 2620 if ( 2621 not isinstance(mReq, base.ReqMechanism) 2622 or not isinstance( 2623 mReq.mechanism, 2624 (base.MechanismName, base.MechanismSpecifier) 2625 ) 2626 ): 2627 raise JournalParseError( 2628 f"Invalid mechanism declaration" 2629 f" {eParts[0]!r}. Declaration must specify" 2630 f" mechanism name and starting state." 2631 ) 2632 mState = mReq.reqState 2633 if isinstance(mReq.mechanism, base.MechanismName): 2634 where = self.definiteDecisionTarget() 2635 mName = mReq.mechanism 2636 else: 2637 assert isinstance( 2638 mReq.mechanism, 2639 base.MechanismSpecifier 2640 ) 2641 mSpec = mReq.mechanism 2642 mName = mSpec.name 2643 if mSpec.decision is not None: 2644 where = base.DecisionSpecifier( 2645 mSpec.domain, 2646 mSpec.zone, 2647 mSpec.decision 2648 ) 2649 else: 2650 where = self.definiteDecisionTarget() 2651 graph = self.exploration.getSituation().graph 2652 thisDomain = graph.domainFor(where) 2653 theseZones = graph.zoneAncestors(where) 2654 if ( 2655 mSpec.domain is not None 2656 and mSpec.domain != thisDomain 2657 ): 2658 raise JournalParseError( 2659 f"Mechanism specifier {mSpec!r}" 2660 f" does not specify a decision but" 2661 f" includes domain {mSpec.domain!r}" 2662 f" which does not match the domain" 2663 f" {thisDomain!r} of the current" 2664 f" decision {graph.identityOf(where)}" 2665 ) 2666 if ( 2667 mSpec.zone is not None 2668 and mSpec.zone not in theseZones 2669 ): 2670 raise JournalParseError( 2671 f"Mechanism specifier {mSpec!r}" 2672 f" does not specify a decision but" 2673 f" includes zone {mSpec.zone!r}" 2674 f" which is not one of the zones" 2675 f" that the current decision" 2676 f" {graph.identityOf(where)} is in:" 2677 f"\n{theseZones!r}" 2678 ) 2679 self.recordMechanism(where, mName, mState) 2680 2681 elif eType == 'requirement': 2682 self.checkFormat( 2683 "requirement", 2684 dType, 2685 eTarget, 2686 eParts, 2687 (None, 'reciprocalPart', 'bothPart'), 2688 None 2689 ) 2690 req = pf.parseRequirement(' '.join(eParts)) 2691 if eTarget in (None, 'bothPart'): 2692 self.recordRequirement(req) 2693 if eTarget in ('reciprocalPart', 'bothPart'): 2694 self.recordReciprocalRequirement(req) 2695 2696 elif eType == 'effect': 2697 self.checkFormat( 2698 "effect", 2699 dType, 2700 eTarget, 2701 eParts, 2702 (None, 'reciprocalPart', 'bothPart'), 2703 None 2704 ) 2705 2706 consequence: base.Consequence 2707 try: 2708 consequence = pf.parseConsequence(' '.join(eParts)) 2709 except parsing.ParseError: 2710 consequence = [pf.parseEffect(' '.join(eParts))] 2711 2712 if eTarget in (None, 'bothPart'): 2713 self.recordTransitionConsequence(consequence) 2714 if eTarget in ('reciprocalPart', 'bothPart'): 2715 self.recordReciprocalConsequence(consequence) 2716 2717 elif eType == 'apply': 2718 self.checkFormat( 2719 "apply", 2720 dType, 2721 eTarget, 2722 eParts, 2723 (None, 'transitionPart'), 2724 None 2725 ) 2726 2727 toApply: base.Consequence 2728 try: 2729 toApply = pf.parseConsequence(' '.join(eParts)) 2730 except parsing.ParseError: 2731 toApply = [pf.parseEffect(' '.join(eParts))] 2732 2733 # If we targeted a transition, that means we wanted 2734 # to both apply the consequence now AND set it up as 2735 # an consequence of the transition we just took. 2736 if eTarget == 'transitionPart': 2737 if self.context['transition'] is None: 2738 raise JournalParseError( 2739 "Can't apply a consequence to a" 2740 " transition here because there is no" 2741 " current relevant transition." 2742 ) 2743 # We need to apply these consequences as part of 2744 # the transition so their trigger count will be 2745 # tracked properly, but we do not want to 2746 # re-apply the other parts of the consequence. 2747 self.recordAdditionalTransitionConsequence( 2748 toApply 2749 ) 2750 else: 2751 # Otherwise just apply the consequence 2752 self.exploration.applyExtraneousConsequence( 2753 toApply, 2754 where=self.context['transition'], 2755 moveWhich=self.context['focus'] 2756 ) 2757 # Note: no situation-based variables need 2758 # updating here 2759 2760 elif eType == 'tag': 2761 self.checkFormat( 2762 "tag", 2763 dType, 2764 eTarget, 2765 eParts, 2766 ( 2767 None, 2768 'decisionPart', 2769 'transitionPart', 2770 'reciprocalPart', 2771 'bothPart', 2772 'zonePart' 2773 ), 2774 None 2775 ) 2776 tag: base.Tag 2777 value: base.TagValue 2778 if len(eParts) == 0: 2779 raise JournalParseError( 2780 "tag entry must include at least a tag name." 2781 ) 2782 elif len(eParts) == 1: 2783 tag = eParts[0] 2784 value = 1 2785 elif len(eParts) == 2: 2786 tag, value = eParts 2787 value = pf.parseTagValue(value) 2788 else: 2789 raise JournalParseError( 2790 f"tag entry has too many parts (only a tag" 2791 f" name and a tag value are allowed). Got:" 2792 f" {eParts}" 2793 ) 2794 2795 if eTarget is None: 2796 self.recordTagStep(tag, value) 2797 elif eTarget == "decisionPart": 2798 self.recordTagDecision(tag, value) 2799 elif eTarget == "transitionPart": 2800 self.recordTagTranstion(tag, value) 2801 elif eTarget == "reciprocalPart": 2802 self.recordTagReciprocal(tag, value) 2803 elif eTarget == "bothPart": 2804 self.recordTagTranstion(tag, value) 2805 self.recordTagReciprocal(tag, value) 2806 elif eTarget == "zonePart": 2807 self.recordTagZone(0, tag, value) 2808 elif ( 2809 isinstance(eTarget, tuple) 2810 and len(eTarget) == 2 2811 and eTarget[0] == "zonePart" 2812 and isinstance(eTarget[1], int) 2813 ): 2814 self.recordTagZone(eTarget[1] - 1, tag, value) 2815 else: 2816 raise JournalParseError( 2817 f"Invalid tag target type {eTarget!r}." 2818 ) 2819 2820 elif eType == 'annotate': 2821 self.checkFormat( 2822 "annotate", 2823 dType, 2824 eTarget, 2825 eParts, 2826 ( 2827 None, 2828 'decisionPart', 2829 'transitionPart', 2830 'reciprocalPart', 2831 'bothPart' 2832 ), 2833 None 2834 ) 2835 if len(eParts) == 0: 2836 raise JournalParseError( 2837 "annotation may not be empty." 2838 ) 2839 combined = ' '.join(eParts) 2840 if eTarget is None: 2841 self.recordAnnotateStep(combined) 2842 elif eTarget == "decisionPart": 2843 self.recordAnnotateDecision(combined) 2844 elif eTarget == "transitionPart": 2845 self.recordAnnotateTranstion(combined) 2846 elif eTarget == "reciprocalPart": 2847 self.recordAnnotateReciprocal(combined) 2848 elif eTarget == "bothPart": 2849 self.recordAnnotateTranstion(combined) 2850 self.recordAnnotateReciprocal(combined) 2851 elif eTarget == "zonePart": 2852 self.recordAnnotateZone(0, combined) 2853 elif ( 2854 isinstance(eTarget, tuple) 2855 and len(eTarget) == 2 2856 and eTarget[0] == "zonePart" 2857 and isinstance(eTarget[1], int) 2858 ): 2859 self.recordAnnotateZone(eTarget[1] - 1, combined) 2860 else: 2861 raise JournalParseError( 2862 f"Invalid annotation target type {eTarget!r}." 2863 ) 2864 2865 elif eType == 'context': 2866 self.checkFormat( 2867 "context", 2868 dType, 2869 eTarget, 2870 eParts, 2871 None, 2872 1 2873 ) 2874 if eParts[0] == pf.markerFor('commonContext'): 2875 self.recordContextSwap(None) 2876 else: 2877 self.recordContextSwap(eParts[0]) 2878 2879 elif eType == 'domain': 2880 self.checkFormat( 2881 "domain", 2882 dType, 2883 eTarget, 2884 eParts, 2885 None, 2886 {1, 2, 3} 2887 ) 2888 inCommon = False 2889 if eParts[-1] == pf.markerFor('commonContext'): 2890 eParts = eParts[:-1] 2891 inCommon = True 2892 if len(eParts) == 3: 2893 raise JournalParseError( 2894 f"A domain entry may only have 1 or 2" 2895 f" arguments unless the last argument is" 2896 f" {repr(pf.markerFor('commonContext'))}" 2897 ) 2898 elif len(eParts) == 2: 2899 if eParts[0] == pf.markerFor('exclusiveDomain'): 2900 self.recordDomainFocus( 2901 eParts[1], 2902 exclusive=True, 2903 inCommon=inCommon 2904 ) 2905 elif eParts[0] == pf.markerFor('notApplicable'): 2906 # Deactivate the domain 2907 self.recordDomainUnfocus( 2908 eParts[1], 2909 inCommon=inCommon 2910 ) 2911 else: 2912 # Set up new domain w/ given focalization 2913 focalization = pf.parseFocalization(eParts[1]) 2914 self.recordNewDomain( 2915 eParts[0], 2916 focalization, 2917 inCommon=inCommon 2918 ) 2919 else: 2920 # Focus the domain (or possibly create it) 2921 self.recordDomainFocus( 2922 eParts[0], 2923 inCommon=inCommon 2924 ) 2925 2926 elif eType == 'focus': 2927 self.checkFormat( 2928 "focus", 2929 dType, 2930 eTarget, 2931 eParts, 2932 None, 2933 {1, 2} 2934 ) 2935 if len(eParts) == 2: # explicit domain 2936 self.recordFocusOn(eParts[1], eParts[0]) 2937 else: # implicit domain 2938 self.recordFocusOn(eParts[0]) 2939 2940 elif eType == 'zone': 2941 self.checkFormat( 2942 "zone", 2943 dType, 2944 eTarget, 2945 eParts, 2946 (None, 'zonePart'), 2947 1 2948 ) 2949 if eTarget is None: 2950 level = 0 2951 elif eTarget == 'zonePart': 2952 level = 1 2953 else: 2954 assert isinstance(eTarget, tuple) 2955 assert len(eTarget) == 2 2956 level = eTarget[1] 2957 self.recordZone(level, eParts[0]) 2958 2959 elif eType == 'unify': 2960 self.checkFormat( 2961 "unify", 2962 dType, 2963 eTarget, 2964 eParts, 2965 (None, 'transitionPart', 'reciprocalPart'), 2966 (1, 2) 2967 ) 2968 if eTarget is None: 2969 decisions = [ 2970 pf.parseDecisionSpecifier(p) 2971 for p in eParts 2972 ] 2973 self.recordUnify(*decisions) 2974 elif eTarget == 'transitionPart': 2975 if len(eParts) != 1: 2976 raise JournalParseError( 2977 "A transition unification entry may only" 2978 f" have one argument, but we got" 2979 f" {len(eParts)}." 2980 ) 2981 self.recordUnifyTransition(eParts[0]) 2982 elif eTarget == 'reciprocalPart': 2983 if len(eParts) != 1: 2984 raise JournalParseError( 2985 "A transition unification entry may only" 2986 f" have one argument, but we got" 2987 f" {len(eParts)}." 2988 ) 2989 self.recordUnifyReciprocal(eParts[0]) 2990 else: 2991 raise RuntimeError( 2992 f"Invalid target type {eTarget} after check" 2993 f" for unify entry!" 2994 ) 2995 2996 elif eType == 'obviate': 2997 self.checkFormat( 2998 "obviate", 2999 dType, 3000 eTarget, 3001 eParts, 3002 None, 3003 3 3004 ) 3005 transition, targetDecision, targetTransition = eParts 3006 self.recordObviate( 3007 transition, 3008 pf.parseDecisionSpecifier(targetDecision), 3009 targetTransition 3010 ) 3011 3012 elif eType == 'extinguish': 3013 self.checkFormat( 3014 "extinguish", 3015 dType, 3016 eTarget, 3017 eParts, 3018 ( 3019 None, 3020 'decisionPart', 3021 'transitionPart', 3022 'reciprocalPart', 3023 'bothPart' 3024 ), 3025 1 3026 ) 3027 if eTarget is None: 3028 eTarget = 'bothPart' 3029 if eTarget == 'decisionPart': 3030 self.recordExtinguishDecision( 3031 pf.parseDecisionSpecifier(eParts[0]) 3032 ) 3033 elif eTarget == 'transitionPart': 3034 transition = eParts[0] 3035 here = self.definiteDecisionTarget() 3036 self.recordExtinguishTransition( 3037 here, 3038 transition, 3039 False 3040 ) 3041 elif eTarget == 'bothPart': 3042 transition = eParts[0] 3043 here = self.definiteDecisionTarget() 3044 self.recordExtinguishTransition( 3045 here, 3046 transition, 3047 True 3048 ) 3049 else: # Must be reciprocalPart 3050 transition = eParts[0] 3051 here = self.definiteDecisionTarget() 3052 now = self.exploration.getSituation() 3053 rPair = now.graph.getReciprocalPair(here, transition) 3054 if rPair is None: 3055 raise JournalParseError( 3056 f"Attempted to extinguish the" 3057 f" reciprocal of transition" 3058 f" {transition!r} which " 3059 f" has no reciprocal (or which" 3060 f" doesn't exist from decision" 3061 f" {now.graph.identityOf(here)})." 3062 ) 3063 3064 self.recordExtinguishTransition( 3065 rPair[0], 3066 rPair[1], 3067 deleteReciprocal=False 3068 ) 3069 3070 elif eType == 'complicate': 3071 self.checkFormat( 3072 "complicate", 3073 dType, 3074 eTarget, 3075 eParts, 3076 None, 3077 4 3078 ) 3079 target, newName, newReciprocal, newRR = eParts 3080 self.recordComplicate( 3081 target, 3082 newName, 3083 newReciprocal, 3084 newRR 3085 ) 3086 3087 elif eType == 'status': 3088 self.checkFormat( 3089 "status", 3090 dType, 3091 eTarget, 3092 eParts, 3093 (None, 'unfinishedPart'), 3094 {0, 1} 3095 ) 3096 dID = self.definiteDecisionTarget() 3097 # Default status to use 3098 status: base.ExplorationStatus = 'explored' 3099 # Figure out whether a valid status was provided 3100 if len(eParts) > 0: 3101 assert len(eParts) == 1 3102 eArgs = get_args(base.ExplorationStatus) 3103 if eParts[0] not in eArgs: 3104 raise JournalParseError( 3105 f"Invalid explicit exploration status" 3106 f" {eParts[0]!r}. Exploration statuses" 3107 f" must be one of:\n{eArgs!r}" 3108 ) 3109 status = cast(base.ExplorationStatus, eParts[0]) 3110 # Record new status, as long as we have an explicit 3111 # status OR 'unfinishedPart' was not given. If 3112 # 'unfinishedPart' was given, also block auto updates 3113 if eTarget == 'unfinishedPart': 3114 if len(eParts) > 0: 3115 self.recordStatus(dID, status) 3116 self.recordObservationIncomplete(dID) 3117 else: 3118 self.recordStatus(dID, status) 3119 3120 elif eType == 'revert': 3121 self.checkFormat( 3122 "revert", 3123 dType, 3124 eTarget, 3125 eParts, 3126 None, 3127 None 3128 ) 3129 aspects: List[str] 3130 if len(eParts) == 0: 3131 slot = base.DEFAULT_SAVE_SLOT 3132 aspects = [] 3133 else: 3134 slot = eParts[0] 3135 aspects = eParts[1:] 3136 aspectsSet = set(aspects) 3137 if len(aspectsSet) == 0: 3138 aspectsSet = self.preferences['revertAspects'] 3139 self.recordRevert(slot, aspectsSet, decisionType=dType) 3140 3141 elif eType == 'fulfills': 3142 self.checkFormat( 3143 "fulfills", 3144 dType, 3145 eTarget, 3146 eParts, 3147 None, 3148 2 3149 ) 3150 condition = pf.parseRequirement(eParts[0]) 3151 fReq = pf.parseRequirement(eParts[1]) 3152 fulfills: Union[ 3153 base.Capability, 3154 Tuple[base.MechanismID, base.MechanismState] 3155 ] 3156 if isinstance(fReq, base.ReqCapability): 3157 fulfills = fReq.capability 3158 elif isinstance(fReq, base.ReqMechanism): 3159 mState = fReq.reqState 3160 if isinstance(fReq.mechanism, int): 3161 mID = fReq.mechanism 3162 else: 3163 graph = self.exploration.getSituation().graph 3164 mID = graph.resolveMechanism( 3165 fReq.mechanism, 3166 {self.definiteDecisionTarget()} 3167 ) 3168 fulfills = (mID, mState) 3169 else: 3170 raise JournalParseError( 3171 f"Cannot fulfill {eParts[1]!r} because it" 3172 f" doesn't specify either a capability or a" 3173 f" mechanism/state pair." 3174 ) 3175 self.recordFulfills(condition, fulfills) 3176 3177 elif eType == 'relative': 3178 self.checkFormat( 3179 "relative", 3180 dType, 3181 eTarget, 3182 eParts, 3183 (None, 'transitionPart'), 3184 (0, 1, 2) 3185 ) 3186 if ( 3187 len(eParts) == 1 3188 and eParts[0] == self.parseFormat.markerFor( 3189 'relative' 3190 ) 3191 ): 3192 self.relative() 3193 elif eTarget == 'transitionPart': 3194 self.relative(None, *eParts) 3195 else: 3196 self.relative(*eParts) 3197 3198 else: 3199 raise NotImplementedError( 3200 f"Unrecognized event type {eType!r}." 3201 ) 3202 except Exception as e: 3203 raise LocatedJournalParseError( 3204 journalText, 3205 self.parseIndices[-1], 3206 e 3207 ) 3208 finally: 3209 self.journalTexts.pop() 3210 self.parseIndices.pop() 3211 3212 def defineAlias( 3213 self, 3214 name: str, 3215 parameters: Sequence[str], 3216 commands: str 3217 ) -> None: 3218 """ 3219 Defines an alias: a block of commands that can be played back 3220 later using the 'custom' command, with parameter substitutions. 3221 3222 If an alias with the specified name already existed, it will be 3223 replaced. 3224 3225 Each of the listed parameters must be supplied when invoking the 3226 alias, and where they appear within curly braces in the commands 3227 string, they will be substituted in. Additional names starting 3228 with '_' plus an optional integer will also be substituted with 3229 unique names (see `nextUniqueName`), with the same name being 3230 used for every instance that shares the same numerical suffix 3231 within each application of the command. Substitution points must 3232 not include spaces; if an open curly brace is followed by 3233 whitesapce or where a close curly brace is proceeded by 3234 whitespace, those will be treated as normal curly braces and will 3235 not create a substitution point. 3236 3237 For example: 3238 3239 >>> o = JournalObserver() 3240 >>> o.defineAlias( 3241 ... 'hintRoom', 3242 ... ['name'], 3243 ... 'o {_5}\\nx {_5} {name} {_5}\\ngd hint\\nt {_5}' 3244 ... ) # _5 to show that the suffix doesn't matter if it's consistent 3245 >>> o.defineAlias( 3246 ... 'trade', 3247 ... ['gain', 'lose'], 3248 ... 'A { gain {gain}; lose {lose} }' 3249 ... ) # note outer curly braces 3250 >>> o.recordStart('start') 3251 >>> o.deployAlias('hintRoom', ['hint1']) 3252 >>> o.deployAlias('hintRoom', ['hint2']) 3253 >>> o.deployAlias('trade', ['flower*1', 'coin*1']) 3254 >>> e = o.getExploration() 3255 >>> e.movementAtStep(0) 3256 (None, None, 0) 3257 >>> e.movementAtStep(1) 3258 (0, '_0', 1) 3259 >>> e.movementAtStep(2) 3260 (1, '_0', 0) 3261 >>> e.movementAtStep(3) 3262 (0, '_1', 2) 3263 >>> e.movementAtStep(4) 3264 (2, '_1', 0) 3265 >>> g = e.getSituation().graph 3266 >>> len(g) 3267 3 3268 >>> g.namesListing([0, 1, 2]) 3269 ' 0 (start)\\n 1 (hint1)\\n 2 (hint2)\\n' 3270 >>> g.decisionTags('hint1') 3271 {'hint': 1} 3272 >>> g.decisionTags('hint2') 3273 {'hint': 1} 3274 >>> e.tokenCountNow('coin') 3275 -1 3276 >>> e.tokenCountNow('flower') 3277 1 3278 """ 3279 # Going to be formatted twice so {{{{ -> {{ -> { 3280 # TODO: Move this logic into deployAlias 3281 commands = re.sub(r'{(\s)', r'{{{{\1', commands) 3282 commands = re.sub(r'(\s)}', r'\1}}}}', commands) 3283 self.aliases[name] = (list(parameters), commands) 3284 3285 def deployAlias(self, name: str, arguments: Sequence[str]) -> None: 3286 """ 3287 Deploys an alias, taking its command string and substituting in 3288 the provided argument values for each of the alias' parameters, 3289 plus any unique names that it requests. Substitution happens 3290 first for named arguments and then for unique strings, so named 3291 arguments of the form '{_-n-}' where -n- is an integer will end 3292 up being substituted for unique names. Sets of curly braces that 3293 have at least one space immediately after the open brace or 3294 immediately before the closing brace will be interpreted as 3295 normal curly braces, NOT as the start/end of a substitution 3296 point. 3297 3298 There are a few automatic arguments (although these can be 3299 overridden if the alias definition uses the same argument name 3300 explicitly): 3301 - '__here__' will substitute to the ID of the current decision 3302 based on the `ObservationContext`, or will generate an error 3303 if there is none. This is the current decision at the moment 3304 the alias is deployed, NOT based on steps within the alias up 3305 to the substitution point. 3306 - '__hereName__' will substitute the name of the current 3307 decision. 3308 - '__zone__' will substitute the name of the alphabetically 3309 first level-0 zone ancestor of the current decision. 3310 - '__region__' will substitute the name of the alphabetically 3311 first level-1 zone ancestor of the current decision. 3312 - '__transition__' will substitute to the name of the current 3313 transition, or will generate an error if there is none. Note 3314 that the current transition is sometimes NOT a valid 3315 transition from the current decision, because when you take 3316 a transition, that transition's name is current but the 3317 current decision is its destination. 3318 - '__reciprocal__' will substitute to the name of the reciprocal 3319 of the current transition. 3320 - '__trBase__' will substitute to the decision from which the 3321 current transition departs. 3322 - '__trDest__' will substitute to the destination of the current 3323 transition. 3324 - '__prev__' will substitute to the ID of the primary decision in 3325 the previous exploration step, (which is NOT always the 3326 previous current decision of the `ObservationContext`, 3327 especially in relative mode). 3328 - '__across__-name-__' where '-name-' is a transition name will 3329 substitute to the decision reached by traversing that 3330 transition from the '__here__' decision. Note that the 3331 transition name used must be a valid Python identifier. 3332 3333 Raises a `JournalParseError` if the specified alias does not 3334 exist, or if the wrong number of parameters has been supplied. 3335 3336 See `defineAlias` for an example. 3337 """ 3338 # Fetch the alias 3339 alias = self.aliases.get(name) 3340 if alias is None: 3341 raise JournalParseError( 3342 f"Alias {name!r} has not been defined yet." 3343 ) 3344 paramNames, commands = alias 3345 3346 # Check arguments 3347 arguments = list(arguments) 3348 if len(arguments) != len(paramNames): 3349 raise JournalParseError( 3350 f"Alias {name!r} requires {len(paramNames)} parameters," 3351 f" but you supplied {len(arguments)}." 3352 ) 3353 3354 # Find unique names 3355 uniques = set([ 3356 match.strip('{}') 3357 for match in re.findall('{_[0-9]*}', commands) 3358 ]) 3359 3360 # Build substitution dictionary that passes through uniques 3361 firstWave = {unique: '{' + unique + '}' for unique in uniques} 3362 3363 # Fill in each non-overridden & requested auto variable: 3364 graph = self.exploration.getSituation().graph 3365 if '{__here__}' in commands and '__here__' not in firstWave: 3366 firstWave['__here__'] = self.definiteDecisionTarget() 3367 if '{__hereName__}' in commands and '__hereName__' not in firstWave: 3368 firstWave['__hereName__'] = graph.nameFor( 3369 self.definiteDecisionTarget() 3370 ) 3371 if '{__zone__}' in commands and '__zone__' not in firstWave: 3372 baseDecision = self.definiteDecisionTarget() 3373 parents = sorted( 3374 ancestor 3375 for ancestor in graph.zoneAncestors(baseDecision) 3376 if graph.zoneHierarchyLevel(ancestor) == 0 3377 ) 3378 if len(parents) == 0: 3379 raise JournalParseError( 3380 f"Used __zone__ in a macro, but the current" 3381 f" decision {graph.identityOf(baseDecision)} is not" 3382 f" in any level-0 zones." 3383 ) 3384 firstWave['__zone__'] = parents[0] 3385 if '{__region__}' in commands and '__region__' not in firstWave: 3386 baseDecision = self.definiteDecisionTarget() 3387 grandparents = sorted( 3388 ancestor 3389 for ancestor in graph.zoneAncestors(baseDecision) 3390 if graph.zoneHierarchyLevel(ancestor) == 1 3391 ) 3392 if len(grandparents) == 0: 3393 raise JournalParseError( 3394 f"Used __region__ in a macro, but the current" 3395 f" decision {graph.identityOf(baseDecision)} is not" 3396 f" in any level-1 zones." 3397 ) 3398 firstWave['__region__'] = grandparents[0] 3399 if ( 3400 '{__transition__}' in commands 3401 and '__transition__' not in firstWave 3402 ): 3403 ctxTr = self.currentTransitionTarget() 3404 if ctxTr is None: 3405 raise JournalParseError( 3406 f"Can't deploy alias {name!r} because it has a" 3407 f" __transition__ auto-slot but there is no current" 3408 f" transition at the current exploration step." 3409 ) 3410 firstWave['__transition__'] = ctxTr[1] 3411 if '{__trBase__}' in commands and '__trBase__' not in firstWave: 3412 ctxTr = self.currentTransitionTarget() 3413 if ctxTr is None: 3414 raise JournalParseError( 3415 f"Can't deploy alias {name!r} because it has a" 3416 f" __transition__ auto-slot but there is no current" 3417 f" transition at the current exploration step." 3418 ) 3419 firstWave['__trBase__'] = ctxTr[0] 3420 if '{__trDest__}' in commands and '__trDest__' not in firstWave: 3421 ctxTr = self.currentTransitionTarget() 3422 if ctxTr is None: 3423 raise JournalParseError( 3424 f"Can't deploy alias {name!r} because it has a" 3425 f" __transition__ auto-slot but there is no current" 3426 f" transition at the current exploration step." 3427 ) 3428 firstWave['__trDest__'] = graph.getDestination(*ctxTr) 3429 if ( 3430 '{__reciprocal__}' in commands 3431 and '__reciprocal__' not in firstWave 3432 ): 3433 ctxTr = self.currentTransitionTarget() 3434 if ctxTr is None: 3435 raise JournalParseError( 3436 f"Can't deploy alias {name!r} because it has a" 3437 f" __transition__ auto-slot but there is no current" 3438 f" transition at the current exploration step." 3439 ) 3440 firstWave['__reciprocal__'] = graph.getReciprocal(*ctxTr) 3441 if '{__prev__}' in commands and '__prev__' not in firstWave: 3442 try: 3443 prevPrimary = self.exploration.primaryDecision(-2) 3444 except IndexError: 3445 raise JournalParseError( 3446 f"Can't deploy alias {name!r} because it has a" 3447 f" __prev__ auto-slot but there is no previous" 3448 f" exploration step." 3449 ) 3450 if prevPrimary is None: 3451 raise JournalParseError( 3452 f"Can't deploy alias {name!r} because it has a" 3453 f" __prev__ auto-slot but there is no primary" 3454 f" decision for the previous exploration step." 3455 ) 3456 firstWave['__prev__'] = prevPrimary 3457 3458 here = self.currentDecisionTarget() 3459 for match in re.findall(r'{__across__[^ ]\+__}', commands): 3460 if here is None: 3461 raise JournalParseError( 3462 f"Can't deploy alias {name!r} because it has an" 3463 f" __across__ auto-slot but there is no current" 3464 f" decision." 3465 ) 3466 transition = match[11:-3] 3467 dest = graph.getDestination(here, transition) 3468 firstWave[f'__across__{transition}__'] = dest 3469 firstWave.update({ 3470 param: value 3471 for (param, value) in zip(paramNames, arguments) 3472 }) 3473 3474 # Substitute parameter values 3475 commands = commands.format(**firstWave) 3476 3477 uniques = set([ 3478 match.strip('{}') 3479 for match in re.findall('{_[0-9]*}', commands) 3480 ]) 3481 3482 # Substitute for remaining unique names 3483 uniqueValues = { 3484 unique: self.nextUniqueName() 3485 for unique in sorted(uniques) # sort for stability 3486 } 3487 commands = commands.format(**uniqueValues) 3488 3489 # Now run the commands 3490 self.observe(commands) 3491 3492 def doDebug(self, action: DebugAction, arg: str = "") -> None: 3493 """ 3494 Prints out a debugging message to stderr. Useful for figuring 3495 out parsing errors. See also `DebugAction` and 3496 `JournalEntryType. Certain actions allow an extra argument. The 3497 action will be one of: 3498 - 'here': prints the ID and name of the current decision, or 3499 `None` if there isn't one. 3500 - 'transition': prints the name of the current transition, or `None` 3501 if there isn't one. 3502 - 'destinations': prints the ID and name of the current decision, 3503 followed by the names of each outgoing transition and their 3504 destinations. Includes any requirements the transitions have. 3505 If an extra argument is supplied, looks up that decision and 3506 prints destinations from there. 3507 - 'steps': prints out the number of steps in the current exploration, 3508 plus the number since the most recent use of 'steps'. 3509 - 'decisions': prints out the number of decisions in the current 3510 graph, plus the number added/removed since the most recent use of 3511 'decisions'. 3512 - 'active': prints out the names listing of all currently active 3513 decisions. 3514 - 'primary': prints out the identity of the current primary 3515 decision, or None if there is none. 3516 - 'saved': prints out the primary decision for the state saved in 3517 the default save slot, or for a specific save slot if a 3518 second argument is given. 3519 - 'inventory': Displays all current capabilities, tokens, and 3520 skills. 3521 - 'mechanisms': Displays all current mechanisms and their states. 3522 - 'equivalences': Displays all current equivalences, along with 3523 whether or not they're active. 3524 """ 3525 graph = self.exploration.getSituation().graph 3526 if arg != '' and action not in ('destinations', 'saved'): 3527 raise JournalParseError( 3528 f"Invalid debug command {action!r} with arg {arg!r}:" 3529 f" Only 'destination' and 'saved' actions may include a" 3530 f" second argument." 3531 ) 3532 if action == "here": 3533 dt = self.currentDecisionTarget() 3534 print( 3535 f"Current decision is: {graph.identityOf(dt)}", 3536 file=sys.stderr 3537 ) 3538 elif action == "transition": 3539 tTarget = self.currentTransitionTarget() 3540 if tTarget is None: 3541 print("Current transition is: None", file=sys.stderr) 3542 else: 3543 tDecision, tTransition = tTarget 3544 print( 3545 ( 3546 f"Current transition is {tTransition!r} from" 3547 f" {graph.identityOf(tDecision)}." 3548 ), 3549 file=sys.stderr 3550 ) 3551 elif action == "destinations": 3552 if arg == "": 3553 here = self.currentDecisionTarget() 3554 adjective = "current" 3555 if here is None: 3556 print("There is no current decision.", file=sys.stderr) 3557 else: 3558 adjective = "target" 3559 dHint = None 3560 zHint = None 3561 tSpec = self.decisionTargetSpecifier() 3562 if tSpec is not None: 3563 dHint = tSpec.domain 3564 zHint = tSpec.zone 3565 here = self.exploration.getSituation().graph.getDecision( 3566 self.parseFormat.parseDecisionSpecifier(arg), 3567 zoneHint=zHint, 3568 domainHint=dHint, 3569 ) 3570 if here is None: 3571 print("Decision {arg!r} was not found.", file=sys.stderr) 3572 3573 if here is not None: 3574 dests = graph.destinationsFrom(here) 3575 outgoing = { 3576 route: dests[route] 3577 for route in dests 3578 if dests[route] != here 3579 } 3580 actions = { 3581 route: dests[route] 3582 for route in dests 3583 if dests[route] == here 3584 } 3585 print( 3586 f"The {adjective} decision is: {graph.identityOf(here)}", 3587 file=sys.stderr 3588 ) 3589 if len(outgoing) == 0: 3590 print( 3591 ( 3592 "There are no outgoing transitions at this" 3593 " decision." 3594 ), 3595 file=sys.stderr 3596 ) 3597 else: 3598 print( 3599 ( 3600 f"There are {len(outgoing)} outgoing" 3601 f" transition(s):" 3602 ), 3603 file=sys.stderr 3604 ) 3605 for transition in outgoing: 3606 destination = outgoing[transition] 3607 req = graph.getTransitionRequirement( 3608 here, 3609 transition 3610 ) 3611 rstring = '' 3612 if req != base.ReqNothing(): 3613 rstring = f" (requires {req})" 3614 print( 3615 ( 3616 f" {transition!r} ->" 3617 f" {graph.identityOf(destination)}{rstring}" 3618 ), 3619 file=sys.stderr 3620 ) 3621 3622 if len(actions) > 0: 3623 print( 3624 f"There are {len(actions)} actions:", 3625 file=sys.stderr 3626 ) 3627 for oneAction in actions: 3628 req = graph.getTransitionRequirement( 3629 here, 3630 oneAction 3631 ) 3632 rstring = '' 3633 if req != base.ReqNothing(): 3634 rstring = f" (requires {req})" 3635 print( 3636 f" {oneAction!r}{rstring}", 3637 file=sys.stderr 3638 ) 3639 3640 elif action == "steps": 3641 steps = len(self.getExploration()) 3642 if self.prevSteps is not None: 3643 elapsed = steps - cast(int, self.prevSteps) 3644 print( 3645 ( 3646 f"There are {steps} steps in the current" 3647 f" exploration (which is {elapsed} more than" 3648 f" there were at the previous check)." 3649 ), 3650 file=sys.stderr 3651 ) 3652 else: 3653 print( 3654 ( 3655 f"There are {steps} steps in the current" 3656 f" exploration." 3657 ), 3658 file=sys.stderr 3659 ) 3660 self.prevSteps = steps 3661 3662 elif action == "decisions": 3663 count = len(self.getExploration().getSituation().graph) 3664 if self.prevDecisions is not None: 3665 elapsed = count - self.prevDecisions 3666 print( 3667 ( 3668 f"There are {count} decisions in the current" 3669 f" graph (which is {elapsed} more than there" 3670 f" were at the previous check)." 3671 ), 3672 file=sys.stderr 3673 ) 3674 else: 3675 print( 3676 ( 3677 f"There are {count} decisions in the current" 3678 f" graph." 3679 ), 3680 file=sys.stderr 3681 ) 3682 self.prevDecisions = count 3683 elif action == "active": 3684 active = self.exploration.getActiveDecisions() 3685 now = self.exploration.getSituation() 3686 print( 3687 "Active decisions:\n", 3688 now.graph.namesListing(active), 3689 file=sys.stderr 3690 ) 3691 elif action == "primary": 3692 e = self.exploration 3693 primary = e.primaryDecision() 3694 if primary is None: 3695 pr = "None" 3696 else: 3697 pr = e.getSituation().graph.identityOf(primary) 3698 print(f"Primary decision: {pr}", file=sys.stderr) 3699 elif action == "saved": 3700 now = self.exploration.getSituation() 3701 slot = base.DEFAULT_SAVE_SLOT 3702 if arg != "": 3703 slot = arg 3704 saved = now.saves.get(slot) 3705 if saved is None: 3706 print(f"Slot {slot!r} has no saved data.", file=sys.stderr) 3707 else: 3708 savedGraph, savedState = saved 3709 savedPrimary = savedGraph.identityOf( 3710 savedState['primaryDecision'] 3711 ) 3712 print(f"Saved at decision: {savedPrimary}", file=sys.stderr) 3713 elif action == "inventory": 3714 now = self.exploration.getSituation() 3715 commonCap = now.state['common']['capabilities'] 3716 activeCap = now.state['contexts'][now.state['activeContext']][ 3717 'capabilities' 3718 ] 3719 merged = base.mergeCapabilitySets(commonCap, activeCap) 3720 capCount = len(merged['capabilities']) 3721 tokCount = len(merged['tokens']) 3722 skillCount = len(merged['skills']) 3723 print( 3724 ( 3725 f"{capCount} capability/ies, {tokCount} token type(s)," 3726 f" and {skillCount} skill(s)" 3727 ), 3728 file=sys.stderr 3729 ) 3730 if capCount > 0: 3731 print("Capabilities (alphabetical order):", file=sys.stderr) 3732 for cap in sorted(merged['capabilities']): 3733 print(f" {cap!r}", file=sys.stderr) 3734 if tokCount > 0: 3735 print("Tokens (alphabetical order):", file=sys.stderr) 3736 for tok in sorted(merged['tokens']): 3737 print( 3738 f" {tok!r}: {merged['tokens'][tok]}", 3739 file=sys.stderr 3740 ) 3741 if skillCount > 0: 3742 print("Skill levels (alphabetical order):", file=sys.stderr) 3743 for skill in sorted(merged['skills']): 3744 print( 3745 f" {skill!r}: {merged['skills'][skill]}", 3746 file=sys.stderr 3747 ) 3748 elif action == "mechanisms": 3749 now = self.exploration.getSituation() 3750 grpah = now.graph 3751 mechs = now.state['mechanisms'] 3752 inGraph = set(graph.mechanisms) - set(mechs) 3753 print( 3754 ( 3755 f"{len(mechs)} mechanism(s) in known states;" 3756 f" {len(inGraph)} additional mechanism(s) in the" 3757 f" default state" 3758 ), 3759 file=sys.stderr 3760 ) 3761 if len(mechs) > 0: 3762 print("Mechanism(s) in known state(s):", file=sys.stderr) 3763 for mID in sorted(mechs): 3764 mState = mechs[mID] 3765 whereID, mName = graph.mechanisms[mID] 3766 if whereID is None: 3767 whereStr = " (global)" 3768 else: 3769 domain = graph.domainFor(whereID) 3770 whereStr = f" at {graph.identityOf(whereID)}" 3771 print( 3772 f" {mName}:{mState!r} - {mID}{whereStr}", 3773 file=sys.stderr 3774 ) 3775 if len(inGraph) > 0: 3776 print("Mechanism(s) in the default state:", file=sys.stderr) 3777 for mID in sorted(inGraph): 3778 whereID, mName = graph.mechanisms[mID] 3779 if whereID is None: 3780 whereStr = " (global)" 3781 else: 3782 domain = graph.domainFor(whereID) 3783 whereStr = f" at {graph.identityOf(whereID)}" 3784 print(f" {mID} - {mName}){whereStr}", file=sys.stderr) 3785 elif action == "equivalences": 3786 now = self.exploration.getSituation() 3787 eqDict = now.graph.equivalences 3788 if len(eqDict) > 0: 3789 print(f"{len(eqDict)} equivalences:", file=sys.stderr) 3790 for hasEq in eqDict: 3791 if isinstance(hasEq, tuple): 3792 assert len(hasEq) == 2 3793 assert isinstance(hasEq[0], base.MechanismID) 3794 assert isinstance(hasEq[1], base.MechanismState) 3795 mID, mState = hasEq 3796 mDetails = now.graph.mechanismDetails(mID) 3797 assert mDetails is not None 3798 mWhere, mName = mDetails 3799 if mWhere is None: 3800 whereStr = " (global)" 3801 else: 3802 whereStr = f" at {graph.identityOf(mWhere)}" 3803 eqStr = f"{mName}:{mState!r} - {mID}{whereStr}" 3804 else: 3805 assert isinstance(hasEq, base.Capability) 3806 eqStr = hasEq 3807 eqSet = eqDict[hasEq] 3808 print( 3809 f" {eqStr} has {len(eqSet)} equivalence(s):", 3810 file=sys.stderr 3811 ) 3812 for eqReq in eqDict[hasEq]: 3813 print(f" {eqReq}", file=sys.stderr) 3814 else: 3815 print( 3816 "There are no equivalences right now.", 3817 file=sys.stderr 3818 ) 3819 else: 3820 raise JournalParseError( 3821 f"Invalid debug command: {action!r}" 3822 ) 3823 3824 def recordStart( 3825 self, 3826 where: Union[base.DecisionName, base.DecisionSpecifier], 3827 decisionType: base.DecisionType = 'imposed' 3828 ) -> None: 3829 """ 3830 Records the start of the exploration. Use only once in each new 3831 domain, as the very first action in that domain (possibly after 3832 some zone declarations). The contextual domain is used if the 3833 given `base.DecisionSpecifier` doesn't include a domain. 3834 3835 To create new decision points that are disconnected from the rest 3836 of the graph that aren't the first in their domain, use the 3837 `relative` method followed by `recordWarp`. 3838 3839 The default 'imposed' decision type can be overridden for the 3840 action that this generates. 3841 """ 3842 if self.inRelativeMode: 3843 raise JournalParseError( 3844 "Can't start the exploration in relative mode." 3845 ) 3846 3847 whereSpec: Union[base.DecisionID, base.DecisionSpecifier] 3848 if isinstance(where, base.DecisionName): 3849 whereSpec = self.parseFormat.parseDecisionSpecifier(where) 3850 if isinstance(whereSpec, base.DecisionID): 3851 raise JournalParseError( 3852 f"Can't use a number for a decision name. Got:" 3853 f" {where!r}" 3854 ) 3855 else: 3856 whereSpec = where 3857 3858 if whereSpec.domain is None: 3859 whereSpec = base.DecisionSpecifier( 3860 domain=self.context['domain'], 3861 zone=whereSpec.zone, 3862 name=whereSpec.name 3863 ) 3864 self.context['decision'] = self.exploration.start( 3865 whereSpec, 3866 decisionType=decisionType 3867 ) 3868 3869 def recordObserveAction(self, name: base.Transition) -> None: 3870 """ 3871 Records the observation of an action at the current decision, 3872 which has the given name. 3873 """ 3874 here = self.definiteDecisionTarget() 3875 self.exploration.getSituation().graph.addAction(here, name) 3876 self.context['transition'] = (here, name) 3877 3878 def recordObserve( 3879 self, 3880 name: base.Transition, 3881 destination: Optional[base.AnyDecisionSpecifier] = None, 3882 reciprocal: Optional[base.Transition] = None 3883 ) -> None: 3884 """ 3885 Records the observation of a new option at the current decision. 3886 3887 If two or three arguments are given, the destination is still 3888 marked as unexplored, but is given a name (with two arguments) 3889 and the reciprocal transition is named (with three arguments). 3890 3891 When a name or decision specifier is used for the destination, 3892 the domain and/or level-0 zone of the current decision are 3893 filled in if the specifier is a name or doesn't have domain 3894 and/or zone info. The first alphabetical level-0 zone is used if 3895 the current decision is in more than one. 3896 """ 3897 here = self.definiteDecisionTarget() 3898 3899 # Our observation matches `DiscreteExploration.observe` args 3900 obs: Union[ 3901 Tuple[base.Transition], 3902 Tuple[base.Transition, base.AnyDecisionSpecifier], 3903 Tuple[ 3904 base.Transition, 3905 base.AnyDecisionSpecifier, 3906 base.Transition 3907 ] 3908 ] 3909 3910 # If we have a destination, parse it as a decision specifier 3911 # (might be an ID) 3912 if isinstance(destination, str): 3913 destination = self.parseFormat.parseDecisionSpecifier( 3914 destination 3915 ) 3916 3917 # If we started with a name or some other kind of decision 3918 # specifier, replace missing domain and/or zone info with info 3919 # from the current decision. 3920 if isinstance(destination, base.DecisionSpecifier): 3921 destination = base.spliceDecisionSpecifiers( 3922 destination, 3923 self.decisionTargetSpecifier() 3924 ) 3925 # TODO: This is kinda janky because it only uses 1 zone, 3926 # whereas explore puts the new decision in all of them. 3927 3928 # Set up our observation argument 3929 if destination is not None: 3930 if reciprocal is not None: 3931 obs = (name, destination, reciprocal) 3932 else: 3933 obs = (name, destination) 3934 elif reciprocal is not None: 3935 # TODO: Allow this? (make the destination generic) 3936 raise JournalParseError( 3937 "You may not specify a reciprocal name without" 3938 " specifying a destination." 3939 ) 3940 else: 3941 obs = (name,) 3942 3943 self.exploration.observe(here, *obs) 3944 self.context['transition'] = (here, name) 3945 3946 def recordObservationIncomplete( 3947 self, 3948 decision: base.AnyDecisionSpecifier 3949 ): 3950 """ 3951 Marks a particular decision as being incompletely-observed. 3952 Normally whenever we leave a decision, we set its exploration 3953 status as 'explored' under the assumption that before moving on 3954 to another decision, we'll note down all of the options at this 3955 one first. Usually, to indicate further exploration 3956 possibilities in a room, you can include a transition, and you 3957 could even use `recordUnify` later to indicate that what seemed 3958 like a junction between two decisions really wasn't, and they 3959 should be merged. But in rare cases, it makes sense instead to 3960 indicate before you leave a decision that you expect to see more 3961 options there later, but you can't or won't observe them now. 3962 Once `recordObservationIncomplete` has been called, the default 3963 mechanism will never upgrade the decision to 'explored', and you 3964 will usually want to eventually call `recordStatus` to 3965 explicitly do that (which also removes it from the 3966 `dontFinalize` set that this method puts it in). 3967 3968 When called on a decision which already has exploration status 3969 'explored', this also sets the exploration status back to 3970 'exploring'. 3971 """ 3972 e = self.exploration 3973 dID = e.getSituation().graph.resolveDecision(decision) 3974 if e.getExplorationStatus(dID) == 'explored': 3975 e.setExplorationStatus(dID, 'exploring') 3976 self.dontFinalize.add(dID) 3977 3978 def recordStatus( 3979 self, 3980 decision: base.AnyDecisionSpecifier, 3981 status: base.ExplorationStatus = 'explored' 3982 ): 3983 """ 3984 Explicitly records that a particular decision has the specified 3985 exploration status (default 'explored' meaning we think we've 3986 seen everything there). This helps analysts look for unexpected 3987 connections. 3988 3989 Note that normally, exploration statuses will be updated 3990 automatically whenever a decision is first observed (status 3991 'noticed'), first visited (status 'exploring') and first left 3992 behind (status 'explored'). However, using 3993 `recordObservationIncomplete` can prevent the automatic 3994 'explored' update. 3995 3996 This method also removes a decision's `dontFinalize` entry, 3997 although it's probably no longer relevant in any case. 3998 TODO: Still this? 3999 4000 A basic example: 4001 4002 >>> obs = JournalObserver() 4003 >>> e = obs.getExploration() 4004 >>> obs.recordStart('A') 4005 >>> e.getExplorationStatus('A', 0) 4006 'unknown' 4007 >>> e.getExplorationStatus('A', 1) 4008 'exploring' 4009 >>> obs.recordStatus('A') 4010 >>> e.getExplorationStatus('A', 1) 4011 'explored' 4012 >>> obs.recordStatus('A', 'hypothesized') 4013 >>> e.getExplorationStatus('A', 1) 4014 'hypothesized' 4015 4016 An example of usage in journal format: 4017 4018 >>> obs = JournalObserver() 4019 >>> obs.observe(''' 4020 ... # step 0 4021 ... S A # step 1 4022 ... x right B left # step 2 4023 ... ... 4024 ... x right C left # step 3 4025 ... t left # back to B; step 4 4026 ... o up 4027 ... . # now we think we've found all options 4028 ... x up D down # step 5 4029 ... t down # back to B again; step 6 4030 ... x down E up # surprise extra option; step 7 4031 ... w # step 8 4032 ... . hypothesized # explicit value 4033 ... t up # auto-updates to 'explored'; step 9 4034 ... ''') 4035 >>> e = obs.getExploration() 4036 >>> len(e) 4037 10 4038 >>> e.getExplorationStatus('A', 1) 4039 'exploring' 4040 >>> e.getExplorationStatus('A', 2) 4041 'explored' 4042 >>> e.getExplorationStatus('B', 1) 4043 Traceback (most recent call last): 4044 ... 4045 exploration.core.MissingDecisionError... 4046 >>> e.getExplorationStatus(1, 1) # the unknown node is created 4047 'unknown' 4048 >>> e.getExplorationStatus('B', 2) 4049 'exploring' 4050 >>> e.getExplorationStatus('B', 3) # not 'explored' yet 4051 'exploring' 4052 >>> e.getExplorationStatus('B', 4) # now explored 4053 'explored' 4054 >>> e.getExplorationStatus('B', 6) # still explored 4055 'explored' 4056 >>> e.getExplorationStatus('E', 7) # initial 4057 'exploring' 4058 >>> e.getExplorationStatus('E', 8) # explicit 4059 'hypothesized' 4060 >>> e.getExplorationStatus('E', 9) # auto-update on leave 4061 'explored' 4062 >>> g2 = e.getSituation(2).graph 4063 >>> g4 = e.getSituation(4).graph 4064 >>> g7 = e.getSituation(7).graph 4065 >>> g2.destinationsFrom('B') 4066 {'left': 0, 'right': 2} 4067 >>> g4.destinationsFrom('B') 4068 {'left': 0, 'right': 2, 'up': 3} 4069 >>> g7.destinationsFrom('B') 4070 {'left': 0, 'right': 2, 'up': 3, 'down': 4} 4071 """ 4072 e = self.exploration 4073 dID = e.getSituation().graph.resolveDecision(decision) 4074 if dID in self.dontFinalize: 4075 self.dontFinalize.remove(dID) 4076 e.setExplorationStatus(decision, status) 4077 4078 def autoFinalizeExplorationStatuses(self): 4079 """ 4080 Looks at the set of nodes that were active in the previous 4081 exploration step but which are no longer active in this one, and 4082 sets their exploration statuses to 'explored' to indicate that 4083 we believe we've already at least observed all of their outgoing 4084 transitions. 4085 4086 Skips finalization for any decisions in our `dontFinalize` set 4087 (see `recordObservationIncomplete`). 4088 """ 4089 oldActive = self.exploration.getActiveDecisions(-2) 4090 newAcive = self.exploration.getActiveDecisions() 4091 for leftBehind in (oldActive - newAcive) - self.dontFinalize: 4092 self.exploration.setExplorationStatus( 4093 leftBehind, 4094 'explored' 4095 ) 4096 4097 def recordExplore( 4098 self, 4099 transition: base.AnyTransition, 4100 destination: Optional[base.AnyDecisionSpecifier] = None, 4101 reciprocal: Optional[base.Transition] = None, 4102 decisionType: base.DecisionType = 'active' 4103 ) -> None: 4104 """ 4105 Records the exploration of a transition which leads to a 4106 specific destination (possibly with outcomes specified for 4107 challenges that are part of that transition's consequences). The 4108 name of the reciprocal transition may also be specified, as can 4109 a non-default decision type (see `base.DecisionType`). Creates 4110 the transition if it needs to. 4111 4112 Note that if the destination specifier has no zone or domain 4113 information, even if a decision with that name already exists, if 4114 the current decision is in a level-0 zone and the existing 4115 decision is not in the same zone, a new decision with that name 4116 in the current level-0 zone will be created (otherwise, it would 4117 be an error to use 'explore' to connect to an already-visited 4118 decision). 4119 4120 If no destination name is specified, the destination node must 4121 already exist and the name of the destination must not begin 4122 with '_u.' otherwise a `JournalParseError` will be generated. 4123 4124 Sets the current transition to the transition taken. 4125 4126 Calls `autoFinalizeExplorationStatuses` to upgrade exploration 4127 statuses for no-longer-active nodes to 'explored'. 4128 4129 In relative mode, this makes all the same changes to the graph, 4130 without adding a new exploration step, applying transition 4131 effects, or changing exploration statuses. 4132 """ 4133 here = self.definiteDecisionTarget() 4134 4135 transitionName, outcomes = base.nameAndOutcomes(transition) 4136 4137 # Create transition if it doesn't already exist 4138 now = self.exploration.getSituation() 4139 graph = now.graph 4140 leadsTo = graph.getDestination(here, transitionName) 4141 4142 if isinstance(destination, str): 4143 destination = self.parseFormat.parseDecisionSpecifier( 4144 destination 4145 ) 4146 4147 newDomain: Optional[base.Domain] 4148 newZone: Union[ 4149 base.Zone, 4150 type[base.DefaultZone], 4151 None 4152 ] = base.DefaultZone 4153 newName: Optional[base.DecisionName] 4154 4155 # if a destination is specified, we need to check that it's not 4156 # an already-existing decision 4157 connectBack: bool = False # are we connecting to a known decision? 4158 if destination is not None: 4159 # If it's not an ID, splice in current node info: 4160 if isinstance(destination, base.DecisionName): 4161 destination = base.DecisionSpecifier(None, None, destination) 4162 if isinstance(destination, base.DecisionSpecifier): 4163 destination = base.spliceDecisionSpecifiers( 4164 destination, 4165 self.decisionTargetSpecifier() 4166 ) 4167 exists = graph.getDecision(destination) 4168 # if the specified decision doesn't exist; great. We'll 4169 # create it below 4170 if exists is not None: 4171 # If it does exist, we may have a problem. 'return' must 4172 # be used instead of 'explore' to connect to an existing 4173 # visited decision. But let's see if we really have a 4174 # conflict? 4175 otherZones = set( 4176 z 4177 for z in graph.zoneParents(exists) 4178 if graph.zoneHierarchyLevel(z) == 0 4179 ) 4180 currentZones = set( 4181 z 4182 for z in graph.zoneParents(here) 4183 if graph.zoneHierarchyLevel(z) == 0 4184 ) 4185 if ( 4186 len(otherZones & currentZones) != 0 4187 or ( 4188 len(otherZones) == 0 4189 and len(currentZones) == 0 4190 ) 4191 ): 4192 if self.exploration.hasBeenVisited(exists): 4193 # A decision by this name exists and shares at 4194 # least one level-0 zone with the current 4195 # decision. That means that 'return' should have 4196 # been used. 4197 raise JournalParseError( 4198 f"Destiation {destination} is invalid" 4199 f" because that decision has already been" 4200 f" visited in the current zone. Use" 4201 f" 'return' to record a new connection to" 4202 f" an already-visisted decision." 4203 ) 4204 else: 4205 connectBack = True 4206 else: 4207 connectBack = True 4208 # Otherwise, we can continue; the DefaultZone setting 4209 # already in place will prevail below 4210 4211 # Figure out domain & zone info for new destination 4212 if isinstance(destination, base.DecisionSpecifier): 4213 # Use current decision's domain by default 4214 if destination.domain is not None: 4215 newDomain = destination.domain 4216 else: 4217 newDomain = graph.domainFor(here) 4218 4219 # Use specified zone if there is one, else leave it as 4220 # DefaultZone to inherit zone(s) from the current decision. 4221 if destination.zone is not None: 4222 newZone = destination.zone 4223 4224 newName = destination.name 4225 # TODO: Some way to specify non-zone placement in explore? 4226 4227 elif isinstance(destination, base.DecisionID): 4228 if connectBack: 4229 newDomain = graph.domainFor(here) 4230 newZone = None 4231 newName = None 4232 else: 4233 raise JournalParseError( 4234 f"You cannot use a decision ID when specifying a" 4235 f" new name for an exploration destination (got:" 4236 f" {repr(destination)})" 4237 ) 4238 4239 elif isinstance(destination, base.DecisionName): 4240 newDomain = None 4241 newZone = base.DefaultZone 4242 newName = destination 4243 4244 else: # must be None 4245 assert destination is None 4246 newDomain = None 4247 newZone = base.DefaultZone 4248 newName = None 4249 4250 if leadsTo is None: 4251 if newName is None and not connectBack: 4252 raise JournalParseError( 4253 f"Transition {transition!r} at decision" 4254 f" {graph.identityOf(here)} does not already exist," 4255 f" so a destination name must be provided." 4256 ) 4257 else: 4258 graph.addUnexploredEdge( 4259 here, 4260 transitionName, 4261 toDomain=newDomain # None is the default anyways 4262 ) 4263 # Zone info only added in next step 4264 elif newName is None: 4265 # TODO: Generalize this... ? 4266 currentName = graph.nameFor(leadsTo) 4267 if currentName.startswith('_u.'): 4268 raise JournalParseError( 4269 f"Destination {graph.identityOf(leadsTo)} from" 4270 f" decision {graph.identityOf(here)} via transition" 4271 f" {transition!r} must be named when explored," 4272 f" because its current name is a placeholder." 4273 ) 4274 else: 4275 newName = currentName 4276 4277 # TODO: Check for incompatible domain/zone in destination 4278 # specifier? 4279 4280 if self.inRelativeMode: 4281 if connectBack: # connect to existing unconfirmed decision 4282 assert exists is not None 4283 graph.replaceUnconfirmed( 4284 here, 4285 transitionName, 4286 exists, 4287 reciprocal 4288 ) # we assume zones are already in place here 4289 self.exploration.setExplorationStatus( 4290 exists, 4291 'noticed', 4292 upgradeOnly=True 4293 ) 4294 else: # connect to a new decision 4295 graph.replaceUnconfirmed( 4296 here, 4297 transitionName, 4298 newName, 4299 reciprocal, 4300 placeInZone=newZone, 4301 forceNew=True 4302 ) 4303 destID = graph.destination(here, transitionName) 4304 self.exploration.setExplorationStatus( 4305 destID, 4306 'noticed', 4307 upgradeOnly=True 4308 ) 4309 self.context['decision'] = graph.destination( 4310 here, 4311 transitionName 4312 ) 4313 self.context['transition'] = (here, transitionName) 4314 else: 4315 if connectBack: # to a known but unvisited decision 4316 destID = self.exploration.explore( 4317 (transitionName, outcomes), 4318 exists, 4319 reciprocal, 4320 zone=newZone, 4321 decisionType=decisionType 4322 ) 4323 else: # to an entirely new decision 4324 destID = self.exploration.explore( 4325 (transitionName, outcomes), 4326 newName, 4327 reciprocal, 4328 zone=newZone, 4329 decisionType=decisionType 4330 ) 4331 self.context['decision'] = destID 4332 self.context['transition'] = (here, transitionName) 4333 self.autoFinalizeExplorationStatuses() 4334 4335 def recordRetrace( 4336 self, 4337 transition: base.AnyTransition, 4338 decisionType: base.DecisionType = 'active', 4339 isAction: Optional[bool] = None 4340 ) -> None: 4341 """ 4342 Records retracing a transition which leads to a known 4343 destination. A non-default decision type can be specified. If 4344 `isAction` is True or False, the transition must be (or must not 4345 be) an action (i.e., a transition whose destination is the same 4346 as its source). If `isAction` is left as `None` (the default) 4347 then either normal or action transitions can be retraced. 4348 4349 Sets the current transition to the transition taken. 4350 4351 Calls `autoFinalizeExplorationStatuses` unless in relative mode. 4352 4353 In relative mode, simply sets the current transition target to 4354 the transition taken and sets the current decision target to its 4355 destination (it does not apply transition effects). 4356 """ 4357 here = self.definiteDecisionTarget() 4358 4359 transitionName, outcomes = base.nameAndOutcomes(transition) 4360 4361 graph = self.exploration.getSituation().graph 4362 destination = graph.getDestination(here, transitionName) 4363 if destination is None: 4364 valid = graph.destinationsListing(graph.destinationsFrom(here)) 4365 raise JournalParseError( 4366 f"Cannot retrace transition {transitionName!r} from" 4367 f" decision {graph.identityOf(here)}: that transition" 4368 f" does not exist. Destinations available are:" 4369 f"\n{valid}" 4370 ) 4371 if isAction is True and destination != here: 4372 raise JournalParseError( 4373 f"Cannot retrace transition {transitionName!r} from" 4374 f" decision {graph.identityOf(here)}: that transition" 4375 f" leads to {graph.identityOf(destination)} but you" 4376 f" specified that an existing action should be retraced," 4377 f" not a normal transition. Use `recordAction` instead" 4378 f" to record a new action (including converting an" 4379 f" unconfirmed transition into an action). Leave" 4380 f" `isAction` unspeicfied or set it to `False` to" 4381 f" retrace a normal transition." 4382 ) 4383 elif isAction is False and destination == here: 4384 raise JournalParseError( 4385 f"Cannot retrace transition {transitionName!r} from" 4386 f" decision {graph.identityOf(here)}: that transition" 4387 f" leads back to {graph.identityOf(destination)} but you" 4388 f" specified that an outgoing transition should be" 4389 f" retraced, not an action. Use `recordAction` instead" 4390 f" to record a new action (which must not have the same" 4391 f" name as any outgoing transition). Leave `isAction`" 4392 f" unspeicfied or set it to `True` to retrace an action." 4393 ) 4394 4395 if not self.inRelativeMode: 4396 destID = self.exploration.retrace( 4397 (transitionName, outcomes), 4398 decisionType=decisionType 4399 ) 4400 self.autoFinalizeExplorationStatuses() 4401 self.context['decision'] = destID 4402 self.context['transition'] = (here, transitionName) 4403 4404 def recordAction( 4405 self, 4406 action: base.AnyTransition, 4407 decisionType: base.DecisionType = 'active' 4408 ) -> None: 4409 """ 4410 Records a new action taken at the current decision. A 4411 non-standard decision type may be specified. If a transition of 4412 that name already existed, it will be converted into an action 4413 assuming that its destination is unexplored and has no 4414 connections yet, and that its reciprocal also has no special 4415 properties yet. If those assumptions do not hold, a 4416 `JournalParseError` will be raised under the assumption that the 4417 name collision was an accident, not intentional, since the 4418 destination and reciprocal are deleted in the process of 4419 converting a normal transition into an action. 4420 4421 This cannot be used to re-triggger an existing action, use 4422 'retrace' for that. 4423 4424 In relative mode, the action is created (or the transition is 4425 converted into an action) but effects are not applied. 4426 4427 Although this does not usually change which decisions are 4428 active, it still calls `autoFinalizeExplorationStatuses` unless 4429 in relative mode. 4430 4431 Example: 4432 4433 >>> o = JournalObserver() 4434 >>> e = o.getExploration() 4435 >>> o.recordStart('start') 4436 >>> o.recordObserve('transition') 4437 >>> e.effectiveCapabilities()['capabilities'] 4438 set() 4439 >>> o.recordObserveAction('action') 4440 >>> o.recordTransitionConsequence([base.effect(gain="capability")]) 4441 >>> o.recordRetrace('action', isAction=True) 4442 >>> e.effectiveCapabilities()['capabilities'] 4443 {'capability'} 4444 >>> o.recordAction('another') # add effects after... 4445 >>> effect = base.effect(lose="capability") 4446 >>> # This applies the effect and then adds it to the 4447 >>> # transition, since we already took the transition 4448 >>> o.recordAdditionalTransitionConsequence([effect]) 4449 >>> e.effectiveCapabilities()['capabilities'] 4450 set() 4451 >>> len(e) 4452 4 4453 >>> e.getActiveDecisions(0) 4454 set() 4455 >>> e.getActiveDecisions(1) 4456 {0} 4457 >>> e.getActiveDecisions(2) 4458 {0} 4459 >>> e.getActiveDecisions(3) 4460 {0} 4461 >>> e.getSituation(0).action 4462 ('start', 0, 0, 'main', None, None, None) 4463 >>> e.getSituation(1).action 4464 ('take', 'active', 0, ('action', [])) 4465 >>> e.getSituation(2).action 4466 ('take', 'active', 0, ('another', [])) 4467 """ 4468 here = self.definiteDecisionTarget() 4469 4470 actionName, outcomes = base.nameAndOutcomes(action) 4471 4472 # Check if the transition already exists 4473 now = self.exploration.getSituation() 4474 graph = now.graph 4475 hereIdent = graph.identityOf(here) 4476 destinations = graph.destinationsFrom(here) 4477 4478 # A transition going somewhere else 4479 if actionName in destinations: 4480 if destinations[actionName] == here: 4481 raise JournalParseError( 4482 f"Action {actionName!r} already exists as an action" 4483 f" at decision {hereIdent!r}. Use 'retrace' to" 4484 " re-activate an existing action." 4485 ) 4486 else: 4487 destination = destinations[actionName] 4488 reciprocal = graph.getReciprocal(here, actionName) 4489 # To replace a transition with an action, the transition 4490 # may only have outgoing properties. Otherwise we assume 4491 # it's an error to name the action after a transition 4492 # which was intended to be a real transition. 4493 if ( 4494 graph.isConfirmed(destination) 4495 or self.exploration.hasBeenVisited(destination) 4496 or cast(int, graph.degree(destination)) > 2 4497 # TODO: Fix MultiDigraph type stubs... 4498 ): 4499 raise JournalParseError( 4500 f"Action {actionName!r} has the same name as" 4501 f" outgoing transition {actionName!r} at" 4502 f" decision {hereIdent!r}. We cannot turn that" 4503 f" transition into an action since its" 4504 f" destination is already explored or has been" 4505 f" connected to." 4506 ) 4507 if ( 4508 reciprocal is not None 4509 and graph.getTransitionProperties( 4510 destination, 4511 reciprocal 4512 ) != { 4513 'requirement': base.ReqNothing(), 4514 'effects': [], 4515 'tags': {}, 4516 'annotations': [] 4517 } 4518 ): 4519 raise JournalParseError( 4520 f"Action {actionName!r} has the same name as" 4521 f" outgoing transition {actionName!r} at" 4522 f" decision {hereIdent!r}. We cannot turn that" 4523 f" transition into an action since its" 4524 f" reciprocal has custom properties." 4525 ) 4526 4527 if ( 4528 graph.decisionAnnotations(destination) != [] 4529 or graph.decisionTags(destination) != {'unknown': 1} 4530 ): 4531 raise JournalParseError( 4532 f"Action {actionName!r} has the same name as" 4533 f" outgoing transition {actionName!r} at" 4534 f" decision {hereIdent!r}. We cannot turn that" 4535 f" transition into an action since its" 4536 f" destination has tags and/or annotations." 4537 ) 4538 4539 # If we get here, re-target the transition, and then 4540 # destroy the old destination along with the old 4541 # reciprocal edge. 4542 graph.retargetTransition( 4543 here, 4544 actionName, 4545 here, 4546 swapReciprocal=False 4547 ) 4548 graph.removeDecision(destination) 4549 4550 # This will either take the existing action OR create it if 4551 # necessary 4552 if self.inRelativeMode: 4553 if actionName not in destinations: 4554 graph.addAction(here, actionName) 4555 else: 4556 destID = self.exploration.takeAction( 4557 (actionName, outcomes), 4558 fromDecision=here, 4559 decisionType=decisionType 4560 ) 4561 self.autoFinalizeExplorationStatuses() 4562 self.context['decision'] = destID 4563 self.context['transition'] = (here, actionName) 4564 4565 def recordReturn( 4566 self, 4567 transition: base.AnyTransition, 4568 destination: Optional[base.AnyDecisionSpecifier] = None, 4569 reciprocal: Optional[base.Transition] = None, 4570 decisionType: base.DecisionType = 'active' 4571 ) -> None: 4572 """ 4573 Records an exploration which leads back to a 4574 previously-encountered decision. If a reciprocal is specified, 4575 we connect to that transition as our reciprocal (it must have 4576 led to an unknown area or not have existed) or if not, we make a 4577 new connection with an automatic reciprocal name. 4578 A non-standard decision type may be specified. 4579 4580 If no destination is specified, then the destination of the 4581 transition must already exist. 4582 4583 If the specified transition does not exist, it will be created. 4584 4585 Sets the current transition to the transition taken. 4586 4587 Calls `autoFinalizeExplorationStatuses` unless in relative mode. 4588 4589 In relative mode, does the same stuff but doesn't apply any 4590 transition effects. 4591 """ 4592 here = self.definiteDecisionTarget() 4593 now = self.exploration.getSituation() 4594 graph = now.graph 4595 4596 transitionName, outcomes = base.nameAndOutcomes(transition) 4597 4598 if destination is None: 4599 destination = graph.getDestination(here, transitionName) 4600 if destination is None: 4601 raise JournalParseError( 4602 f"Cannot 'return' across transition" 4603 f" {transitionName!r} from decision" 4604 f" {graph.identityOf(here)} without specifying a" 4605 f" destination, because that transition does not" 4606 f" already have a destination." 4607 ) 4608 4609 if isinstance(destination, str): 4610 destination = self.parseFormat.parseDecisionSpecifier( 4611 destination 4612 ) 4613 4614 # If we started with a name or some other kind of decision 4615 # specifier, replace missing domain and/or zone info with info 4616 # from the current decision. 4617 if isinstance(destination, base.DecisionSpecifier): 4618 destination = base.spliceDecisionSpecifiers( 4619 destination, 4620 self.decisionTargetSpecifier() 4621 ) 4622 4623 # Add an unexplored edge just before doing the return if the 4624 # named transition didn't already exist. 4625 if graph.getDestination(here, transitionName) is None: 4626 graph.addUnexploredEdge(here, transitionName) 4627 4628 # Works differently in relative mode 4629 if self.inRelativeMode: 4630 graph.replaceUnconfirmed( 4631 here, 4632 transitionName, 4633 destination, 4634 reciprocal 4635 ) 4636 self.context['decision'] = graph.resolveDecision(destination) 4637 self.context['transition'] = (here, transitionName) 4638 else: 4639 destID = self.exploration.returnTo( 4640 (transitionName, outcomes), 4641 destination, 4642 reciprocal, 4643 decisionType=decisionType 4644 ) 4645 self.autoFinalizeExplorationStatuses() 4646 self.context['decision'] = destID 4647 self.context['transition'] = (here, transitionName) 4648 4649 def recordWarp( 4650 self, 4651 destination: base.AnyDecisionSpecifier, 4652 decisionType: base.DecisionType = 'active' 4653 ) -> None: 4654 """ 4655 Records a warp to a specific destination without creating a 4656 transition. If the destination did not exist, it will be 4657 created (but only if a `base.DecisionName` or 4658 `base.DecisionSpecifier` was supplied; a destination cannot be 4659 created based on a non-existent `base.DecisionID`). 4660 A non-standard decision type may be specified. 4661 4662 If the destination already exists its zones won't be changed. 4663 However, if the destination gets created, it will be in the same 4664 domain and added to the same zones as the previous position, or 4665 to whichever zone was specified as the zone component of a 4666 `base.DecisionSpecifier`, if any. 4667 4668 Sets the current transition to `None`. 4669 4670 In relative mode, simply updates the current target decision and 4671 sets the current target transition to `None`. It will still 4672 create the destination if necessary, possibly putting it in a 4673 zone. In relative mode, the destination's exploration status is 4674 set to "noticed" (and no exploration step is created), while in 4675 normal mode, the exploration status is set to 'unknown' in the 4676 original current step, and then a new step is added which will 4677 set the status to 'exploring'. 4678 4679 Calls `autoFinalizeExplorationStatuses` unless in relative mode. 4680 """ 4681 now = self.exploration.getSituation() 4682 graph = now.graph 4683 4684 if isinstance(destination, str): 4685 destination = self.parseFormat.parseDecisionSpecifier( 4686 destination 4687 ) 4688 4689 destID = graph.getDecision(destination) 4690 4691 newZone: Union[ 4692 base.Zone, 4693 type[base.DefaultZone], 4694 None 4695 ] = base.DefaultZone 4696 here = self.currentDecisionTarget() 4697 newDomain: Optional[base.Domain] = None 4698 if here is not None: 4699 newDomain = graph.domainFor(here) 4700 if self.inRelativeMode: # create the decision if it didn't exist 4701 if destID not in graph: # including if it's None 4702 if isinstance(destination, base.DecisionID): 4703 raise JournalParseError( 4704 f"Cannot go to decision {destination} because that" 4705 f" decision ID does not exist, and we cannot create" 4706 f" a new decision based only on a decision ID. Use" 4707 f" a DecisionSpecifier or DecisionName to go to a" 4708 f" new decision that needs to be created." 4709 ) 4710 elif isinstance(destination, base.DecisionName): 4711 newName = destination 4712 newZone = base.DefaultZone 4713 elif isinstance(destination, base.DecisionSpecifier): 4714 specDomain, newZone, newName = destination 4715 if specDomain is not None: 4716 newDomain = specDomain 4717 else: 4718 raise JournalParseError( 4719 f"Invalid decision specifier: {repr(destination)}." 4720 f" The destination must be a decision ID, a" 4721 f" decision name, or a decision specifier." 4722 ) 4723 destID = graph.addDecision(newName, domain=newDomain) 4724 if newZone is base.DefaultZone: 4725 ctxDecision = self.context['decision'] 4726 if ctxDecision is not None: 4727 for zp in graph.zoneParents(ctxDecision): 4728 graph.addDecisionToZone(destID, zp) 4729 elif newZone is not None: 4730 graph.addDecisionToZone(destID, newZone) 4731 # TODO: If this zone is new create it & add it to 4732 # parent zones of old level-0 zone(s)? 4733 4734 base.setExplorationStatus( 4735 now, 4736 destID, 4737 'noticed', 4738 upgradeOnly=True 4739 ) 4740 # TODO: Some way to specify 'hypothesized' here instead? 4741 4742 else: 4743 # in normal mode, 'DiscreteExploration.warp' takes care of 4744 # creating the decision if needed 4745 whichFocus = None 4746 if self.context['focus'] is not None: 4747 whichFocus = ( 4748 self.context['context'], 4749 self.context['domain'], 4750 self.context['focus'] 4751 ) 4752 if destination is None: 4753 destination = destID 4754 4755 if isinstance(destination, base.DecisionSpecifier): 4756 newZone = destination.zone 4757 if destination.domain is not None: 4758 newDomain = destination.domain 4759 else: 4760 newZone = base.DefaultZone 4761 4762 destID = self.exploration.warp( 4763 destination, 4764 domain=newDomain, 4765 zone=newZone, 4766 whichFocus=whichFocus, 4767 inCommon=self.context['context'] == 'common', 4768 decisionType=decisionType 4769 ) 4770 self.autoFinalizeExplorationStatuses() 4771 4772 self.context['decision'] = destID 4773 self.context['transition'] = None 4774 4775 def recordWait( 4776 self, 4777 decisionType: base.DecisionType = 'active' 4778 ) -> None: 4779 """ 4780 Records a wait step. Does not modify the current transition. 4781 A non-standard decision type may be specified. 4782 4783 Raises a `JournalParseError` in relative mode, since it wouldn't 4784 have any effect. 4785 """ 4786 if self.inRelativeMode: 4787 raise JournalParseError("Can't wait in relative mode.") 4788 else: 4789 self.exploration.wait(decisionType=decisionType) 4790 4791 def recordObserveEnding(self, name: base.DecisionName) -> None: 4792 """ 4793 Records the observation of an action which warps to an ending, 4794 although unlike `recordEnd` we don't use that action yet. This 4795 does NOT update the current decision, although it sets the 4796 current transition to the action it creates. 4797 4798 The action created has the same name as the ending it warps to. 4799 4800 Note that normally, we just warp to endings, so there's no need 4801 to use `recordObserveEnding`. But if there's a player-controlled 4802 option to end the game at a particular node that is noticed 4803 before it's actually taken, this is the right thing to do. 4804 4805 We set up player-initiated ending transitions as actions with a 4806 goto rather than usual transitions because endings exist in a 4807 separate domain, and are active simultaneously with normal 4808 decisions. 4809 """ 4810 graph = self.exploration.getSituation().graph 4811 here = self.definiteDecisionTarget() 4812 # Add the ending decision or grab the ID of the existing ending 4813 eID = graph.endingID(name) 4814 # Create action & add goto consequence 4815 graph.addAction(here, name) 4816 graph.setConsequence(here, name, [base.effect(goto=eID)]) 4817 # Set the exploration status 4818 self.exploration.setExplorationStatus( 4819 eID, 4820 'noticed', 4821 upgradeOnly=True 4822 ) 4823 self.context['transition'] = (here, name) 4824 # TODO: Prevent things like adding unexplored nodes to the 4825 # an ending... 4826 4827 def recordEnd( 4828 self, 4829 name: base.DecisionName, 4830 voluntary: bool = False, 4831 decisionType: Optional[base.DecisionType] = None 4832 ) -> None: 4833 """ 4834 Records an ending. If `voluntary` is `False` (the default) then 4835 this becomes a warp that activates the specified ending (which 4836 is in the `core.ENDINGS_DOMAIN` domain, so that doesn't leave 4837 the current decision). 4838 4839 If `voluntary` is `True` then we also record an action with a 4840 'goto' effect that activates the specified ending, and record an 4841 exploration step that takes that action, instead of just a warp 4842 (`recordObserveEnding` would set up such an action without 4843 taking it). 4844 4845 The specified ending decision is created if it didn't already 4846 exist. If `voluntary` is True and an action that warps to the 4847 specified ending already exists with the correct name, we will 4848 simply take that action. 4849 4850 If it created an action, it sets the current transition to the 4851 action that warps to the ending. Endings are not added to zones; 4852 otherwise it sets the current transition to None. 4853 4854 In relative mode, an ending is still added, possibly with an 4855 action that warps to it, and the current decision is set to that 4856 ending node, but the transition doesn't actually get taken. 4857 4858 If not in relative mode, sets the exploration status of the 4859 current decision to `explored` if it wasn't in the 4860 `dontFinalize` set, even though we do not deactivate that 4861 transition. 4862 4863 When `voluntary` is not set, the decision type for the warp will 4864 be 'imposed', otherwise it will be 'active'. However, if an 4865 explicit `decisionType` is specified, that will override these 4866 defaults. 4867 """ 4868 graph = self.exploration.getSituation().graph 4869 here = self.definiteDecisionTarget() 4870 4871 # Add our warping action if we need to 4872 if voluntary: 4873 # If voluntary, check for an existing warp action and set 4874 # one up if we don't have one. 4875 aDest = graph.getDestination(here, name) 4876 eID = graph.getDecision( 4877 base.DecisionSpecifier(core.ENDINGS_DOMAIN, None, name) 4878 ) 4879 if aDest is None: 4880 # Okay we can just create the action 4881 self.recordObserveEnding(name) 4882 # else check if the existing transition is an action 4883 # that warps to the correct ending already 4884 elif ( 4885 aDest != here 4886 or eID is None 4887 or not any( 4888 c == base.effect(goto=eID) 4889 for c in graph.getConsequence(here, name) 4890 ) 4891 ): 4892 raise JournalParseError( 4893 f"Attempting to add voluntary ending {name!r} at" 4894 f" decision {graph.identityOf(here)} but that" 4895 f" decision already has an action with that name" 4896 f" and it's not set up to warp to that ending" 4897 f" already." 4898 ) 4899 4900 # Grab ending ID (creates the decision if necessary) 4901 eID = graph.endingID(name) 4902 4903 # Update our context variables 4904 self.context['decision'] = eID 4905 if voluntary: 4906 self.context['transition'] = (here, name) 4907 else: 4908 self.context['transition'] = None 4909 4910 # Update exploration status in relative mode, or possibly take 4911 # action in normal mode 4912 if self.inRelativeMode: 4913 self.exploration.setExplorationStatus( 4914 eID, 4915 "noticed", 4916 upgradeOnly=True 4917 ) 4918 else: 4919 # Either take the action we added above, or just warp 4920 if decisionType is None: 4921 decisionType = 'active' if voluntary else 'imposed' 4922 4923 if voluntary: 4924 # Taking the action warps us to the ending 4925 self.exploration.takeAction( 4926 name, 4927 decisionType=decisionType 4928 ) 4929 else: 4930 # We'll use a warp to get there 4931 self.exploration.warp( 4932 base.DecisionSpecifier(core.ENDINGS_DOMAIN, None, name), 4933 zone=None, 4934 decisionType=decisionType 4935 ) 4936 if ( 4937 here not in self.dontFinalize 4938 and ( 4939 self.exploration.getExplorationStatus(here) 4940 == "exploring" 4941 ) 4942 ): 4943 self.exploration.setExplorationStatus(here, "explored") 4944 # TODO: Prevent things like adding unexplored nodes to the 4945 # ending... 4946 4947 def recordMechanism( 4948 self, 4949 where: Optional[base.AnyDecisionSpecifier], 4950 name: base.MechanismName, 4951 startingState: base.MechanismState = base.DEFAULT_MECHANISM_STATE 4952 ) -> None: 4953 """ 4954 Records the existence of a mechanism at the specified decision 4955 with the specified starting state (or the default starting 4956 state). Set `where` to `None` to set up a global mechanism that's 4957 not tied to any particular decision. 4958 """ 4959 graph = self.exploration.getSituation().graph 4960 # TODO: a way to set up global mechanisms 4961 newID = graph.addMechanism(name, where) 4962 if startingState != base.DEFAULT_MECHANISM_STATE: 4963 self.exploration.setMechanismStateNow(newID, startingState) 4964 4965 def recordRequirement(self, req: Union[base.Requirement, str]) -> None: 4966 """ 4967 Records a requirement observed on the most recently 4968 defined/taken transition. If a string is given, 4969 `ParseFormat.parseRequirement` will be used to parse it. 4970 """ 4971 if isinstance(req, str): 4972 req = self.parseFormat.parseRequirement(req) 4973 target = self.currentTransitionTarget() 4974 if target is None: 4975 raise JournalParseError( 4976 "Can't set a requirement because there is no current" 4977 " transition." 4978 ) 4979 graph = self.exploration.getSituation().graph 4980 graph.setTransitionRequirement( 4981 *target, 4982 req 4983 ) 4984 4985 def recordReciprocalRequirement( 4986 self, 4987 req: Union[base.Requirement, str] 4988 ) -> None: 4989 """ 4990 Records a requirement observed on the reciprocal of the most 4991 recently defined/taken transition. If a string is given, 4992 `ParseFormat.parseRequirement` will be used to parse it. 4993 """ 4994 if isinstance(req, str): 4995 req = self.parseFormat.parseRequirement(req) 4996 target = self.currentReciprocalTarget() 4997 if target is None: 4998 raise JournalParseError( 4999 "Can't set a reciprocal requirement because there is no" 5000 " current transition or it doesn't have a reciprocal." 5001 ) 5002 graph = self.exploration.getSituation().graph 5003 graph.setTransitionRequirement(*target, req) 5004 5005 def recordTransitionConsequence( 5006 self, 5007 consequence: base.Consequence 5008 ) -> None: 5009 """ 5010 Records a transition consequence, which gets added to any 5011 existing consequences of the currently-relevant transition (the 5012 most-recently created or taken transition). A `JournalParseError` 5013 will be raised if there is no current transition. 5014 """ 5015 target = self.currentTransitionTarget() 5016 if target is None: 5017 raise JournalParseError( 5018 "Cannot apply a consequence because there is no current" 5019 " transition." 5020 ) 5021 5022 now = self.exploration.getSituation() 5023 now.graph.addConsequence(*target, consequence) 5024 5025 def recordReciprocalConsequence( 5026 self, 5027 consequence: base.Consequence 5028 ) -> None: 5029 """ 5030 Like `recordTransitionConsequence` but applies the effect to the 5031 reciprocal of the current transition. Will cause a 5032 `JournalParseError` if the current transition has no reciprocal 5033 (e.g., it's an ending transition). 5034 """ 5035 target = self.currentReciprocalTarget() 5036 if target is None: 5037 raise JournalParseError( 5038 "Cannot apply a reciprocal effect because there is no" 5039 " current transition, or it doesn't have a reciprocal." 5040 ) 5041 5042 now = self.exploration.getSituation() 5043 now.graph.addConsequence(*target, consequence) 5044 5045 def recordAdditionalTransitionConsequence( 5046 self, 5047 consequence: base.Consequence, 5048 hideEffects: bool = True 5049 ) -> None: 5050 """ 5051 Records the addition of a new consequence to the current 5052 relevant transition, while also triggering the effects of that 5053 consequence (but not the other effects of that transition, which 5054 we presume have just been applied already). 5055 5056 By default each effect added this way automatically gets the 5057 "hidden" property added to it, because the assumption is if it 5058 were a foreseeable effect, you would have added it to the 5059 transition before taking it. If you set `hideEffects` to 5060 `False`, this won't be done. 5061 5062 This modifies the current state but does not add a step to the 5063 exploration. It does NOT call `autoFinalizeExplorationStatuses`, 5064 which means that if a 'bounce' or 'goto' effect ends up making 5065 one or more decisions no-longer-active, they do NOT get their 5066 exploration statuses upgraded to 'explored'. 5067 """ 5068 # Receive begin/end indices from `addConsequence` and send them 5069 # to `applyTransitionConsequence` to limit which # parts of the 5070 # expanded consequence are actually applied. 5071 currentTransition = self.currentTransitionTarget() 5072 if currentTransition is None: 5073 consRepr = self.parseFormat.unparseConsequence(consequence) 5074 raise JournalParseError( 5075 f"Can't apply an additional consequence to a transition" 5076 f" when there is no current transition. Got" 5077 f" consequence:\n{consRepr}" 5078 ) 5079 5080 if hideEffects: 5081 for (index, item) in base.walkParts(consequence): 5082 if isinstance(item, dict) and 'value' in item: 5083 assert 'hidden' in item 5084 item = cast(base.Effect, item) 5085 item['hidden'] = True 5086 5087 now = self.exploration.getSituation() 5088 begin, end = now.graph.addConsequence( 5089 *currentTransition, 5090 consequence 5091 ) 5092 self.exploration.applyTransitionConsequence( 5093 *currentTransition, 5094 moveWhich=self.context['focus'], 5095 policy="specified", 5096 fromIndex=begin, 5097 toIndex=end 5098 ) 5099 # This tracks trigger counts and obeys 5100 # charges/delays, unlike 5101 # applyExtraneousConsequence, but some effects 5102 # like 'bounce' still can't be properly applied 5103 5104 def recordTagStep( 5105 self, 5106 tag: base.Tag, 5107 value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue 5108 ) -> None: 5109 """ 5110 Records a tag to be applied to the current exploration step. 5111 """ 5112 self.exploration.tagStep(tag, value) 5113 5114 def recordTagDecision( 5115 self, 5116 tag: base.Tag, 5117 value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue 5118 ) -> None: 5119 """ 5120 Records a tag to be applied to the current decision. 5121 """ 5122 now = self.exploration.getSituation() 5123 now.graph.tagDecision( 5124 self.definiteDecisionTarget(), 5125 tag, 5126 value 5127 ) 5128 5129 def recordTagTranstion( 5130 self, 5131 tag: base.Tag, 5132 value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue 5133 ) -> None: 5134 """ 5135 Records a tag to be applied to the most-recently-defined or 5136 -taken transition. 5137 """ 5138 target = self.currentTransitionTarget() 5139 if target is None: 5140 raise JournalParseError( 5141 "Cannot tag a transition because there is no current" 5142 " transition." 5143 ) 5144 5145 now = self.exploration.getSituation() 5146 now.graph.tagTransition(*target, tag, value) 5147 5148 def recordTagReciprocal( 5149 self, 5150 tag: base.Tag, 5151 value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue 5152 ) -> None: 5153 """ 5154 Records a tag to be applied to the reciprocal of the 5155 most-recently-defined or -taken transition. 5156 """ 5157 target = self.currentReciprocalTarget() 5158 if target is None: 5159 raise JournalParseError( 5160 "Cannot tag a transition because there is no current" 5161 " transition." 5162 ) 5163 5164 now = self.exploration.getSituation() 5165 now.graph.tagTransition(*target, tag, value) 5166 5167 def currentZoneAtLevel(self, level: int) -> base.Zone: 5168 """ 5169 Returns a zone in the current graph that applies to the current 5170 decision which is at the specified hierarchy level. If there is 5171 no such zone, raises a `JournalParseError`. If there are 5172 multiple such zones, returns the zone which includes the fewest 5173 decisions, breaking ties alphabetically by zone name. 5174 """ 5175 here = self.definiteDecisionTarget() 5176 graph = self.exploration.getSituation().graph 5177 ancestors = graph.zoneAncestors(here) 5178 candidates = [ 5179 ancestor 5180 for ancestor in ancestors 5181 if graph.zoneHierarchyLevel(ancestor) == level 5182 ] 5183 if len(candidates) == 0: 5184 raise JournalParseError( 5185 ( 5186 f"Cannot find any level-{level} zones for the" 5187 f" current decision {graph.identityOf(here)}. That" 5188 f" decision is" 5189 ) + ( 5190 " in the following zones:" 5191 + '\n'.join( 5192 f" level {graph.zoneHierarchyLevel(z)}: {z!r}" 5193 for z in ancestors 5194 ) 5195 ) if len(ancestors) > 0 else ( 5196 " not in any zones." 5197 ) 5198 ) 5199 candidates.sort( 5200 key=lambda zone: (len(graph.allDecisionsInZone(zone)), zone) 5201 ) 5202 return candidates[0] 5203 5204 def recordTagZone( 5205 self, 5206 level: int, 5207 tag: base.Tag, 5208 value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue 5209 ) -> None: 5210 """ 5211 Records a tag to be applied to one of the zones that the current 5212 decision is in, at a specific hierarchy level. There must be at 5213 least one zone ancestor of the current decision at that hierarchy 5214 level; if there are multiple then the tag is applied to the 5215 smallest one, breaking ties by alphabetical order. 5216 """ 5217 applyTo = self.currentZoneAtLevel(level) 5218 self.exploration.getSituation().graph.tagZone(applyTo, tag, value) 5219 5220 def recordAnnotateStep( 5221 self, 5222 *annotations: base.Annotation 5223 ) -> None: 5224 """ 5225 Records annotations to be applied to the current exploration 5226 step. 5227 """ 5228 self.exploration.annotateStep(annotations) 5229 pf = self.parseFormat 5230 now = self.exploration.getSituation() 5231 for a in annotations: 5232 if a.startswith("at:"): 5233 expects = pf.parseDecisionSpecifier(a[3:]) 5234 if isinstance(expects, base.DecisionSpecifier): 5235 if expects.domain is None and expects.zone is None: 5236 expects = base.spliceDecisionSpecifiers( 5237 expects, 5238 self.decisionTargetSpecifier() 5239 ) 5240 eID = now.graph.getDecision(expects) 5241 primaryNow: Optional[base.DecisionID] 5242 if self.inRelativeMode: 5243 primaryNow = self.definiteDecisionTarget() 5244 else: 5245 primaryNow = now.state['primaryDecision'] 5246 if eID is None: 5247 self.warn( 5248 f"'at' annotation expects position {expects!r}" 5249 f" but that's not a valid decision specifier in" 5250 f" the current graph." 5251 ) 5252 elif eID != primaryNow: 5253 self.warn( 5254 f"'at' annotation expects position {expects!r}" 5255 f" which is decision" 5256 f" {now.graph.identityOf(eID)}, but the current" 5257 f" primary decision is" 5258 f" {now.graph.identityOf(primaryNow)}" 5259 ) 5260 elif a.startswith("active:"): 5261 expects = pf.parseDecisionSpecifier(a[3:]) 5262 eID = now.graph.getDecision(expects) 5263 atNow = base.combinedDecisionSet(now.state) 5264 if eID is None: 5265 self.warn( 5266 f"'active' annotation expects decision {expects!r}" 5267 f" but that's not a valid decision specifier in" 5268 f" the current graph." 5269 ) 5270 elif eID not in atNow: 5271 self.warn( 5272 f"'active' annotation expects decision {expects!r}" 5273 f" which is {now.graph.identityOf(eID)}, but" 5274 f" the current active position(s) is/are:" 5275 f"\n{now.graph.namesListing(atNow)}" 5276 ) 5277 elif a.startswith("has:"): 5278 ea = pf.parseOneEffectArg(pf.lex(a[4:]))[0] 5279 if ( 5280 isinstance(ea, tuple) 5281 and len(ea) == 2 5282 and isinstance(ea[0], base.Token) 5283 and isinstance(ea[1], base.TokenCount) 5284 ): 5285 countNow = base.combinedTokenCount(now.state, ea[0]) 5286 if countNow != ea[1]: 5287 self.warn( 5288 f"'has' annotation expects {ea[1]} {ea[0]!r}" 5289 f" token(s) but the current state has" 5290 f" {countNow} of them." 5291 ) 5292 else: 5293 self.warn( 5294 f"'has' annotation expects tokens {a[4:]!r} but" 5295 f" that's not a (token, count) pair." 5296 ) 5297 elif a.startswith("level:"): 5298 ea = pf.parseOneEffectArg(pf.lex(a[6:]))[0] 5299 if ( 5300 isinstance(ea, tuple) 5301 and len(ea) == 3 5302 and ea[0] == 'skill' 5303 and isinstance(ea[1], base.Skill) 5304 and isinstance(ea[2], base.Level) 5305 ): 5306 levelNow = base.getSkillLevel(now.state, ea[1]) 5307 if levelNow != ea[2]: 5308 self.warn( 5309 f"'level' annotation expects skill {ea[1]!r}" 5310 f" to be at level {ea[2]} but the current" 5311 f" level for that skill is {levelNow}." 5312 ) 5313 else: 5314 self.warn( 5315 f"'level' annotation expects skill {a[6:]!r} but" 5316 f" that's not a (skill, level) pair." 5317 ) 5318 elif a.startswith("can:"): 5319 try: 5320 req = pf.parseRequirement(a[4:]) 5321 except parsing.ParseError: 5322 self.warn( 5323 f"'can' annotation expects requirement {a[4:]!r}" 5324 f" but that's not parsable as a requirement." 5325 ) 5326 req = None 5327 if req is not None: 5328 ctx = base.genericContextForSituation(now) 5329 if not req.satisfied(ctx): 5330 self.warn( 5331 f"'can' annotation expects requirement" 5332 f" {req!r} to be satisfied but it's not in" 5333 f" the current situation." 5334 ) 5335 elif a.startswith("state:"): 5336 ctx = base.genericContextForSituation( 5337 now 5338 ) 5339 ea = pf.parseOneEffectArg(pf.lex(a[6:]))[0] 5340 if ( 5341 isinstance(ea, tuple) 5342 and len(ea) == 2 5343 and isinstance(ea[0], tuple) 5344 and len(ea[0]) == 4 5345 and (ea[0][0] is None or isinstance(ea[0][0], base.Domain)) 5346 and (ea[0][1] is None or isinstance(ea[0][1], base.Zone)) 5347 and ( 5348 ea[0][2] is None 5349 or isinstance(ea[0][2], base.DecisionName) 5350 ) 5351 and isinstance(ea[0][3], base.MechanismName) 5352 and isinstance(ea[1], base.MechanismState) 5353 ): 5354 mID = now.graph.resolveMechanism(ea[0], ctx.searchFrom) 5355 stateNow = base.stateOfMechanism(ctx, mID) 5356 if not base.mechanismInStateOrEquivalent( 5357 mID, 5358 ea[1], 5359 ctx 5360 ): 5361 self.warn( 5362 f"'state' annotation expects mechanism {mID}" 5363 f" {ea[0]!r} to be in state {ea[1]!r} but" 5364 f" its current state is {stateNow!r} and no" 5365 f" equivalence makes it count as being in" 5366 f" state {ea[1]!r}." 5367 ) 5368 else: 5369 self.warn( 5370 f"'state' annotation expects mechanism state" 5371 f" {a[6:]!r} but that's not a mechanism/state" 5372 f" pair." 5373 ) 5374 elif a.startswith("exists:"): 5375 expects = pf.parseDecisionSpecifier(a[7:]) 5376 try: 5377 now.graph.resolveDecision(expects) 5378 except core.MissingDecisionError: 5379 self.warn( 5380 f"'exists' annotation expects decision" 5381 f" {a[7:]!r} but that decision does not exist." 5382 ) 5383 5384 def recordAnnotateDecision( 5385 self, 5386 *annotations: base.Annotation 5387 ) -> None: 5388 """ 5389 Records annotations to be applied to the current decision. 5390 """ 5391 now = self.exploration.getSituation() 5392 now.graph.annotateDecision(self.definiteDecisionTarget(), annotations) 5393 5394 def recordAnnotateTranstion( 5395 self, 5396 *annotations: base.Annotation 5397 ) -> None: 5398 """ 5399 Records annotations to be applied to the most-recently-defined 5400 or -taken transition. 5401 """ 5402 target = self.currentTransitionTarget() 5403 if target is None: 5404 raise JournalParseError( 5405 "Cannot annotate a transition because there is no" 5406 " current transition." 5407 ) 5408 5409 now = self.exploration.getSituation() 5410 now.graph.annotateTransition(*target, annotations) 5411 5412 def recordAnnotateReciprocal( 5413 self, 5414 *annotations: base.Annotation 5415 ) -> None: 5416 """ 5417 Records annotations to be applied to the reciprocal of the 5418 most-recently-defined or -taken transition. 5419 """ 5420 target = self.currentReciprocalTarget() 5421 if target is None: 5422 raise JournalParseError( 5423 "Cannot annotate a reciprocal because there is no" 5424 " current transition or because it doens't have a" 5425 " reciprocal." 5426 ) 5427 5428 now = self.exploration.getSituation() 5429 now.graph.annotateTransition(*target, annotations) 5430 5431 def recordAnnotateZone( 5432 self, 5433 level, 5434 *annotations: base.Annotation 5435 ) -> None: 5436 """ 5437 Records annotations to be applied to the zone at the specified 5438 hierarchy level which contains the current decision. If there are 5439 multiple such zones, it picks the smallest one, breaking ties 5440 alphabetically by zone name (see `currentZoneAtLevel`). 5441 """ 5442 applyTo = self.currentZoneAtLevel(level) 5443 self.exploration.getSituation().graph.annotateZone( 5444 applyTo, 5445 annotations 5446 ) 5447 5448 def recordContextSwap( 5449 self, 5450 targetContext: Optional[base.FocalContextName] 5451 ) -> None: 5452 """ 5453 Records a swap of the active focal context, and/or a swap into 5454 "common"-context mode where all effects modify the common focal 5455 context instead of the active one. Use `None` as the argument to 5456 swap to common mode; use another specific value so swap to 5457 normal mode and set that context as the active one. 5458 5459 In relative mode, swaps the active context without adding an 5460 exploration step. Swapping into the common context never results 5461 in a new exploration step. 5462 """ 5463 if targetContext is None: 5464 self.context['context'] = "common" 5465 else: 5466 self.context['context'] = "active" 5467 e = self.getExploration() 5468 if self.inRelativeMode: 5469 e.setActiveContext(targetContext) 5470 else: 5471 e.advanceSituation(('swap', targetContext)) 5472 5473 def recordZone(self, level: int, zone: base.Zone) -> None: 5474 """ 5475 Records a new current zone to be swapped with the zone(s) at the 5476 specified hierarchy level for the current decision target. See 5477 `core.DiscreteExploration.reZone` and 5478 `core.DecisionGraph.replaceZonesInHierarchy` for details on what 5479 exactly happens; the summary is that the zones at the specified 5480 hierarchy level are replaced with the provided zone (which is 5481 created if necessary) and their children are re-parented onto 5482 the provided zone, while that zone is also set as a child of 5483 their parents. 5484 5485 Does the same thing in relative mode as in normal mode. 5486 """ 5487 self.exploration.reZone( 5488 zone, 5489 self.definiteDecisionTarget(), 5490 level 5491 ) 5492 5493 def recordUnify( 5494 self, 5495 merge: base.AnyDecisionSpecifier, 5496 mergeInto: Optional[base.AnyDecisionSpecifier] = None 5497 ) -> None: 5498 """ 5499 Records a unification between two decisions. This marks an 5500 observation that they are actually the same decision and it 5501 merges them. If only one decision is given the current decision 5502 is merged into that one. After the merge, the first decision (or 5503 the current decision if only one was given) will no longer 5504 exist. 5505 5506 If one of the merged decisions was the current position in a 5507 singular-focalized domain, or one of the current positions in a 5508 plural- or spreading-focalized domain, the merged decision will 5509 replace it as a current decision after the merge, and this 5510 happens even when in relative mode. The target decision is also 5511 updated if it needs to be. 5512 5513 A `TransitionCollisionError` will be raised if the two decisions 5514 have outgoing transitions that share a name. 5515 5516 Logs a `JournalParseWarning` if the two decisions were in 5517 different zones. 5518 5519 Any transitions between the two merged decisions will remain in 5520 place as actions. 5521 5522 TODO: Option for removing self-edges after the merge? Option for 5523 doing that for just effect-less edges? 5524 """ 5525 if mergeInto is None: 5526 mergeInto = merge 5527 merge = self.definiteDecisionTarget() 5528 5529 if isinstance(merge, str): 5530 merge = self.parseFormat.parseDecisionSpecifier(merge) 5531 5532 if isinstance(mergeInto, str): 5533 mergeInto = self.parseFormat.parseDecisionSpecifier(mergeInto) 5534 5535 now = self.exploration.getSituation() 5536 5537 if not isinstance(merge, base.DecisionID): 5538 merge = now.graph.resolveDecision(merge) 5539 5540 merge = cast(base.DecisionID, merge) 5541 5542 now.graph.mergeDecisions(merge, mergeInto) 5543 5544 mergedID = now.graph.resolveDecision(mergeInto) 5545 5546 # Update FocalContexts & ObservationContexts as necessary 5547 self.cleanupContexts(remapped={merge: mergedID}) 5548 5549 def recordUnifyTransition(self, target: base.Transition) -> None: 5550 """ 5551 Records a unification between the most-recently-defined or 5552 -taken transition and the specified transition (which must be 5553 outgoing from the same decision). This marks an observation that 5554 two transitions are actually the same transition and it merges 5555 them. 5556 5557 After the merge, the target transition will still exist but the 5558 previously most-recent transition will have been deleted. 5559 5560 Their reciprocals will also be merged. 5561 5562 A `JournalParseError` is raised if there is no most-recent 5563 transition. 5564 """ 5565 now = self.exploration.getSituation() 5566 graph = now.graph 5567 affected = self.currentTransitionTarget() 5568 if affected is None or affected[1] is None: 5569 raise JournalParseError( 5570 "Cannot unify transitions: there is no current" 5571 " transition." 5572 ) 5573 5574 decision, transition = affected 5575 5576 # If they don't share a target, then the current transition must 5577 # lead to an unknown node, which we will dispose of 5578 destination = graph.getDestination(decision, transition) 5579 if destination is None: 5580 raise JournalParseError( 5581 f"Cannot unify transitions: transition" 5582 f" {transition!r} at decision" 5583 f" {graph.identityOf(decision)} has no destination." 5584 ) 5585 5586 finalDestination = graph.getDestination(decision, target) 5587 if finalDestination is None: 5588 raise JournalParseError( 5589 f"Cannot unify transitions: transition" 5590 f" {target!r} at decision {graph.identityOf(decision)}" 5591 f" has no destination." 5592 ) 5593 5594 if destination != finalDestination: 5595 if graph.isConfirmed(destination): 5596 raise JournalParseError( 5597 f"Cannot unify transitions: destination" 5598 f" {graph.identityOf(destination)} of transition" 5599 f" {transition!r} at decision" 5600 f" {graph.identityOf(decision)} is not an" 5601 f" unconfirmed decision." 5602 ) 5603 # Retarget and delete the unknown node that we abandon 5604 # TODO: Merge nodes instead? 5605 now.graph.retargetTransition( 5606 decision, 5607 transition, 5608 finalDestination 5609 ) 5610 now.graph.removeDecision(destination) 5611 5612 # Now we can merge transitions 5613 now.graph.mergeTransitions(decision, transition, target) 5614 5615 # Update targets if they were merged 5616 self.cleanupContexts( 5617 remappedTransitions={ 5618 (decision, transition): (decision, target) 5619 } 5620 ) 5621 5622 def recordUnifyReciprocal( 5623 self, 5624 target: base.Transition 5625 ) -> None: 5626 """ 5627 Records a unification between the reciprocal of the 5628 most-recently-defined or -taken transition and the specified 5629 transition, which must be outgoing from the current transition's 5630 destination. This marks an observation that two transitions are 5631 actually the same transition and it merges them, deleting the 5632 original reciprocal. Note that the current transition will also 5633 be merged with the reciprocal of the target. 5634 5635 A `JournalParseError` is raised if there is no current 5636 transition, or if it does not have a reciprocal. 5637 """ 5638 now = self.exploration.getSituation() 5639 graph = now.graph 5640 affected = self.currentReciprocalTarget() 5641 if affected is None or affected[1] is None: 5642 raise JournalParseError( 5643 "Cannot unify transitions: there is no current" 5644 " transition." 5645 ) 5646 5647 decision, transition = affected 5648 5649 destination = graph.destination(decision, transition) 5650 reciprocal = graph.getReciprocal(decision, transition) 5651 if reciprocal is None: 5652 raise JournalParseError( 5653 "Cannot unify reciprocal: there is no reciprocal of the" 5654 " current transition." 5655 ) 5656 5657 # If they don't share a target, then the current transition must 5658 # lead to an unknown node, which we will dispose of 5659 finalDestination = graph.getDestination(destination, target) 5660 if finalDestination is None: 5661 raise JournalParseError( 5662 f"Cannot unify reciprocal: transition" 5663 f" {target!r} at decision" 5664 f" {graph.identityOf(destination)} has no destination." 5665 ) 5666 5667 if decision != finalDestination: 5668 if graph.isConfirmed(decision): 5669 raise JournalParseError( 5670 f"Cannot unify reciprocal: destination" 5671 f" {graph.identityOf(decision)} of transition" 5672 f" {reciprocal!r} at decision" 5673 f" {graph.identityOf(destination)} is not an" 5674 f" unconfirmed decision." 5675 ) 5676 # Retarget and delete the unknown node that we abandon 5677 # TODO: Merge nodes instead? 5678 graph.retargetTransition( 5679 destination, 5680 reciprocal, 5681 finalDestination 5682 ) 5683 graph.removeDecision(decision) 5684 5685 # Actually merge the transitions 5686 graph.mergeTransitions(destination, reciprocal, target) 5687 5688 # Update targets if they were merged 5689 self.cleanupContexts( 5690 remappedTransitions={ 5691 (decision, transition): (decision, target) 5692 } 5693 ) 5694 5695 def recordObviate( 5696 self, 5697 transition: base.Transition, 5698 otherDecision: base.AnyDecisionSpecifier, 5699 otherTransition: base.Transition 5700 ) -> None: 5701 """ 5702 Records the obviation of a transition at another decision. This 5703 is the observation that a specific transition at the current 5704 decision is the reciprocal of a different transition at another 5705 decision which previously led to an unknown area. The difference 5706 between this and `recordReturn` is that `recordReturn` logs 5707 movement across the newly-connected transition, while this 5708 leaves the player at their original decision (and does not even 5709 add a step to the current exploration). 5710 5711 Both transitions will be created if they didn't already exist. 5712 5713 In relative mode does the same thing and doesn't move the current 5714 decision across the transition updated. 5715 5716 If the destination is unknown, it will remain unknown after this 5717 operation. 5718 """ 5719 now = self.exploration.getSituation() 5720 graph = now.graph 5721 here = self.definiteDecisionTarget() 5722 5723 if isinstance(otherDecision, str): 5724 otherDecision = self.parseFormat.parseDecisionSpecifier( 5725 otherDecision 5726 ) 5727 5728 # If we started with a name or some other kind of decision 5729 # specifier, replace missing domain and/or zone info with info 5730 # from the current decision. 5731 if isinstance(otherDecision, base.DecisionSpecifier): 5732 otherDecision = base.spliceDecisionSpecifiers( 5733 otherDecision, 5734 self.decisionTargetSpecifier() 5735 ) 5736 5737 otherDestination = graph.getDestination( 5738 otherDecision, 5739 otherTransition 5740 ) 5741 if otherDestination is not None: 5742 if graph.isConfirmed(otherDestination): 5743 raise JournalParseError( 5744 f"Cannot obviate transition {otherTransition!r} at" 5745 f" decision {graph.identityOf(otherDecision)}: that" 5746 f" transition leads to decision" 5747 f" {graph.identityOf(otherDestination)} which has" 5748 f" already been visited." 5749 ) 5750 else: 5751 # We must create the other destination 5752 graph.addUnexploredEdge(otherDecision, otherTransition) 5753 5754 destination = graph.getDestination(here, transition) 5755 if destination is not None: 5756 if graph.isConfirmed(destination): 5757 raise JournalParseError( 5758 f"Cannot obviate using transition {transition!r} at" 5759 f" decision {graph.identityOf(here)}: that" 5760 f" transition leads to decision" 5761 f" {graph.identityOf(destination)} which is not an" 5762 f" unconfirmed decision." 5763 ) 5764 else: 5765 # we need to create it 5766 graph.addUnexploredEdge(here, transition) 5767 5768 # Track exploration status of destination (because 5769 # `replaceUnconfirmed` will overwrite it but we want to preserve 5770 # it in this case. 5771 if otherDecision is not None: 5772 prevStatus = base.explorationStatusOf(now, otherDecision) 5773 5774 # Now connect the transitions and clean up the unknown nodes 5775 graph.replaceUnconfirmed( 5776 here, 5777 transition, 5778 otherDecision, 5779 otherTransition 5780 ) 5781 # Restore exploration status 5782 base.setExplorationStatus(now, otherDecision, prevStatus) 5783 5784 # Update context 5785 self.context['transition'] = (here, transition) 5786 5787 def cleanupContexts( 5788 self, 5789 remapped: Optional[Dict[base.DecisionID, base.DecisionID]] = None, 5790 remappedTransitions: Optional[ 5791 Dict[ 5792 Tuple[base.DecisionID, base.Transition], 5793 Tuple[base.DecisionID, base.Transition] 5794 ] 5795 ] = None 5796 ) -> None: 5797 """ 5798 Checks the validity of context decision and transition entries, 5799 and sets them to `None` in situations where they are no longer 5800 valid, affecting both the current and stored contexts. 5801 5802 Also updates position information in focal contexts in the 5803 current exploration step. 5804 5805 If a `remapped` dictionary is provided, decisions in the keys of 5806 that dictionary will be replaced with the corresponding value 5807 before being checked. 5808 5809 Similarly a `remappedTransitions` dicitonary may provide info on 5810 renamed transitions using (`base.DecisionID`, `base.Transition`) 5811 pairs as both keys and values. 5812 """ 5813 if remapped is None: 5814 remapped = {} 5815 5816 if remappedTransitions is None: 5817 remappedTransitions = {} 5818 5819 # Fix broken position information in the current focal contexts 5820 now = self.exploration.getSituation() 5821 graph = now.graph 5822 state = now.state 5823 for ctx in ( 5824 state['common'], 5825 state['contexts'][state['activeContext']] 5826 ): 5827 active = ctx['activeDecisions'] 5828 for domain in active: 5829 aVal = active[domain] 5830 if isinstance(aVal, base.DecisionID): 5831 if aVal in remapped: # check for remap 5832 aVal = remapped[aVal] 5833 active[domain] = aVal 5834 if graph.getDecision(aVal) is None: # Ultimately valid? 5835 active[domain] = None 5836 elif isinstance(aVal, dict): 5837 for fpName in aVal: 5838 fpVal = aVal[fpName] 5839 if fpVal is None: 5840 aVal[fpName] = None 5841 elif fpVal in remapped: # check for remap 5842 aVal[fpName] = remapped[fpVal] 5843 elif graph.getDecision(fpVal) is None: # valid? 5844 aVal[fpName] = None 5845 elif isinstance(aVal, set): 5846 for r in remapped: 5847 if r in aVal: 5848 aVal.remove(r) 5849 aVal.add(remapped[r]) 5850 discard = [] 5851 for dID in aVal: 5852 if graph.getDecision(dID) is None: 5853 discard.append(dID) 5854 for dID in discard: 5855 aVal.remove(dID) 5856 elif aVal is not None: 5857 raise RuntimeError( 5858 f"Invalid active decisions for domain" 5859 f" {repr(domain)}: {repr(aVal)}" 5860 ) 5861 5862 # Fix up our ObservationContexts 5863 fix = [self.context] 5864 if self.storedContext is not None: 5865 fix.append(self.storedContext) 5866 5867 graph = self.exploration.getSituation().graph 5868 for obsCtx in fix: 5869 cdID = obsCtx['decision'] 5870 if cdID in remapped: 5871 cdID = remapped[cdID] 5872 obsCtx['decision'] = cdID 5873 5874 if cdID not in graph: 5875 obsCtx['decision'] = None 5876 5877 transition = obsCtx['transition'] 5878 if transition is not None: 5879 tSourceID = transition[0] 5880 if tSourceID in remapped: 5881 tSourceID = remapped[tSourceID] 5882 obsCtx['transition'] = (tSourceID, transition[1]) 5883 5884 if transition in remappedTransitions: 5885 obsCtx['transition'] = remappedTransitions[transition] 5886 5887 tDestID = graph.getDestination(tSourceID, transition[1]) 5888 if tDestID is None: 5889 obsCtx['transition'] = None 5890 5891 def recordExtinguishDecision( 5892 self, 5893 target: base.AnyDecisionSpecifier 5894 ) -> None: 5895 """ 5896 Records the deletion of a decision. The decision and all 5897 transitions connected to it will be removed from the current 5898 graph. Does not create a new exploration step. If the current 5899 position is deleted, the position will be set to `None`, or if 5900 we're in relative mode, the decision target will be set to 5901 `None` if it gets deleted. Likewise, all stored and/or current 5902 transitions which no longer exist are erased to `None`. 5903 """ 5904 # Erase target if it's going to be removed 5905 now = self.exploration.getSituation() 5906 5907 if isinstance(target, str): 5908 target = self.parseFormat.parseDecisionSpecifier(target) 5909 5910 # TODO: Do we need to worry about the node being part of any 5911 # focal context data structures? 5912 5913 # Actually remove it 5914 now.graph.removeDecision(target) 5915 5916 # Clean up our contexts 5917 self.cleanupContexts() 5918 5919 def recordExtinguishTransition( 5920 self, 5921 source: base.AnyDecisionSpecifier, 5922 target: base.Transition, 5923 deleteReciprocal: bool = True 5924 ) -> None: 5925 """ 5926 Records the deletion of a named transition coming from a 5927 specific source. The reciprocal will also be removed, unless 5928 `deleteReciprocal` is set to False. If `deleteReciprocal` is 5929 used and this results in the complete isolation of an unknown 5930 node, that node will be deleted as well. Cleans up any saved 5931 transition targets that are no longer valid by setting them to 5932 `None`. Does not create a graph step. 5933 """ 5934 now = self.exploration.getSituation() 5935 graph = now.graph 5936 dest = graph.destination(source, target) 5937 5938 # Remove the transition 5939 graph.removeTransition(source, target, deleteReciprocal) 5940 5941 # Remove the old destination if it's unconfirmed and no longer 5942 # connected anywhere 5943 if ( 5944 not graph.isConfirmed(dest) 5945 and len(graph.destinationsFrom(dest)) == 0 5946 ): 5947 graph.removeDecision(dest) 5948 5949 # Clean up our contexts 5950 self.cleanupContexts() 5951 5952 def recordComplicate( 5953 self, 5954 target: base.Transition, 5955 newDecision: base.DecisionName, # TODO: Allow zones/domain here 5956 newReciprocal: Optional[base.Transition], 5957 newReciprocalReciprocal: Optional[base.Transition] 5958 ) -> base.DecisionID: 5959 """ 5960 Records the complication of a transition and its reciprocal into 5961 a new decision. The old transition and its old reciprocal (if 5962 there was one) both point to the new decision. The 5963 `newReciprocal` becomes the new reciprocal of the original 5964 transition, and the `newReciprocalReciprocal` becomes the new 5965 reciprocal of the old reciprocal. Either may be set explicitly to 5966 `None` to leave the corresponding new transition without a 5967 reciprocal (but they don't default to `None`). If there was no 5968 old reciprocal, but `newReciprocalReciprocal` is specified, then 5969 that transition is created linking the new node to the old 5970 destination, without a reciprocal. 5971 5972 The current decision & transition information is not updated. 5973 5974 Returns the decision ID for the new node. 5975 """ 5976 now = self.exploration.getSituation() 5977 graph = now.graph 5978 here = self.definiteDecisionTarget() 5979 domain = graph.domainFor(here) 5980 5981 oldDest = graph.destination(here, target) 5982 oldReciprocal = graph.getReciprocal(here, target) 5983 5984 # Create the new decision: 5985 newID = graph.addDecision(newDecision, domain=domain) 5986 # Note that the new decision is NOT an unknown decision 5987 # We copy the exploration status from the current decision 5988 self.exploration.setExplorationStatus( 5989 newID, 5990 self.exploration.getExplorationStatus(here) 5991 ) 5992 # Copy over zone info 5993 for zp in graph.zoneParents(here): 5994 graph.addDecisionToZone(newID, zp) 5995 5996 # Retarget the transitions 5997 graph.retargetTransition( 5998 here, 5999 target, 6000 newID, 6001 swapReciprocal=False 6002 ) 6003 if oldReciprocal is not None: 6004 graph.retargetTransition( 6005 oldDest, 6006 oldReciprocal, 6007 newID, 6008 swapReciprocal=False 6009 ) 6010 6011 # Add a new reciprocal edge 6012 if newReciprocal is not None: 6013 graph.addTransition(newID, newReciprocal, here) 6014 graph.setReciprocal(here, target, newReciprocal) 6015 6016 # Add a new double-reciprocal edge (even if there wasn't a 6017 # reciprocal before) 6018 if newReciprocalReciprocal is not None: 6019 graph.addTransition( 6020 newID, 6021 newReciprocalReciprocal, 6022 oldDest 6023 ) 6024 if oldReciprocal is not None: 6025 graph.setReciprocal( 6026 oldDest, 6027 oldReciprocal, 6028 newReciprocalReciprocal 6029 ) 6030 6031 return newID 6032 6033 def recordRevert( 6034 self, 6035 slot: base.SaveSlot, 6036 aspects: Set[str], 6037 decisionType: base.DecisionType = 'active' 6038 ) -> None: 6039 """ 6040 Records a reversion to a previous state (possibly for only some 6041 aspects of the current state). See `base.revertedState` for the 6042 allowed values and meanings of strings in the aspects set. 6043 Uses the specified decision type, or 'active' by default. 6044 6045 Reversion counts as an exploration step. 6046 6047 This sets the current decision to the primary decision for the 6048 reverted state (which might be `None` in some cases) and sets 6049 the current transition to None. 6050 """ 6051 self.exploration.revert(slot, aspects, decisionType=decisionType) 6052 newPrimary = self.exploration.getSituation().state['primaryDecision'] 6053 self.context['decision'] = newPrimary 6054 self.context['transition'] = None 6055 6056 def recordFulfills( 6057 self, 6058 requirement: Union[str, base.Requirement], 6059 fulfilled: Union[ 6060 base.Capability, 6061 Tuple[base.MechanismID, base.MechanismState] 6062 ] 6063 ) -> None: 6064 """ 6065 Records the observation that a certain requirement fulfills the 6066 same role as (i.e., is equivalent to) a specific capability, or a 6067 specific mechanism being in a specific state. Transitions that 6068 require that capability or mechanism state will count as 6069 traversable even if that capability is not obtained or that 6070 mechanism is in another state, as long as the requirement for the 6071 fulfillment is satisfied. If multiple equivalences are 6072 established, any one of them being satisfied will count as that 6073 capability being obtained (or the mechanism being in the 6074 specified state). Note that if a circular dependency is created, 6075 the capability or mechanism (unless actually obtained or in the 6076 target state) will be considered as not being obtained (or in the 6077 target state) during recursive checks. 6078 """ 6079 if isinstance(requirement, str): 6080 requirement = self.parseFormat.parseRequirement(requirement) 6081 6082 self.getExploration().getSituation().graph.addEquivalence( 6083 requirement, 6084 fulfilled 6085 ) 6086 6087 def recordFocusOn( 6088 self, 6089 newFocalPoint: base.FocalPointName, 6090 inDomain: Optional[base.Domain] = None, 6091 inCommon: bool = False 6092 ): 6093 """ 6094 Records a swap to a new focal point, setting that focal point as 6095 the active focal point in the observer's current domain, or in 6096 the specified domain if one is specified. 6097 6098 A `JournalParseError` is raised if the current/specified domain 6099 does not have plural focalization. If it doesn't have a focal 6100 point with that name, then one is created and positioned at the 6101 observer's current decision (which must be in the appropriate 6102 domain). 6103 6104 If `inCommon` is set to `True` (default is `False`) then the 6105 changes will be applied to the common context instead of the 6106 active context. 6107 6108 Note that this does *not* make the target domain active; use 6109 `recordDomainFocus` for that if you need to. 6110 """ 6111 if inDomain is None: 6112 inDomain = self.context['domain'] 6113 6114 if inCommon: 6115 ctx = self.getExploration().getCommonContext() 6116 else: 6117 ctx = self.getExploration().getActiveContext() 6118 6119 if ctx['focalization'].get('domain') != 'plural': 6120 raise JournalParseError( 6121 f"Domain {inDomain!r} does not exist or does not have" 6122 f" plural focalization, so we can't set a focal point" 6123 f" in it." 6124 ) 6125 6126 focalPointMap = ctx['activeDecisions'].setdefault(inDomain, {}) 6127 if not isinstance(focalPointMap, dict): 6128 raise RuntimeError( 6129 f"Plural-focalized domain {inDomain!r} has" 6130 f" non-dictionary active" 6131 f" decisions:\n{repr(focalPointMap)}" 6132 ) 6133 6134 if newFocalPoint not in focalPointMap: 6135 focalPointMap[newFocalPoint] = self.context['decision'] 6136 6137 self.context['focus'] = newFocalPoint 6138 self.context['decision'] = focalPointMap[newFocalPoint] 6139 6140 def recordDomainUnfocus( 6141 self, 6142 domain: base.Domain, 6143 inCommon: bool = False 6144 ): 6145 """ 6146 Records a domain losing focus. Does not raise an error if the 6147 target domain was not active (in that case, it doesn't need to 6148 do anything). 6149 6150 If `inCommon` is set to `True` (default is `False`) then the 6151 domain changes will be applied to the common context instead of 6152 the active context. 6153 """ 6154 if inCommon: 6155 ctx = self.getExploration().getCommonContext() 6156 else: 6157 ctx = self.getExploration().getActiveContext() 6158 6159 try: 6160 ctx['activeDomains'].remove(domain) 6161 except KeyError: 6162 pass 6163 6164 def recordDomainFocus( 6165 self, 6166 domain: base.Domain, 6167 exclusive: bool = False, 6168 inCommon: bool = False 6169 ): 6170 """ 6171 Records a domain gaining focus, activating that domain in the 6172 current focal context and setting it as the observer's current 6173 domain. If the domain named doesn't exist yet, it will be 6174 created first (with default focalization) and then focused. 6175 6176 If `exclusive` is set to `True` (default is `False`) then all 6177 other active domains will be deactivated. 6178 6179 If `inCommon` is set to `True` (default is `False`) then the 6180 domain changes will be applied to the common context instead of 6181 the active context. 6182 """ 6183 if inCommon: 6184 ctx = self.getExploration().getCommonContext() 6185 else: 6186 ctx = self.getExploration().getActiveContext() 6187 6188 if exclusive: 6189 ctx['activeDomains'] = set() 6190 6191 if domain not in ctx['focalization']: 6192 self.recordNewDomain(domain, inCommon=inCommon) 6193 else: 6194 ctx['activeDomains'].add(domain) 6195 6196 self.context['domain'] = domain 6197 6198 def recordNewDomain( 6199 self, 6200 domain: base.Domain, 6201 focalization: base.DomainFocalization = "singular", 6202 inCommon: bool = False 6203 ): 6204 """ 6205 Records a new domain, setting it up with the specified 6206 focalization. Sets that domain as an active domain and as the 6207 journal's current domain so that subsequent entries will create 6208 decisions in that domain. However, it does not activate any 6209 decisions within that domain. 6210 6211 Raises a `JournalParseError` if the specified domain already 6212 exists. 6213 6214 If `inCommon` is set to `True` (default is `False`) then the new 6215 domain will be made active in the common context instead of the 6216 active context. 6217 """ 6218 if inCommon: 6219 ctx = self.getExploration().getCommonContext() 6220 else: 6221 ctx = self.getExploration().getActiveContext() 6222 6223 if domain in ctx['focalization']: 6224 raise JournalParseError( 6225 f"Cannot create domain {domain!r}: that domain already" 6226 f" exists." 6227 ) 6228 6229 ctx['focalization'][domain] = focalization 6230 ctx['activeDecisions'][domain] = None 6231 ctx['activeDomains'].add(domain) 6232 self.context['domain'] = domain 6233 6234 def relative( 6235 self, 6236 where: Optional[base.AnyDecisionSpecifier] = None, 6237 transition: Optional[base.Transition] = None, 6238 ) -> None: 6239 """ 6240 Enters 'relative mode' where the exploration ceases to add new 6241 steps but edits can still be performed on the current graph. This 6242 also changes the current decision/transition settings so that 6243 edits can be applied anywhere. It can accept 0, 1, or 2 6244 arguments. With 0 arguments, it simply enters relative mode but 6245 maintains the current position as the target decision and the 6246 last-taken or last-created transition as the target transition 6247 (note that that transition usually originates at a different 6248 decision). With 1 argument, it sets the target decision to the 6249 decision named, and sets the target transition to None. With 2 6250 arguments, it sets the target decision to the decision named, and 6251 the target transition to the transition named, which must 6252 originate at that target decision. If the first argument is None, 6253 the current decision is used. 6254 6255 If given the name of a decision which does not yet exist, it will 6256 create that decision in the current graph, disconnected from the 6257 rest of the graph. In that case, it is an error to also supply a 6258 transition to target (you can use other commands once in relative 6259 mode to build more transitions and decisions out from the 6260 newly-created decision). 6261 6262 When called in relative mode, it updates the current position 6263 and/or decision, or if called with no arguments, it exits 6264 relative mode. When exiting relative mode, the current decision 6265 is set back to the graph's current position, and the current 6266 transition is set to whatever it was before relative mode was 6267 entered. 6268 6269 Raises a `TypeError` if a transition is specified without 6270 specifying a decision. Raises a `ValueError` if given no 6271 arguments and the exploration does not have a current position. 6272 Also raises a `ValueError` if told to target a specific 6273 transition which does not exist. 6274 6275 TODO: Example here! 6276 """ 6277 # TODO: Not this? 6278 if where is None: 6279 if transition is None and self.inRelativeMode: 6280 # If we're in relative mode, cancel it 6281 self.inRelativeMode = False 6282 6283 # Here we restore saved sate 6284 if self.storedContext is None: 6285 raise RuntimeError( 6286 "No stored context despite being in relative" 6287 "mode." 6288 ) 6289 self.context = self.storedContext 6290 self.storedContext = None 6291 6292 else: 6293 # Enter or stay in relative mode and set up the current 6294 # decision/transition as the targets 6295 6296 # Ensure relative mode 6297 self.inRelativeMode = True 6298 6299 # Store state 6300 self.storedContext = self.context 6301 where = self.storedContext['decision'] 6302 if where is None: 6303 raise ValueError( 6304 "Cannot enter relative mode at the current" 6305 " position because there is no current" 6306 " position." 6307 ) 6308 6309 self.context = observationContext( 6310 context=self.storedContext['context'], 6311 domain=self.storedContext['domain'], 6312 focus=self.storedContext['focus'], 6313 decision=where, 6314 transition=( 6315 None 6316 if transition is None 6317 else (where, transition) 6318 ) 6319 ) 6320 6321 else: # we have at least a decision to target 6322 # If we're entering relative mode instead of just changing 6323 # focus, we need to set up the current transition if no 6324 # transition was specified. 6325 entering: Optional[ 6326 Tuple[ 6327 base.ContextSpecifier, 6328 base.Domain, 6329 Optional[base.FocalPointName] 6330 ] 6331 ] = None 6332 if not self.inRelativeMode: 6333 # We'll be entering relative mode, so store state 6334 entering = ( 6335 self.context['context'], 6336 self.context['domain'], 6337 self.context['focus'] 6338 ) 6339 self.storedContext = self.context 6340 if transition is None: 6341 oldTransitionPair = self.context['transition'] 6342 if oldTransitionPair is not None: 6343 oldBase, oldTransition = oldTransitionPair 6344 if oldBase == where: 6345 transition = oldTransition 6346 6347 # Enter (or stay in) relative mode 6348 self.inRelativeMode = True 6349 6350 now = self.exploration.getSituation() 6351 whereID: Optional[base.DecisionID] 6352 whereSpec: Optional[base.DecisionSpecifier] = None 6353 if isinstance(where, str): 6354 where = self.parseFormat.parseDecisionSpecifier(where) 6355 # might turn it into a DecisionID 6356 6357 if isinstance(where, base.DecisionID): 6358 whereID = where 6359 elif isinstance(where, base.DecisionSpecifier): 6360 # Add in current zone + domain info if those things 6361 # aren't explicit 6362 if self.currentDecisionTarget() is not None: 6363 where = base.spliceDecisionSpecifiers( 6364 where, 6365 self.decisionTargetSpecifier() 6366 ) 6367 elif where.domain is None: 6368 # Splice in current domain if needed 6369 where = base.DecisionSpecifier( 6370 domain=self.context['domain'], 6371 zone=where.zone, 6372 name=where.name 6373 ) 6374 whereID = now.graph.getDecision(where) # might be None 6375 whereSpec = where 6376 else: 6377 raise TypeError(f"Invalid decision specifier: {where!r}") 6378 6379 # Create a new decision if necessary 6380 if whereID is None: 6381 if transition is not None: 6382 raise TypeError( 6383 f"Cannot specify a target transition when" 6384 f" entering relative mode at previously" 6385 f" non-existent decision" 6386 f" {now.graph.identityOf(where)}." 6387 ) 6388 assert whereSpec is not None 6389 whereID = now.graph.addDecision( 6390 whereSpec.name, 6391 domain=whereSpec.domain 6392 ) 6393 if whereSpec.zone is not None: 6394 now.graph.addDecisionToZone(whereID, whereSpec.zone) 6395 6396 # Create the new context if we're entering relative mode 6397 if entering is not None: 6398 self.context = observationContext( 6399 context=entering[0], 6400 domain=entering[1], 6401 focus=entering[2], 6402 decision=whereID, 6403 transition=( 6404 None 6405 if transition is None 6406 else (whereID, transition) 6407 ) 6408 ) 6409 6410 # Target the specified decision 6411 self.context['decision'] = whereID 6412 6413 # Target the specified transition 6414 if transition is not None: 6415 self.context['transition'] = (whereID, transition) 6416 if now.graph.getDestination(where, transition) is None: 6417 raise ValueError( 6418 f"Cannot target transition {transition!r} at" 6419 f" decision {now.graph.identityOf(where)}:" 6420 f" there is no such transition." 6421 ) 6422 # otherwise leave self.context['transition'] alone
Keeps track of extra state needed when parsing a journal in order to
produce a core.DiscreteExploration
object. The methods of this
class act as an API for constructing explorations that have several
special properties. The API is designed to allow journal entries
(which represent specific observations/events during an exploration)
to be directly accumulated into an exploration object, including
entries which apply to things like the most-recent-decision or
-transition.
You can use the convertJournal
function to handle things instead,
since that function creates and manages a JournalObserver
object
for you.
The basic usage is as follows:
- Create a
JournalObserver
, optionally specifying a customParseFormat
. - Repeatedly either:
- Call
record*
API methods corresponding to specific entries observed or... - Call
JournalObserver.observe
to parse one or more journal blocks from a string and call the appropriate methods automatically.
- Call
- Call
JournalObserver.getExploration
to retrieve thecore.DiscreteExploration
object that's been created.
You can just call convertJournal
to do all of these things at
once.
Notes:
JournalObserver.getExploration
may be called at any time to get the exploration object constructed so far, and that that object (unless it'sNone
) will always be the same object (which gets modified as entries are recorded). Modifying this object directly is possible for making changes not available via the API, but must be done carefully, as there are important conventions around things like decision names that must be respected if the API functions need to keep working.- To get the latest graph or state, simply use the
core.DiscreteExploration.getSituation()
method of theJournalObserver.getExploration
result.
Examples
>>> obs = JournalObserver()
>>> e = obs.getExploration()
>>> len(e) # blank starting state
1
>>> e.getActiveDecisions(0) # no active decisions before starting
set()
>>> obs.definiteDecisionTarget()
Traceback (most recent call last):
...
exploration.core.MissingDecisionError...
>>> obs.currentDecisionTarget() is None
True
>>> # We start by using the record* methods...
>>> obs.recordStart("Start")
>>> obs.definiteDecisionTarget()
0
>>> obs.recordObserve("bottom")
>>> obs.definiteDecisionTarget()
0
>>> len(e) # blank + started states
2
>>> e.getActiveDecisions(1)
{0}
>>> obs.recordExplore("left", "West", "right")
>>> obs.definiteDecisionTarget()
2
>>> len(e) # starting states + one step
3
>>> e.getActiveDecisions(1)
{0}
>>> e.movementAtStep(1)
(0, 'left', 2)
>>> e.getActiveDecisions(2)
{2}
>>> e.getActiveDecisions()
{2}
>>> e.getSituation().graph.nameFor(list(e.getActiveDecisions())[0])
'West'
>>> obs.recordRetrace("right") # back at Start
>>> obs.definiteDecisionTarget()
0
>>> len(e) # starting states + two steps
4
>>> e.getActiveDecisions(1)
{0}
>>> e.movementAtStep(1)
(0, 'left', 2)
>>> e.getActiveDecisions(2)
{2}
>>> e.movementAtStep(2)
(2, 'right', 0)
>>> e.getActiveDecisions(3)
{0}
>>> obs.recordRetrace("bad") # transition doesn't exist
Traceback (most recent call last):
...
JournalParseError...
>>> obs.definiteDecisionTarget()
0
>>> obs.recordObserve('right', 'East', 'left')
>>> e.getSituation().graph.getTransitionRequirement('Start', 'right')
ReqNothing()
>>> obs.recordRequirement('crawl|small')
>>> e.getSituation().graph.getTransitionRequirement('Start', 'right')
ReqAny([ReqCapability('crawl'), ReqCapability('small')])
>>> obs.definiteDecisionTarget()
0
>>> obs.currentTransitionTarget()
(0, 'right')
>>> obs.currentReciprocalTarget()
(3, 'left')
>>> g = e.getSituation().graph
>>> print(g.namesListing(g).rstrip('\n'))
0 (Start)
1 (_u.0)
2 (West)
3 (East)
>>> # The use of relative mode to add remote observations
>>> obs.relative('East')
>>> obs.definiteDecisionTarget()
3
>>> obs.recordObserve('top_vent')
>>> obs.recordRequirement('crawl')
>>> obs.recordReciprocalRequirement('crawl')
>>> obs.recordMechanism('East', 'door', 'closed') # door starts closed
>>> obs.recordAction('lever')
>>> obs.recordTransitionConsequence(
... [base.effect(set=("door", "open")), base.effect(deactivate=True)]
... ) # lever opens the door
>>> obs.recordExplore('right_door', 'Outside', 'left_door')
>>> obs.definiteDecisionTarget()
5
>>> obs.recordRequirement('door:open')
>>> obs.recordReciprocalRequirement('door:open')
>>> obs.definiteDecisionTarget()
5
>>> obs.exploration.getExplorationStatus('East')
'noticed'
>>> obs.exploration.hasBeenVisited('East')
False
>>> obs.exploration.getExplorationStatus('Outside')
'noticed'
>>> obs.exploration.hasBeenVisited('Outside')
False
>>> obs.relative() # leave relative mode
>>> len(e) # starting states + two steps, no steps happen in relative mode
4
>>> obs.definiteDecisionTarget() # out of relative mode; at Start
0
>>> g = e.getSituation().graph
>>> g.getTransitionRequirement(
... g.getDestination('East', 'top_vent'),
... 'return'
... )
ReqCapability('crawl')
>>> g.getTransitionRequirement('East', 'top_vent')
ReqCapability('crawl')
>>> g.getTransitionRequirement('East', 'right_door')
ReqMechanism('door', 'open')
>>> g.getTransitionRequirement('Outside', 'left_door')
ReqMechanism('door', 'open')
>>> print(g.namesListing(g).rstrip('\n'))
0 (Start)
1 (_u.0)
2 (West)
3 (East)
4 (_u.3)
5 (Outside)
>>> # Now we demonstrate the use of "observe"
>>> e.getActiveDecisions()
{0}
>>> g.destinationsFrom(0)
{'bottom': 1, 'left': 2, 'right': 3}
>>> g.getDecision('Attic') is None
True
>>> obs.definiteDecisionTarget()
0
>>> obs.observe("o up Attic down\nx up\n n at: Attic\no vent\nq crawl")
>>> g = e.getSituation().graph
>>> print(g.namesListing(g).rstrip('\n'))
0 (Start)
1 (_u.0)
2 (West)
3 (East)
4 (_u.3)
5 (Outside)
6 (Attic)
7 (_u.6)
>>> g.destinationsFrom(0)
{'bottom': 1, 'left': 2, 'right': 3, 'up': 6}
>>> g.nameFor(list(e.getActiveDecisions())[0])
'Attic'
>>> g.getTransitionRequirement('Attic', 'vent')
ReqCapability('crawl')
>>> sorted(list(g.destinationsFrom('Attic').items()))
[('down', 0), ('vent', 7)]
>>> obs.definiteDecisionTarget() # in the Attic
6
>>> obs.observe("a getCrawl\n At gain crawl\nx vent East top_vent") # connecting to a previously-observed transition
>>> g = e.getSituation().graph
>>> print(g.namesListing(g).rstrip('\n'))
0 (Start)
1 (_u.0)
2 (West)
3 (East)
5 (Outside)
6 (Attic)
>>> g.getTransitionRequirement('East', 'top_vent')
ReqCapability('crawl')
>>> g.nameFor(g.getDestination('Attic', 'vent'))
'East'
>>> g.nameFor(g.getDestination('East', 'top_vent'))
'Attic'
>>> len(e) # exploration, action, and return are each 1
7
>>> e.getActiveDecisions(3)
{0}
>>> e.movementAtStep(3)
(0, 'up', 6)
>>> e.getActiveDecisions(4)
{6}
>>> g.nameFor(list(e.getActiveDecisions(4))[0])
'Attic'
>>> e.movementAtStep(4)
(6, 'getCrawl', 6)
>>> g.nameFor(list(e.getActiveDecisions(5))[0])
'Attic'
>>> e.movementAtStep(5)
(6, 'vent', 3)
>>> g.nameFor(list(e.getActiveDecisions(6))[0])
'East'
>>> # Now let's pull the lever and go outside, but first, we'll
>>> # return to the Start to demonstrate recordRetrace
>>> # note that recordReturn only applies when the destination of the
>>> # transition is not already known.
>>> obs.recordRetrace('left') # back to Start
>>> obs.definiteDecisionTarget()
0
>>> obs.recordRetrace('right') # and back to East
>>> obs.definiteDecisionTarget()
3
>>> obs.exploration.mechanismState('door')
'closed'
>>> obs.recordRetrace('lever', isAction=True) # door is now open
>>> obs.exploration.mechanismState('door')
'open'
>>> obs.exploration.getExplorationStatus('Outside')
'noticed'
>>> obs.recordExplore('right_door')
>>> obs.definiteDecisionTarget() # now we're Outside
5
>>> obs.recordReturn('tunnelUnder', 'Start', 'bottom')
>>> obs.definiteDecisionTarget() # back at the start
0
>>> g = e.getSituation().graph
>>> print(g.namesListing(g).rstrip('\n'))
0 (Start)
2 (West)
3 (East)
5 (Outside)
6 (Attic)
>>> g.destinationsFrom(0)
{'left': 2, 'right': 3, 'up': 6, 'bottom': 5}
>>> g.destinationsFrom(5)
{'left_door': 3, 'tunnelUnder': 0}
An example of the use of recordUnify
and recordObviate
.
>>> obs = JournalObserver()
>>> obs.observe('''
... S start
... x right hall left
... x right room left
... x vent vents right_vent
... ''')
>>> obs.recordObviate('middle_vent', 'hall', 'vent')
>>> obs.recordExplore('left_vent', 'new_room', 'vent')
>>> obs.recordUnify('start')
>>> e = obs.getExploration()
>>> len(e)
6
>>> e.getActiveDecisions(0)
set()
>>> [
... e.getSituation(n).graph.nameFor(list(e.getActiveDecisions(n))[0])
... for n in range(1, 6)
... ]
['start', 'hall', 'room', 'vents', 'start']
>>> g = e.getSituation().graph
>>> g.getDestination('start', 'vent')
3
>>> g.getDestination('vents', 'left_vent')
0
>>> g.getReciprocal('start', 'vent')
'left_vent'
>>> g.getReciprocal('vents', 'left_vent')
'vent'
>>> 'new_room' in g
False
1776 def __init__(self, parseFormat: Optional[JournalParseFormat] = None): 1777 """ 1778 Sets up the observer. If a parse format is supplied, that will 1779 be used instead of the default parse format, which is just the 1780 result of creating a `ParseFormat` with default arguments. 1781 1782 A simple example: 1783 1784 >>> o = JournalObserver() 1785 >>> o.recordStart('hi') 1786 >>> o.exploration.getExplorationStatus('hi') 1787 'exploring' 1788 >>> e = o.getExploration() 1789 >>> len(e) 1790 2 1791 >>> g = e.getSituation().graph 1792 >>> len(g) 1793 1 1794 >>> e.getActiveContext() 1795 {\ 1796'capabilities': {'capabilities': set(), 'tokens': {}, 'skills': {}},\ 1797 'focalization': {'main': 'singular'},\ 1798 'activeDomains': {'main'},\ 1799 'activeDecisions': {'main': 0}\ 1800} 1801 >>> list(g.nodes)[0] 1802 0 1803 >>> o.recordObserve('option') 1804 >>> list(g.nodes) 1805 [0, 1] 1806 >>> [g.nameFor(d) for d in g.nodes] 1807 ['hi', '_u.0'] 1808 >>> o.recordZone(0, 'Lower') 1809 >>> [g.nameFor(d) for d in g.nodes] 1810 ['hi', '_u.0'] 1811 >>> e.getActiveDecisions() 1812 {0} 1813 >>> o.recordZone(1, 'Upper') 1814 >>> o.recordExplore('option', 'bye', 'back') 1815 >>> g = e.getSituation().graph 1816 >>> [g.nameFor(d) for d in g.nodes] 1817 ['hi', 'bye'] 1818 >>> o.recordObserve('option2') 1819 >>> import pytest 1820 >>> oldWarn = core.WARN_OF_NAME_COLLISIONS 1821 >>> core.WARN_OF_NAME_COLLISIONS = True 1822 >>> try: 1823 ... with pytest.warns(core.DecisionCollisionWarning): 1824 ... o.recordExplore('option2', 'Lower2::hi', 'back') 1825 ... finally: 1826 ... core.WARN_OF_NAME_COLLISIONS = oldWarn 1827 >>> g = e.getSituation().graph 1828 >>> [g.nameFor(d) for d in g.nodes] 1829 ['hi', 'bye', 'hi'] 1830 >>> # Prefix must be specified because it's ambiguous 1831 >>> o.recordWarp('Lower::hi') 1832 >>> g = e.getSituation().graph 1833 >>> [(d, g.nameFor(d)) for d in g.nodes] 1834 [(0, 'hi'), (1, 'bye'), (2, 'hi')] 1835 >>> e.getActiveDecisions() 1836 {0} 1837 >>> o.recordWarp('bye') 1838 >>> g = e.getSituation().graph 1839 >>> [(d, g.nameFor(d)) for d in g.nodes] 1840 [(0, 'hi'), (1, 'bye'), (2, 'hi')] 1841 >>> e.getActiveDecisions() 1842 {1} 1843 """ 1844 if parseFormat is None: 1845 self.parseFormat = JournalParseFormat() 1846 else: 1847 self.parseFormat = parseFormat 1848 1849 self.uniqueNumber = 0 1850 self.aliases = {} 1851 1852 # Set up default observation preferences 1853 self.preferences = observationPreferences() 1854 1855 # Create a blank exploration 1856 self.exploration = core.DiscreteExploration() 1857 1858 # Debugging support 1859 self.prevSteps: Optional[int] = None 1860 self.prevDecisions: Optional[int] = None 1861 1862 # Current context tracking focal context, domain, focus point, 1863 # decision, and/or transition that's currently most relevant: 1864 self.context = observationContext() 1865 1866 # TODO: Stack of contexts? 1867 # Stored observation context can be restored as the current 1868 # state later. This is used to support relative mode. 1869 self.storedContext: Optional[ 1870 ObservationContext 1871 ] = None 1872 1873 # Whether or not we're in relative mode. 1874 self.inRelativeMode = False 1875 1876 # Tracking which decisions we shouldn't auto-finalize 1877 self.dontFinalize: Set[base.DecisionID] = set() 1878 1879 # Tracking current parse location for errors & warnings 1880 self.journalTexts: List[str] = [] # a stack 'cause of macros 1881 self.parseIndices: List[int] = [] # also a stack
Sets up the observer. If a parse format is supplied, that will
be used instead of the default parse format, which is just the
result of creating a ParseFormat
with default arguments.
A simple example:
>>> o = JournalObserver()
>>> o.recordStart('hi')
>>> o.exploration.getExplorationStatus('hi')
'exploring'
>>> e = o.getExploration()
>>> len(e)
2
>>> g = e.getSituation().graph
>>> len(g)
1
>>> e.getActiveContext()
{'capabilities': {'capabilities': set(), 'tokens': {}, 'skills': {}}, 'focalization': {'main': 'singular'}, 'activeDomains': {'main'}, 'activeDecisions': {'main': 0}}
>>> list(g.nodes)[0]
0
>>> o.recordObserve('option')
>>> list(g.nodes)
[0, 1]
>>> [g.nameFor(d) for d in g.nodes]
['hi', '_u.0']
>>> o.recordZone(0, 'Lower')
>>> [g.nameFor(d) for d in g.nodes]
['hi', '_u.0']
>>> e.getActiveDecisions()
{0}
>>> o.recordZone(1, 'Upper')
>>> o.recordExplore('option', 'bye', 'back')
>>> g = e.getSituation().graph
>>> [g.nameFor(d) for d in g.nodes]
['hi', 'bye']
>>> o.recordObserve('option2')
>>> import pytest
>>> oldWarn = core.WARN_OF_NAME_COLLISIONS
>>> core.WARN_OF_NAME_COLLISIONS = True
>>> try:
... with pytest.warns(core.DecisionCollisionWarning):
... o.recordExplore('option2', 'Lower2::hi', 'back')
... finally:
... core.WARN_OF_NAME_COLLISIONS = oldWarn
>>> g = e.getSituation().graph
>>> [g.nameFor(d) for d in g.nodes]
['hi', 'bye', 'hi']
>>> # Prefix must be specified because it's ambiguous
>>> o.recordWarp('Lower::hi')
>>> g = e.getSituation().graph
>>> [(d, g.nameFor(d)) for d in g.nodes]
[(0, 'hi'), (1, 'bye'), (2, 'hi')]
>>> e.getActiveDecisions()
{0}
>>> o.recordWarp('bye')
>>> g = e.getSituation().graph
>>> [(d, g.nameFor(d)) for d in g.nodes]
[(0, 'hi'), (1, 'bye'), (2, 'hi')]
>>> e.getActiveDecisions()
{1}
The parse format used to parse entries supplied as text. This also ends up controlling some of the decision and transition naming conventions that are followed, so it is not safe to change it mid-journal; it should be set once before observation begins, and may be accessed but should not be changed.
This is the exploration object being built via journal observations. Note that the exploration object may be empty (i.e., have length 0) even after the first few entries have been recorded because in some cases entries are ambiguous and are not translated into exploration steps until a further entry resolves that ambiguity.
Preferences for the observation mechanisms. See
ObservationPreferences
.
A unique number to be substituted (prefixed with '_') into underscore-substitutions within aliases. Will be incremented for each such substitution.
The defined aliases for this observer. Each alias has a name, and stored under that name is a list of parameters followed by a commands string.
1883 def getExploration(self) -> core.DiscreteExploration: 1884 """ 1885 Returns the exploration that this observer edits. 1886 """ 1887 return self.exploration
Returns the exploration that this observer edits.
1889 def nextUniqueName(self) -> str: 1890 """ 1891 Returns the next unique name for this observer, which is just an 1892 underscore followed by an integer. This increments 1893 `uniqueNumber`. 1894 """ 1895 result = '_' + str(self.uniqueNumber) 1896 self.uniqueNumber += 1 1897 return result
Returns the next unique name for this observer, which is just an
underscore followed by an integer. This increments
uniqueNumber
.
1899 def currentDecisionTarget(self) -> Optional[base.DecisionID]: 1900 """ 1901 Returns the decision which decision-based changes should be 1902 applied to. Changes depending on whether relative mode is 1903 active. Will be `None` when there is no current position (e.g., 1904 before the exploration is started). 1905 """ 1906 return self.context['decision']
Returns the decision which decision-based changes should be
applied to. Changes depending on whether relative mode is
active. Will be None
when there is no current position (e.g.,
before the exploration is started).
1908 def definiteDecisionTarget(self) -> base.DecisionID: 1909 """ 1910 Works like `currentDecisionTarget` but raises a 1911 `core.MissingDecisionError` instead of returning `None` if there 1912 is no current decision. 1913 """ 1914 result = self.currentDecisionTarget() 1915 1916 if result is None: 1917 raise core.MissingDecisionError("There is no current decision.") 1918 else: 1919 return result
Works like currentDecisionTarget
but raises a
core.MissingDecisionError
instead of returning None
if there
is no current decision.
1921 def decisionTargetSpecifier(self) -> base.DecisionSpecifier: 1922 """ 1923 Returns a `base.DecisionSpecifier` which includes domain, zone, 1924 and name for the current decision. The zone used is the first 1925 alphabetical lowest-level zone that the decision is in, which 1926 *could* in some cases remain ambiguous. If you're worried about 1927 that, use `definiteDecisionTarget` instead. 1928 1929 Like `definiteDecisionTarget` this will crash if there isn't a 1930 current decision target. 1931 """ 1932 graph = self.exploration.getSituation().graph 1933 dID = self.definiteDecisionTarget() 1934 domain = graph.domainFor(dID) 1935 name = graph.nameFor(dID) 1936 inZones = graph.zoneAncestors(dID) 1937 # Alphabetical order (we have no better option) 1938 ordered = sorted( 1939 inZones, 1940 key=lambda z: ( 1941 graph.zoneHierarchyLevel(z), # level-0 first 1942 z # alphabetical as tie-breaker 1943 ) 1944 ) 1945 if len(ordered) > 0: 1946 useZone = ordered[0] 1947 else: 1948 useZone = None 1949 1950 return base.DecisionSpecifier( 1951 domain=domain, 1952 zone=useZone, 1953 name=name 1954 )
Returns a base.DecisionSpecifier
which includes domain, zone,
and name for the current decision. The zone used is the first
alphabetical lowest-level zone that the decision is in, which
could in some cases remain ambiguous. If you're worried about
that, use definiteDecisionTarget
instead.
Like definiteDecisionTarget
this will crash if there isn't a
current decision target.
1956 def currentTransitionTarget( 1957 self 1958 ) -> Optional[Tuple[base.DecisionID, base.Transition]]: 1959 """ 1960 Returns the decision, transition pair that identifies the current 1961 transition which transition-based changes should apply to. Will 1962 be `None` when there is no current transition (e.g., just after a 1963 warp). 1964 """ 1965 transition = self.context['transition'] 1966 if transition is None: 1967 return None 1968 else: 1969 return transition
Returns the decision, transition pair that identifies the current
transition which transition-based changes should apply to. Will
be None
when there is no current transition (e.g., just after a
warp).
1971 def currentReciprocalTarget( 1972 self 1973 ) -> Optional[Tuple[base.DecisionID, base.Transition]]: 1974 """ 1975 Returns the decision, transition pair that identifies the 1976 reciprocal of the `currentTransitionTarget`. Will be `None` when 1977 there is no current transition, or when the current transition 1978 doesn't have a reciprocal (e.g., after an ending). 1979 """ 1980 # relative mode is handled by `currentTransitionTarget` 1981 target = self.currentTransitionTarget() 1982 if target is None: 1983 return None 1984 return self.exploration.getSituation().graph.getReciprocalPair( 1985 *target 1986 )
Returns the decision, transition pair that identifies the
reciprocal of the currentTransitionTarget
. Will be None
when
there is no current transition, or when the current transition
doesn't have a reciprocal (e.g., after an ending).
1988 def checkFormat( 1989 self, 1990 entryType: str, 1991 decisionType: base.DecisionType, 1992 target: Union[None, JournalTargetType, Tuple[JournalTargetType, int]], 1993 pieces: List[str], 1994 expectedTargets: Union[ 1995 None, 1996 JournalTargetType, 1997 Collection[ 1998 Union[None, JournalTargetType] 1999 ] 2000 ], 2001 expectedPieces: Union[None, int, Collection[int]] 2002 ) -> None: 2003 """ 2004 Does format checking for a journal entry after 2005 `determineEntryType` is called. Checks that: 2006 2007 - A decision type other than 'active' is only used for entries 2008 where that makes sense. 2009 - The target is one from an allowed list of targets (or is `None` 2010 if `expectedTargets` is set to `None`) 2011 - The number of pieces of content is a specific number or within 2012 a specific collection of allowed numbers. If `expectedPieces` 2013 is set to None, there is no restriction on the number of 2014 pieces. 2015 2016 Raises a `JournalParseError` if its expectations are violated. 2017 """ 2018 if decisionType != 'active' and entryType not in ( 2019 'START', 2020 'explore', 2021 'retrace', 2022 'return', 2023 'action', 2024 'warp', 2025 'wait', 2026 'END', 2027 'revert' 2028 ): 2029 raise JournalParseError( 2030 f"{entryType} entry may not specify a non-standard" 2031 f" decision type (got {decisionType!r}), because it is" 2032 f" not associated with an exploration action." 2033 ) 2034 2035 if expectedTargets is None: 2036 if target is not None: 2037 raise JournalParseError( 2038 f"{entryType} entry may not specify a target." 2039 ) 2040 else: 2041 if isinstance(expectedTargets, str): 2042 expected = cast( 2043 Collection[Union[None, JournalTargetType]], 2044 [expectedTargets] 2045 ) 2046 else: 2047 expected = cast( 2048 Collection[Union[None, JournalTargetType]], 2049 expectedTargets 2050 ) 2051 tType = target 2052 if isinstance(tType, tuple): 2053 tType = tType[0] 2054 2055 if tType not in expected: 2056 raise JournalParseError( 2057 f"{entryType} entry had invalid target {target!r}." 2058 f" Expected one of:\n{expected}" 2059 ) 2060 2061 if expectedPieces is None: 2062 # No restriction 2063 pass 2064 elif isinstance(expectedPieces, int): 2065 if len(pieces) != expectedPieces: 2066 raise JournalParseError( 2067 f"{entryType} entry had {len(pieces)} arguments but" 2068 f" only {expectedPieces} argument(s) is/are allowed." 2069 ) 2070 2071 elif len(pieces) not in expectedPieces: 2072 allowed = ', '.join(str(x) for x in expectedPieces) 2073 raise JournalParseError( 2074 f"{entryType} entry had {len(pieces)} arguments but the" 2075 f" allowed argument counts are: {allowed}" 2076 )
Does format checking for a journal entry after
determineEntryType
is called. Checks that:
- A decision type other than 'active' is only used for entries where that makes sense.
- The target is one from an allowed list of targets (or is
None
ifexpectedTargets
is set toNone
) - The number of pieces of content is a specific number or within
a specific collection of allowed numbers. If
expectedPieces
is set to None, there is no restriction on the number of pieces.
Raises a JournalParseError
if its expectations are violated.
2078 def parseOneCommand( 2079 self, 2080 journalText: str, 2081 startIndex: int 2082 ) -> Tuple[List[str], int]: 2083 """ 2084 Parses a single command from the given journal text, starting at 2085 the specified start index. Each command occupies a single line, 2086 except when blocks are present in which case it may stretch 2087 across multiple lines. This function splits the command up into a 2088 list of strings (including multi-line strings and/or strings 2089 with spaces in them when blocks are used). It returns that list 2090 of strings, along with the index after the newline at the end of 2091 the command it parsed (which could be used as the start index 2092 for the next command). If the command has no newline after it 2093 (only possible when the string ends) the returned index will be 2094 the length of the string. 2095 2096 If the line starting with the start character is empty (or just 2097 contains spaces), the result will be an empty list along with the 2098 index for the start of the next line. 2099 2100 Examples: 2101 2102 >>> o = JournalObserver() 2103 >>> commands = '''\\ 2104 ... S start 2105 ... o option 2106 ... 2107 ... x option next back 2108 ... o lever 2109 ... e edit [ 2110 ... o bridge 2111 ... q speed 2112 ... ] [ 2113 ... o bridge 2114 ... q X 2115 ... ] 2116 ... a lever 2117 ... ''' 2118 >>> o.parseOneCommand(commands, 0) 2119 (['S', 'start'], 8) 2120 >>> o.parseOneCommand(commands, 8) 2121 (['o', 'option'], 17) 2122 >>> o.parseOneCommand(commands, 17) 2123 ([], 18) 2124 >>> o.parseOneCommand(commands, 18) 2125 (['x', 'option', 'next', 'back'], 37) 2126 >>> o.parseOneCommand(commands, 37) 2127 (['o', 'lever'], 45) 2128 >>> bits, end = o.parseOneCommand(commands, 45) 2129 >>> bits[:2] 2130 ['e', 'edit'] 2131 >>> bits[2] 2132 'o bridge\\n q speed' 2133 >>> bits[3] 2134 'o bridge\\n q X' 2135 >>> len(bits) 2136 4 2137 >>> end 2138 116 2139 >>> o.parseOneCommand(commands, end) 2140 (['a', 'lever'], 124) 2141 2142 >>> o = JournalObserver() 2143 >>> s = "o up Attic down\\nx up\\no vent\\nq crawl" 2144 >>> o.parseOneCommand(s, 0) 2145 (['o', 'up', 'Attic', 'down'], 16) 2146 >>> o.parseOneCommand(s, 16) 2147 (['x', 'up'], 21) 2148 >>> o.parseOneCommand(s, 21) 2149 (['o', 'vent'], 28) 2150 >>> o.parseOneCommand(s, 28) 2151 (['q', 'crawl'], 35) 2152 """ 2153 2154 index = startIndex 2155 unit: Optional[str] = None 2156 bits: List[str] = [] 2157 pf = self.parseFormat # shortcut variable 2158 while index < len(journalText): 2159 char = journalText[index] 2160 if char.isspace(): 2161 # Space after non-spaces -> end of unit 2162 if unit is not None: 2163 bits.append(unit) 2164 unit = None 2165 # End of line -> end of command 2166 if char == '\n': 2167 index += 1 2168 break 2169 else: 2170 # Non-space -> check for block 2171 if char == pf.blockStart: 2172 if unit is not None: 2173 bits.append(unit) 2174 unit = None 2175 blockEnd = pf.findBlockEnd(journalText, index) 2176 block = journalText[index + 1:blockEnd - 1].strip() 2177 bits.append(block) 2178 index = blockEnd # +1 added below 2179 elif unit is None: # Initial non-space -> start of unit 2180 unit = char 2181 else: # Continuing non-space -> accumulate 2182 unit += char 2183 # Increment index 2184 index += 1 2185 2186 # Grab final unit if there is one hanging 2187 if unit is not None: 2188 bits.append(unit) 2189 2190 return (bits, index)
Parses a single command from the given journal text, starting at the specified start index. Each command occupies a single line, except when blocks are present in which case it may stretch across multiple lines. This function splits the command up into a list of strings (including multi-line strings and/or strings with spaces in them when blocks are used). It returns that list of strings, along with the index after the newline at the end of the command it parsed (which could be used as the start index for the next command). If the command has no newline after it (only possible when the string ends) the returned index will be the length of the string.
If the line starting with the start character is empty (or just contains spaces), the result will be an empty list along with the index for the start of the next line.
Examples:
>>> o = JournalObserver()
>>> commands = '''\
... S start
... o option
...
... x option next back
... o lever
... e edit [
... o bridge
... q speed
... ] [
... o bridge
... q X
... ]
... a lever
... '''
>>> o.parseOneCommand(commands, 0)
(['S', 'start'], 8)
>>> o.parseOneCommand(commands, 8)
(['o', 'option'], 17)
>>> o.parseOneCommand(commands, 17)
([], 18)
>>> o.parseOneCommand(commands, 18)
(['x', 'option', 'next', 'back'], 37)
>>> o.parseOneCommand(commands, 37)
(['o', 'lever'], 45)
>>> bits, end = o.parseOneCommand(commands, 45)
>>> bits[:2]
['e', 'edit']
>>> bits[2]
'o bridge\n q speed'
>>> bits[3]
'o bridge\n q X'
>>> len(bits)
4
>>> end
116
>>> o.parseOneCommand(commands, end)
(['a', 'lever'], 124)
>>> o = JournalObserver()
>>> s = "o up Attic down\nx up\no vent\nq crawl"
>>> o.parseOneCommand(s, 0)
(['o', 'up', 'Attic', 'down'], 16)
>>> o.parseOneCommand(s, 16)
(['x', 'up'], 21)
>>> o.parseOneCommand(s, 21)
(['o', 'vent'], 28)
>>> o.parseOneCommand(s, 28)
(['q', 'crawl'], 35)
2192 def warn(self, message: str) -> None: 2193 """ 2194 Issues a `JournalParseWarning`. 2195 """ 2196 if len(self.journalTexts) == 0 or len(self.parseIndices) == 0: 2197 warnings.warn(message, JournalParseWarning) 2198 else: 2199 # Note: We use the basal position info because that will 2200 # typically be much more useful when debugging 2201 ec = errorContext(self.journalTexts[0], self.parseIndices[0]) 2202 errorCM = textwrap.indent(errorContextMessage(ec), ' ') 2203 warnings.warn(errorCM + '\n' + message, JournalParseWarning)
Issues a JournalParseWarning
.
2205 def observe(self, journalText: str) -> None: 2206 """ 2207 Ingests one or more journal blocks in text format (as a 2208 multi-line string) and updates the exploration being built by 2209 this observer, as well as updating internal state. 2210 2211 This method can be called multiple times to process a longer 2212 journal incrementally including line-by-line. 2213 2214 The `journalText` and `parseIndex` fields will be updated during 2215 parsing to support contextual error messages and warnings. 2216 2217 ## Example: 2218 2219 >>> obs = JournalObserver() 2220 >>> oldWarn = core.WARN_OF_NAME_COLLISIONS 2221 >>> try: 2222 ... obs.observe('''\\ 2223 ... S Room1::start 2224 ... zz Region 2225 ... o nope 2226 ... q power|tokens*3 2227 ... o unexplored 2228 ... o onwards 2229 ... x onwards sub_room backwards 2230 ... t backwards 2231 ... o down 2232 ... 2233 ... x down Room2::middle up 2234 ... a box 2235 ... At deactivate 2236 ... At gain tokens*1 2237 ... o left 2238 ... o right 2239 ... gt blue 2240 ... 2241 ... x right Room3::middle left 2242 ... o right 2243 ... a miniboss 2244 ... At deactivate 2245 ... At gain power 2246 ... x right - left 2247 ... o ledge 2248 ... q tall 2249 ... t left 2250 ... t left 2251 ... t up 2252 ... 2253 ... x nope secret back 2254 ... ''') 2255 ... finally: 2256 ... core.WARN_OF_NAME_COLLISIONS = oldWarn 2257 >>> e = obs.getExploration() 2258 >>> len(e) 2259 13 2260 >>> g = e.getSituation().graph 2261 >>> len(g) 2262 9 2263 >>> def showDestinations(g, r): 2264 ... if isinstance(r, str): 2265 ... r = obs.parseFormat.parseDecisionSpecifier(r) 2266 ... d = g.destinationsFrom(r) 2267 ... for outgoing in sorted(d): 2268 ... req = g.getTransitionRequirement(r, outgoing) 2269 ... if req is None or req == base.ReqNothing(): 2270 ... req = '' 2271 ... else: 2272 ... req = ' ' + repr(req) 2273 ... print(outgoing, g.identityOf(d[outgoing]) + req) 2274 ... 2275 >>> "start" in g 2276 False 2277 >>> showDestinations(g, "Room1::start") 2278 down 4 (Room2::middle) 2279 nope 1 (Room1::secret) ReqAny([ReqCapability('power'),\ 2280 ReqTokens('tokens', 3)]) 2281 onwards 3 (Room1::sub_room) 2282 unexplored 2 (_u.1) 2283 >>> showDestinations(g, "Room1::secret") 2284 back 0 (Room1::start) 2285 >>> showDestinations(g, "Room1::sub_room") 2286 backwards 0 (Room1::start) 2287 >>> showDestinations(g, "Room2::middle") 2288 box 4 (Room2::middle) 2289 left 5 (_u.4) 2290 right 6 (Room3::middle) 2291 up 0 (Room1::start) 2292 >>> g.transitionTags(4, "right") 2293 {'blue': 1} 2294 >>> showDestinations(g, "Room3::middle") 2295 left 4 (Room2::middle) 2296 miniboss 6 (Room3::middle) 2297 right 7 (Room3::-) 2298 >>> showDestinations(g, "Room3::-") 2299 ledge 8 (_u.7) ReqCapability('tall') 2300 left 6 (Room3::middle) 2301 >>> showDestinations(g, "_u.7") 2302 return 7 (Room3::-) 2303 >>> e.getActiveDecisions() 2304 {1} 2305 >>> g.identityOf(1) 2306 '1 (Room1::secret)' 2307 2308 Note that there are plenty of other annotations not shown in 2309 this example; see `DEFAULT_FORMAT` for the default mapping from 2310 journal entry types to markers, and see `JournalEntryType` for 2311 the explanation for each entry type. 2312 2313 Most entries start with a marker (which includes one character 2314 for the type and possibly one for the target) followed by a 2315 single space, and everything after that is the content of the 2316 entry. 2317 """ 2318 # Normalize newlines 2319 journalText = journalText\ 2320 .replace('\r\n', '\n')\ 2321 .replace('\n\r', '\n')\ 2322 .replace('\r', '\n') 2323 2324 # Shortcut variable 2325 pf = self.parseFormat 2326 2327 # Remove comments from entire text 2328 journalText = pf.removeComments(journalText) 2329 2330 # TODO: Give access to comments in error messages? 2331 # Store for error messages 2332 self.journalTexts.append(journalText) 2333 self.parseIndices.append(0) 2334 2335 startAt = 0 2336 try: 2337 while startAt < len(journalText): 2338 self.parseIndices[-1] = startAt 2339 bits, startAt = self.parseOneCommand(journalText, startAt) 2340 2341 if len(bits) == 0: 2342 continue 2343 2344 eType, dType, eTarget, eParts = pf.determineEntryType(bits) 2345 if eType == 'preference': 2346 self.checkFormat( 2347 'preference', 2348 dType, 2349 eTarget, 2350 eParts, 2351 None, 2352 2 2353 ) 2354 pref = eParts[0] 2355 opAnn = get_type_hints(ObservationPreferences) 2356 if pref not in opAnn: 2357 raise JournalParseError( 2358 f"Invalid preference name {pref!r}." 2359 ) 2360 2361 prefVal: Union[None, str, bool, Set[str]] 2362 if opAnn[pref] is bool: 2363 prefVal = pf.onOff(eParts[1]) 2364 if prefVal is None: 2365 self.warn( 2366 f"On/off value {eParts[1]!r} is neither" 2367 f" {pf.markerFor('on')!r} nor" 2368 f" {pf.markerFor('off')!r}. Assuming" 2369 f" 'off'." 2370 ) 2371 elif opAnn[pref] == Set[str]: 2372 prefVal = set(' '.join(eParts[1:]).split()) 2373 else: # we assume it's a string 2374 assert opAnn[pref] is str 2375 prefVal = eParts[1] 2376 2377 # Set the preference value (type checked above) 2378 self.preferences[pref] = prefVal # type: ignore [literal-required] # noqa: E501 2379 2380 elif eType == 'alias': 2381 self.checkFormat( 2382 "alias", 2383 dType, 2384 eTarget, 2385 eParts, 2386 None, 2387 None 2388 ) 2389 2390 if len(eParts) < 2: 2391 raise JournalParseError( 2392 "Alias entry must include at least an alias" 2393 " name and a commands list." 2394 ) 2395 aliasName = eParts[0] 2396 parameters = eParts[1:-1] 2397 commands = eParts[-1] 2398 self.defineAlias(aliasName, parameters, commands) 2399 2400 elif eType == 'custom': 2401 self.checkFormat( 2402 "custom", 2403 dType, 2404 eTarget, 2405 eParts, 2406 None, 2407 None 2408 ) 2409 if len(eParts) == 0: 2410 raise JournalParseError( 2411 "Custom entry must include at least an alias" 2412 " name." 2413 ) 2414 self.deployAlias(eParts[0], eParts[1:]) 2415 2416 elif eType == 'DEBUG': 2417 self.checkFormat( 2418 "DEBUG", 2419 dType, 2420 eTarget, 2421 eParts, 2422 None, 2423 {1, 2} 2424 ) 2425 if eParts[0] not in get_args(DebugAction): 2426 raise JournalParseError( 2427 f"Invalid debug action: {eParts[0]!r}" 2428 ) 2429 dAction = cast(DebugAction, eParts[0]) 2430 if len(eParts) > 1: 2431 self.doDebug(dAction, eParts[1]) 2432 else: 2433 self.doDebug(dAction) 2434 2435 elif eType == 'START': 2436 self.checkFormat( 2437 "START", 2438 dType, 2439 eTarget, 2440 eParts, 2441 None, 2442 1 2443 ) 2444 2445 where = pf.parseDecisionSpecifier(eParts[0]) 2446 if isinstance(where, base.DecisionID): 2447 raise JournalParseError( 2448 f"Can't use {repr(where)} as a start" 2449 f" because the start must be a decision" 2450 f" name, not a decision ID." 2451 ) 2452 self.recordStart(where, dType) 2453 2454 elif eType == 'explore': 2455 self.checkFormat( 2456 "explore", 2457 dType, 2458 eTarget, 2459 eParts, 2460 None, 2461 {1, 2, 3} 2462 ) 2463 2464 tr = pf.parseTransitionWithOutcomes(eParts[0]) 2465 2466 if len(eParts) == 1: 2467 self.recordExplore(tr, decisionType=dType) 2468 elif len(eParts) == 2: 2469 destination = pf.parseDecisionSpecifier(eParts[1]) 2470 self.recordExplore( 2471 tr, 2472 destination, 2473 decisionType=dType 2474 ) 2475 else: 2476 destination = pf.parseDecisionSpecifier(eParts[1]) 2477 self.recordExplore( 2478 tr, 2479 destination, 2480 eParts[2], 2481 decisionType=dType 2482 ) 2483 2484 elif eType == 'return': 2485 self.checkFormat( 2486 "return", 2487 dType, 2488 eTarget, 2489 eParts, 2490 None, 2491 {1, 2, 3} 2492 ) 2493 tr = pf.parseTransitionWithOutcomes(eParts[0]) 2494 if len(eParts) > 1: 2495 destination = pf.parseDecisionSpecifier(eParts[1]) 2496 else: 2497 destination = None 2498 if len(eParts) > 2: 2499 reciprocal = eParts[2] 2500 else: 2501 reciprocal = None 2502 self.recordReturn( 2503 tr, 2504 destination, 2505 reciprocal, 2506 decisionType=dType 2507 ) 2508 2509 elif eType == 'action': 2510 self.checkFormat( 2511 "action", 2512 dType, 2513 eTarget, 2514 eParts, 2515 None, 2516 1 2517 ) 2518 tr = pf.parseTransitionWithOutcomes(eParts[0]) 2519 self.recordAction(tr, decisionType=dType) 2520 2521 elif eType == 'retrace': 2522 self.checkFormat( 2523 "retrace", 2524 dType, 2525 eTarget, 2526 eParts, 2527 (None, 'actionPart'), 2528 1 2529 ) 2530 tr = pf.parseTransitionWithOutcomes(eParts[0]) 2531 self.recordRetrace( 2532 tr, 2533 decisionType=dType, 2534 isAction=eTarget == 'actionPart' 2535 ) 2536 2537 elif eType == 'warp': 2538 self.checkFormat( 2539 "warp", 2540 dType, 2541 eTarget, 2542 eParts, 2543 None, 2544 {1} 2545 ) 2546 2547 destination = pf.parseDecisionSpecifier(eParts[0]) 2548 self.recordWarp(destination, decisionType=dType) 2549 2550 elif eType == 'wait': 2551 self.checkFormat( 2552 "wait", 2553 dType, 2554 eTarget, 2555 eParts, 2556 None, 2557 0 2558 ) 2559 self.recordWait(decisionType=dType) 2560 2561 elif eType == 'observe': 2562 self.checkFormat( 2563 "observe", 2564 dType, 2565 eTarget, 2566 eParts, 2567 (None, 'actionPart', 'endingPart'), 2568 (1, 2, 3) 2569 ) 2570 if eTarget is None: 2571 self.recordObserve(*eParts) 2572 elif eTarget == 'actionPart': 2573 if len(eParts) > 1: 2574 raise JournalParseError( 2575 f"Observing action {eParts[0]!r} at" 2576 f" {self.definiteDecisionTarget()!r}:" 2577 f" neither a destination nor a" 2578 f" reciprocal may be specified when" 2579 f" observing an action (did you mean to" 2580 f" observe a transition?)." 2581 ) 2582 self.recordObserveAction(*eParts) 2583 elif eTarget == 'endingPart': 2584 if len(eParts) > 1: 2585 raise JournalParseError( 2586 f"Observing ending {eParts[0]!r} at" 2587 f" {self.definiteDecisionTarget()!r}:" 2588 f" neither a destination nor a" 2589 f" reciprocal may be specified when" 2590 f" observing an ending (did you mean to" 2591 f" observe a transition?)." 2592 ) 2593 self.recordObserveEnding(*eParts) 2594 2595 elif eType == 'END': 2596 self.checkFormat( 2597 "END", 2598 dType, 2599 eTarget, 2600 eParts, 2601 (None, 'actionPart'), 2602 1 2603 ) 2604 self.recordEnd( 2605 eParts[0], 2606 eTarget == 'actionPart', 2607 decisionType=dType 2608 ) 2609 2610 elif eType == 'mechanism': 2611 self.checkFormat( 2612 "mechanism", 2613 dType, 2614 eTarget, 2615 eParts, 2616 None, 2617 1 2618 ) 2619 mReq = pf.parseRequirement(eParts[0]) 2620 if ( 2621 not isinstance(mReq, base.ReqMechanism) 2622 or not isinstance( 2623 mReq.mechanism, 2624 (base.MechanismName, base.MechanismSpecifier) 2625 ) 2626 ): 2627 raise JournalParseError( 2628 f"Invalid mechanism declaration" 2629 f" {eParts[0]!r}. Declaration must specify" 2630 f" mechanism name and starting state." 2631 ) 2632 mState = mReq.reqState 2633 if isinstance(mReq.mechanism, base.MechanismName): 2634 where = self.definiteDecisionTarget() 2635 mName = mReq.mechanism 2636 else: 2637 assert isinstance( 2638 mReq.mechanism, 2639 base.MechanismSpecifier 2640 ) 2641 mSpec = mReq.mechanism 2642 mName = mSpec.name 2643 if mSpec.decision is not None: 2644 where = base.DecisionSpecifier( 2645 mSpec.domain, 2646 mSpec.zone, 2647 mSpec.decision 2648 ) 2649 else: 2650 where = self.definiteDecisionTarget() 2651 graph = self.exploration.getSituation().graph 2652 thisDomain = graph.domainFor(where) 2653 theseZones = graph.zoneAncestors(where) 2654 if ( 2655 mSpec.domain is not None 2656 and mSpec.domain != thisDomain 2657 ): 2658 raise JournalParseError( 2659 f"Mechanism specifier {mSpec!r}" 2660 f" does not specify a decision but" 2661 f" includes domain {mSpec.domain!r}" 2662 f" which does not match the domain" 2663 f" {thisDomain!r} of the current" 2664 f" decision {graph.identityOf(where)}" 2665 ) 2666 if ( 2667 mSpec.zone is not None 2668 and mSpec.zone not in theseZones 2669 ): 2670 raise JournalParseError( 2671 f"Mechanism specifier {mSpec!r}" 2672 f" does not specify a decision but" 2673 f" includes zone {mSpec.zone!r}" 2674 f" which is not one of the zones" 2675 f" that the current decision" 2676 f" {graph.identityOf(where)} is in:" 2677 f"\n{theseZones!r}" 2678 ) 2679 self.recordMechanism(where, mName, mState) 2680 2681 elif eType == 'requirement': 2682 self.checkFormat( 2683 "requirement", 2684 dType, 2685 eTarget, 2686 eParts, 2687 (None, 'reciprocalPart', 'bothPart'), 2688 None 2689 ) 2690 req = pf.parseRequirement(' '.join(eParts)) 2691 if eTarget in (None, 'bothPart'): 2692 self.recordRequirement(req) 2693 if eTarget in ('reciprocalPart', 'bothPart'): 2694 self.recordReciprocalRequirement(req) 2695 2696 elif eType == 'effect': 2697 self.checkFormat( 2698 "effect", 2699 dType, 2700 eTarget, 2701 eParts, 2702 (None, 'reciprocalPart', 'bothPart'), 2703 None 2704 ) 2705 2706 consequence: base.Consequence 2707 try: 2708 consequence = pf.parseConsequence(' '.join(eParts)) 2709 except parsing.ParseError: 2710 consequence = [pf.parseEffect(' '.join(eParts))] 2711 2712 if eTarget in (None, 'bothPart'): 2713 self.recordTransitionConsequence(consequence) 2714 if eTarget in ('reciprocalPart', 'bothPart'): 2715 self.recordReciprocalConsequence(consequence) 2716 2717 elif eType == 'apply': 2718 self.checkFormat( 2719 "apply", 2720 dType, 2721 eTarget, 2722 eParts, 2723 (None, 'transitionPart'), 2724 None 2725 ) 2726 2727 toApply: base.Consequence 2728 try: 2729 toApply = pf.parseConsequence(' '.join(eParts)) 2730 except parsing.ParseError: 2731 toApply = [pf.parseEffect(' '.join(eParts))] 2732 2733 # If we targeted a transition, that means we wanted 2734 # to both apply the consequence now AND set it up as 2735 # an consequence of the transition we just took. 2736 if eTarget == 'transitionPart': 2737 if self.context['transition'] is None: 2738 raise JournalParseError( 2739 "Can't apply a consequence to a" 2740 " transition here because there is no" 2741 " current relevant transition." 2742 ) 2743 # We need to apply these consequences as part of 2744 # the transition so their trigger count will be 2745 # tracked properly, but we do not want to 2746 # re-apply the other parts of the consequence. 2747 self.recordAdditionalTransitionConsequence( 2748 toApply 2749 ) 2750 else: 2751 # Otherwise just apply the consequence 2752 self.exploration.applyExtraneousConsequence( 2753 toApply, 2754 where=self.context['transition'], 2755 moveWhich=self.context['focus'] 2756 ) 2757 # Note: no situation-based variables need 2758 # updating here 2759 2760 elif eType == 'tag': 2761 self.checkFormat( 2762 "tag", 2763 dType, 2764 eTarget, 2765 eParts, 2766 ( 2767 None, 2768 'decisionPart', 2769 'transitionPart', 2770 'reciprocalPart', 2771 'bothPart', 2772 'zonePart' 2773 ), 2774 None 2775 ) 2776 tag: base.Tag 2777 value: base.TagValue 2778 if len(eParts) == 0: 2779 raise JournalParseError( 2780 "tag entry must include at least a tag name." 2781 ) 2782 elif len(eParts) == 1: 2783 tag = eParts[0] 2784 value = 1 2785 elif len(eParts) == 2: 2786 tag, value = eParts 2787 value = pf.parseTagValue(value) 2788 else: 2789 raise JournalParseError( 2790 f"tag entry has too many parts (only a tag" 2791 f" name and a tag value are allowed). Got:" 2792 f" {eParts}" 2793 ) 2794 2795 if eTarget is None: 2796 self.recordTagStep(tag, value) 2797 elif eTarget == "decisionPart": 2798 self.recordTagDecision(tag, value) 2799 elif eTarget == "transitionPart": 2800 self.recordTagTranstion(tag, value) 2801 elif eTarget == "reciprocalPart": 2802 self.recordTagReciprocal(tag, value) 2803 elif eTarget == "bothPart": 2804 self.recordTagTranstion(tag, value) 2805 self.recordTagReciprocal(tag, value) 2806 elif eTarget == "zonePart": 2807 self.recordTagZone(0, tag, value) 2808 elif ( 2809 isinstance(eTarget, tuple) 2810 and len(eTarget) == 2 2811 and eTarget[0] == "zonePart" 2812 and isinstance(eTarget[1], int) 2813 ): 2814 self.recordTagZone(eTarget[1] - 1, tag, value) 2815 else: 2816 raise JournalParseError( 2817 f"Invalid tag target type {eTarget!r}." 2818 ) 2819 2820 elif eType == 'annotate': 2821 self.checkFormat( 2822 "annotate", 2823 dType, 2824 eTarget, 2825 eParts, 2826 ( 2827 None, 2828 'decisionPart', 2829 'transitionPart', 2830 'reciprocalPart', 2831 'bothPart' 2832 ), 2833 None 2834 ) 2835 if len(eParts) == 0: 2836 raise JournalParseError( 2837 "annotation may not be empty." 2838 ) 2839 combined = ' '.join(eParts) 2840 if eTarget is None: 2841 self.recordAnnotateStep(combined) 2842 elif eTarget == "decisionPart": 2843 self.recordAnnotateDecision(combined) 2844 elif eTarget == "transitionPart": 2845 self.recordAnnotateTranstion(combined) 2846 elif eTarget == "reciprocalPart": 2847 self.recordAnnotateReciprocal(combined) 2848 elif eTarget == "bothPart": 2849 self.recordAnnotateTranstion(combined) 2850 self.recordAnnotateReciprocal(combined) 2851 elif eTarget == "zonePart": 2852 self.recordAnnotateZone(0, combined) 2853 elif ( 2854 isinstance(eTarget, tuple) 2855 and len(eTarget) == 2 2856 and eTarget[0] == "zonePart" 2857 and isinstance(eTarget[1], int) 2858 ): 2859 self.recordAnnotateZone(eTarget[1] - 1, combined) 2860 else: 2861 raise JournalParseError( 2862 f"Invalid annotation target type {eTarget!r}." 2863 ) 2864 2865 elif eType == 'context': 2866 self.checkFormat( 2867 "context", 2868 dType, 2869 eTarget, 2870 eParts, 2871 None, 2872 1 2873 ) 2874 if eParts[0] == pf.markerFor('commonContext'): 2875 self.recordContextSwap(None) 2876 else: 2877 self.recordContextSwap(eParts[0]) 2878 2879 elif eType == 'domain': 2880 self.checkFormat( 2881 "domain", 2882 dType, 2883 eTarget, 2884 eParts, 2885 None, 2886 {1, 2, 3} 2887 ) 2888 inCommon = False 2889 if eParts[-1] == pf.markerFor('commonContext'): 2890 eParts = eParts[:-1] 2891 inCommon = True 2892 if len(eParts) == 3: 2893 raise JournalParseError( 2894 f"A domain entry may only have 1 or 2" 2895 f" arguments unless the last argument is" 2896 f" {repr(pf.markerFor('commonContext'))}" 2897 ) 2898 elif len(eParts) == 2: 2899 if eParts[0] == pf.markerFor('exclusiveDomain'): 2900 self.recordDomainFocus( 2901 eParts[1], 2902 exclusive=True, 2903 inCommon=inCommon 2904 ) 2905 elif eParts[0] == pf.markerFor('notApplicable'): 2906 # Deactivate the domain 2907 self.recordDomainUnfocus( 2908 eParts[1], 2909 inCommon=inCommon 2910 ) 2911 else: 2912 # Set up new domain w/ given focalization 2913 focalization = pf.parseFocalization(eParts[1]) 2914 self.recordNewDomain( 2915 eParts[0], 2916 focalization, 2917 inCommon=inCommon 2918 ) 2919 else: 2920 # Focus the domain (or possibly create it) 2921 self.recordDomainFocus( 2922 eParts[0], 2923 inCommon=inCommon 2924 ) 2925 2926 elif eType == 'focus': 2927 self.checkFormat( 2928 "focus", 2929 dType, 2930 eTarget, 2931 eParts, 2932 None, 2933 {1, 2} 2934 ) 2935 if len(eParts) == 2: # explicit domain 2936 self.recordFocusOn(eParts[1], eParts[0]) 2937 else: # implicit domain 2938 self.recordFocusOn(eParts[0]) 2939 2940 elif eType == 'zone': 2941 self.checkFormat( 2942 "zone", 2943 dType, 2944 eTarget, 2945 eParts, 2946 (None, 'zonePart'), 2947 1 2948 ) 2949 if eTarget is None: 2950 level = 0 2951 elif eTarget == 'zonePart': 2952 level = 1 2953 else: 2954 assert isinstance(eTarget, tuple) 2955 assert len(eTarget) == 2 2956 level = eTarget[1] 2957 self.recordZone(level, eParts[0]) 2958 2959 elif eType == 'unify': 2960 self.checkFormat( 2961 "unify", 2962 dType, 2963 eTarget, 2964 eParts, 2965 (None, 'transitionPart', 'reciprocalPart'), 2966 (1, 2) 2967 ) 2968 if eTarget is None: 2969 decisions = [ 2970 pf.parseDecisionSpecifier(p) 2971 for p in eParts 2972 ] 2973 self.recordUnify(*decisions) 2974 elif eTarget == 'transitionPart': 2975 if len(eParts) != 1: 2976 raise JournalParseError( 2977 "A transition unification entry may only" 2978 f" have one argument, but we got" 2979 f" {len(eParts)}." 2980 ) 2981 self.recordUnifyTransition(eParts[0]) 2982 elif eTarget == 'reciprocalPart': 2983 if len(eParts) != 1: 2984 raise JournalParseError( 2985 "A transition unification entry may only" 2986 f" have one argument, but we got" 2987 f" {len(eParts)}." 2988 ) 2989 self.recordUnifyReciprocal(eParts[0]) 2990 else: 2991 raise RuntimeError( 2992 f"Invalid target type {eTarget} after check" 2993 f" for unify entry!" 2994 ) 2995 2996 elif eType == 'obviate': 2997 self.checkFormat( 2998 "obviate", 2999 dType, 3000 eTarget, 3001 eParts, 3002 None, 3003 3 3004 ) 3005 transition, targetDecision, targetTransition = eParts 3006 self.recordObviate( 3007 transition, 3008 pf.parseDecisionSpecifier(targetDecision), 3009 targetTransition 3010 ) 3011 3012 elif eType == 'extinguish': 3013 self.checkFormat( 3014 "extinguish", 3015 dType, 3016 eTarget, 3017 eParts, 3018 ( 3019 None, 3020 'decisionPart', 3021 'transitionPart', 3022 'reciprocalPart', 3023 'bothPart' 3024 ), 3025 1 3026 ) 3027 if eTarget is None: 3028 eTarget = 'bothPart' 3029 if eTarget == 'decisionPart': 3030 self.recordExtinguishDecision( 3031 pf.parseDecisionSpecifier(eParts[0]) 3032 ) 3033 elif eTarget == 'transitionPart': 3034 transition = eParts[0] 3035 here = self.definiteDecisionTarget() 3036 self.recordExtinguishTransition( 3037 here, 3038 transition, 3039 False 3040 ) 3041 elif eTarget == 'bothPart': 3042 transition = eParts[0] 3043 here = self.definiteDecisionTarget() 3044 self.recordExtinguishTransition( 3045 here, 3046 transition, 3047 True 3048 ) 3049 else: # Must be reciprocalPart 3050 transition = eParts[0] 3051 here = self.definiteDecisionTarget() 3052 now = self.exploration.getSituation() 3053 rPair = now.graph.getReciprocalPair(here, transition) 3054 if rPair is None: 3055 raise JournalParseError( 3056 f"Attempted to extinguish the" 3057 f" reciprocal of transition" 3058 f" {transition!r} which " 3059 f" has no reciprocal (or which" 3060 f" doesn't exist from decision" 3061 f" {now.graph.identityOf(here)})." 3062 ) 3063 3064 self.recordExtinguishTransition( 3065 rPair[0], 3066 rPair[1], 3067 deleteReciprocal=False 3068 ) 3069 3070 elif eType == 'complicate': 3071 self.checkFormat( 3072 "complicate", 3073 dType, 3074 eTarget, 3075 eParts, 3076 None, 3077 4 3078 ) 3079 target, newName, newReciprocal, newRR = eParts 3080 self.recordComplicate( 3081 target, 3082 newName, 3083 newReciprocal, 3084 newRR 3085 ) 3086 3087 elif eType == 'status': 3088 self.checkFormat( 3089 "status", 3090 dType, 3091 eTarget, 3092 eParts, 3093 (None, 'unfinishedPart'), 3094 {0, 1} 3095 ) 3096 dID = self.definiteDecisionTarget() 3097 # Default status to use 3098 status: base.ExplorationStatus = 'explored' 3099 # Figure out whether a valid status was provided 3100 if len(eParts) > 0: 3101 assert len(eParts) == 1 3102 eArgs = get_args(base.ExplorationStatus) 3103 if eParts[0] not in eArgs: 3104 raise JournalParseError( 3105 f"Invalid explicit exploration status" 3106 f" {eParts[0]!r}. Exploration statuses" 3107 f" must be one of:\n{eArgs!r}" 3108 ) 3109 status = cast(base.ExplorationStatus, eParts[0]) 3110 # Record new status, as long as we have an explicit 3111 # status OR 'unfinishedPart' was not given. If 3112 # 'unfinishedPart' was given, also block auto updates 3113 if eTarget == 'unfinishedPart': 3114 if len(eParts) > 0: 3115 self.recordStatus(dID, status) 3116 self.recordObservationIncomplete(dID) 3117 else: 3118 self.recordStatus(dID, status) 3119 3120 elif eType == 'revert': 3121 self.checkFormat( 3122 "revert", 3123 dType, 3124 eTarget, 3125 eParts, 3126 None, 3127 None 3128 ) 3129 aspects: List[str] 3130 if len(eParts) == 0: 3131 slot = base.DEFAULT_SAVE_SLOT 3132 aspects = [] 3133 else: 3134 slot = eParts[0] 3135 aspects = eParts[1:] 3136 aspectsSet = set(aspects) 3137 if len(aspectsSet) == 0: 3138 aspectsSet = self.preferences['revertAspects'] 3139 self.recordRevert(slot, aspectsSet, decisionType=dType) 3140 3141 elif eType == 'fulfills': 3142 self.checkFormat( 3143 "fulfills", 3144 dType, 3145 eTarget, 3146 eParts, 3147 None, 3148 2 3149 ) 3150 condition = pf.parseRequirement(eParts[0]) 3151 fReq = pf.parseRequirement(eParts[1]) 3152 fulfills: Union[ 3153 base.Capability, 3154 Tuple[base.MechanismID, base.MechanismState] 3155 ] 3156 if isinstance(fReq, base.ReqCapability): 3157 fulfills = fReq.capability 3158 elif isinstance(fReq, base.ReqMechanism): 3159 mState = fReq.reqState 3160 if isinstance(fReq.mechanism, int): 3161 mID = fReq.mechanism 3162 else: 3163 graph = self.exploration.getSituation().graph 3164 mID = graph.resolveMechanism( 3165 fReq.mechanism, 3166 {self.definiteDecisionTarget()} 3167 ) 3168 fulfills = (mID, mState) 3169 else: 3170 raise JournalParseError( 3171 f"Cannot fulfill {eParts[1]!r} because it" 3172 f" doesn't specify either a capability or a" 3173 f" mechanism/state pair." 3174 ) 3175 self.recordFulfills(condition, fulfills) 3176 3177 elif eType == 'relative': 3178 self.checkFormat( 3179 "relative", 3180 dType, 3181 eTarget, 3182 eParts, 3183 (None, 'transitionPart'), 3184 (0, 1, 2) 3185 ) 3186 if ( 3187 len(eParts) == 1 3188 and eParts[0] == self.parseFormat.markerFor( 3189 'relative' 3190 ) 3191 ): 3192 self.relative() 3193 elif eTarget == 'transitionPart': 3194 self.relative(None, *eParts) 3195 else: 3196 self.relative(*eParts) 3197 3198 else: 3199 raise NotImplementedError( 3200 f"Unrecognized event type {eType!r}." 3201 ) 3202 except Exception as e: 3203 raise LocatedJournalParseError( 3204 journalText, 3205 self.parseIndices[-1], 3206 e 3207 ) 3208 finally: 3209 self.journalTexts.pop() 3210 self.parseIndices.pop()
Ingests one or more journal blocks in text format (as a multi-line string) and updates the exploration being built by this observer, as well as updating internal state.
This method can be called multiple times to process a longer journal incrementally including line-by-line.
The journalText
and parseIndex
fields will be updated during
parsing to support contextual error messages and warnings.
Example:
>>> obs = JournalObserver()
>>> oldWarn = core.WARN_OF_NAME_COLLISIONS
>>> try:
... obs.observe('''\
... S Room1::start
... zz Region
... o nope
... q power|tokens*3
... o unexplored
... o onwards
... x onwards sub_room backwards
... t backwards
... o down
...
... x down Room2::middle up
... a box
... At deactivate
... At gain tokens*1
... o left
... o right
... gt blue
...
... x right Room3::middle left
... o right
... a miniboss
... At deactivate
... At gain power
... x right - left
... o ledge
... q tall
... t left
... t left
... t up
...
... x nope secret back
... ''')
... finally:
... core.WARN_OF_NAME_COLLISIONS = oldWarn
>>> e = obs.getExploration()
>>> len(e)
13
>>> g = e.getSituation().graph
>>> len(g)
9
>>> def showDestinations(g, r):
... if isinstance(r, str):
... r = obs.parseFormat.parseDecisionSpecifier(r)
... d = g.destinationsFrom(r)
... for outgoing in sorted(d):
... req = g.getTransitionRequirement(r, outgoing)
... if req is None or req == base.ReqNothing():
... req = ''
... else:
... req = ' ' + repr(req)
... print(outgoing, g.identityOf(d[outgoing]) + req)
...
>>> "start" in g
False
>>> showDestinations(g, "Room1::start")
down 4 (Room2::middle)
nope 1 (Room1::secret) ReqAny([ReqCapability('power'), ReqTokens('tokens', 3)])
onwards 3 (Room1::sub_room)
unexplored 2 (_u.1)
>>> showDestinations(g, "Room1::secret")
back 0 (Room1::start)
>>> showDestinations(g, "Room1::sub_room")
backwards 0 (Room1::start)
>>> showDestinations(g, "Room2::middle")
box 4 (Room2::middle)
left 5 (_u.4)
right 6 (Room3::middle)
up 0 (Room1::start)
>>> g.transitionTags(4, "right")
{'blue': 1}
>>> showDestinations(g, "Room3::middle")
left 4 (Room2::middle)
miniboss 6 (Room3::middle)
right 7 (Room3::-)
>>> showDestinations(g, "Room3::-")
ledge 8 (_u.7) ReqCapability('tall')
left 6 (Room3::middle)
>>> showDestinations(g, "_u.7")
return 7 (Room3::-)
>>> e.getActiveDecisions()
{1}
>>> g.identityOf(1)
'1 (Room1::secret)'
Note that there are plenty of other annotations not shown in
this example; see DEFAULT_FORMAT
for the default mapping from
journal entry types to markers, and see JournalEntryType
for
the explanation for each entry type.
Most entries start with a marker (which includes one character for the type and possibly one for the target) followed by a single space, and everything after that is the content of the entry.
3212 def defineAlias( 3213 self, 3214 name: str, 3215 parameters: Sequence[str], 3216 commands: str 3217 ) -> None: 3218 """ 3219 Defines an alias: a block of commands that can be played back 3220 later using the 'custom' command, with parameter substitutions. 3221 3222 If an alias with the specified name already existed, it will be 3223 replaced. 3224 3225 Each of the listed parameters must be supplied when invoking the 3226 alias, and where they appear within curly braces in the commands 3227 string, they will be substituted in. Additional names starting 3228 with '_' plus an optional integer will also be substituted with 3229 unique names (see `nextUniqueName`), with the same name being 3230 used for every instance that shares the same numerical suffix 3231 within each application of the command. Substitution points must 3232 not include spaces; if an open curly brace is followed by 3233 whitesapce or where a close curly brace is proceeded by 3234 whitespace, those will be treated as normal curly braces and will 3235 not create a substitution point. 3236 3237 For example: 3238 3239 >>> o = JournalObserver() 3240 >>> o.defineAlias( 3241 ... 'hintRoom', 3242 ... ['name'], 3243 ... 'o {_5}\\nx {_5} {name} {_5}\\ngd hint\\nt {_5}' 3244 ... ) # _5 to show that the suffix doesn't matter if it's consistent 3245 >>> o.defineAlias( 3246 ... 'trade', 3247 ... ['gain', 'lose'], 3248 ... 'A { gain {gain}; lose {lose} }' 3249 ... ) # note outer curly braces 3250 >>> o.recordStart('start') 3251 >>> o.deployAlias('hintRoom', ['hint1']) 3252 >>> o.deployAlias('hintRoom', ['hint2']) 3253 >>> o.deployAlias('trade', ['flower*1', 'coin*1']) 3254 >>> e = o.getExploration() 3255 >>> e.movementAtStep(0) 3256 (None, None, 0) 3257 >>> e.movementAtStep(1) 3258 (0, '_0', 1) 3259 >>> e.movementAtStep(2) 3260 (1, '_0', 0) 3261 >>> e.movementAtStep(3) 3262 (0, '_1', 2) 3263 >>> e.movementAtStep(4) 3264 (2, '_1', 0) 3265 >>> g = e.getSituation().graph 3266 >>> len(g) 3267 3 3268 >>> g.namesListing([0, 1, 2]) 3269 ' 0 (start)\\n 1 (hint1)\\n 2 (hint2)\\n' 3270 >>> g.decisionTags('hint1') 3271 {'hint': 1} 3272 >>> g.decisionTags('hint2') 3273 {'hint': 1} 3274 >>> e.tokenCountNow('coin') 3275 -1 3276 >>> e.tokenCountNow('flower') 3277 1 3278 """ 3279 # Going to be formatted twice so {{{{ -> {{ -> { 3280 # TODO: Move this logic into deployAlias 3281 commands = re.sub(r'{(\s)', r'{{{{\1', commands) 3282 commands = re.sub(r'(\s)}', r'\1}}}}', commands) 3283 self.aliases[name] = (list(parameters), commands)
Defines an alias: a block of commands that can be played back later using the 'custom' command, with parameter substitutions.
If an alias with the specified name already existed, it will be replaced.
Each of the listed parameters must be supplied when invoking the
alias, and where they appear within curly braces in the commands
string, they will be substituted in. Additional names starting
with '_' plus an optional integer will also be substituted with
unique names (see nextUniqueName
), with the same name being
used for every instance that shares the same numerical suffix
within each application of the command. Substitution points must
not include spaces; if an open curly brace is followed by
whitesapce or where a close curly brace is proceeded by
whitespace, those will be treated as normal curly braces and will
not create a substitution point.
For example:
>>> o = JournalObserver()
>>> o.defineAlias(
... 'hintRoom',
... ['name'],
... 'o {_5}\nx {_5} {name} {_5}\ngd hint\nt {_5}'
... ) # _5 to show that the suffix doesn't matter if it's consistent
>>> o.defineAlias(
... 'trade',
... ['gain', 'lose'],
... 'A { gain {gain}; lose {lose} }'
... ) # note outer curly braces
>>> o.recordStart('start')
>>> o.deployAlias('hintRoom', ['hint1'])
>>> o.deployAlias('hintRoom', ['hint2'])
>>> o.deployAlias('trade', ['flower*1', 'coin*1'])
>>> e = o.getExploration()
>>> e.movementAtStep(0)
(None, None, 0)
>>> e.movementAtStep(1)
(0, '_0', 1)
>>> e.movementAtStep(2)
(1, '_0', 0)
>>> e.movementAtStep(3)
(0, '_1', 2)
>>> e.movementAtStep(4)
(2, '_1', 0)
>>> g = e.getSituation().graph
>>> len(g)
3
>>> g.namesListing([0, 1, 2])
' 0 (start)\n 1 (hint1)\n 2 (hint2)\n'
>>> g.decisionTags('hint1')
{'hint': 1}
>>> g.decisionTags('hint2')
{'hint': 1}
>>> e.tokenCountNow('coin')
-1
>>> e.tokenCountNow('flower')
1
3285 def deployAlias(self, name: str, arguments: Sequence[str]) -> None: 3286 """ 3287 Deploys an alias, taking its command string and substituting in 3288 the provided argument values for each of the alias' parameters, 3289 plus any unique names that it requests. Substitution happens 3290 first for named arguments and then for unique strings, so named 3291 arguments of the form '{_-n-}' where -n- is an integer will end 3292 up being substituted for unique names. Sets of curly braces that 3293 have at least one space immediately after the open brace or 3294 immediately before the closing brace will be interpreted as 3295 normal curly braces, NOT as the start/end of a substitution 3296 point. 3297 3298 There are a few automatic arguments (although these can be 3299 overridden if the alias definition uses the same argument name 3300 explicitly): 3301 - '__here__' will substitute to the ID of the current decision 3302 based on the `ObservationContext`, or will generate an error 3303 if there is none. This is the current decision at the moment 3304 the alias is deployed, NOT based on steps within the alias up 3305 to the substitution point. 3306 - '__hereName__' will substitute the name of the current 3307 decision. 3308 - '__zone__' will substitute the name of the alphabetically 3309 first level-0 zone ancestor of the current decision. 3310 - '__region__' will substitute the name of the alphabetically 3311 first level-1 zone ancestor of the current decision. 3312 - '__transition__' will substitute to the name of the current 3313 transition, or will generate an error if there is none. Note 3314 that the current transition is sometimes NOT a valid 3315 transition from the current decision, because when you take 3316 a transition, that transition's name is current but the 3317 current decision is its destination. 3318 - '__reciprocal__' will substitute to the name of the reciprocal 3319 of the current transition. 3320 - '__trBase__' will substitute to the decision from which the 3321 current transition departs. 3322 - '__trDest__' will substitute to the destination of the current 3323 transition. 3324 - '__prev__' will substitute to the ID of the primary decision in 3325 the previous exploration step, (which is NOT always the 3326 previous current decision of the `ObservationContext`, 3327 especially in relative mode). 3328 - '__across__-name-__' where '-name-' is a transition name will 3329 substitute to the decision reached by traversing that 3330 transition from the '__here__' decision. Note that the 3331 transition name used must be a valid Python identifier. 3332 3333 Raises a `JournalParseError` if the specified alias does not 3334 exist, or if the wrong number of parameters has been supplied. 3335 3336 See `defineAlias` for an example. 3337 """ 3338 # Fetch the alias 3339 alias = self.aliases.get(name) 3340 if alias is None: 3341 raise JournalParseError( 3342 f"Alias {name!r} has not been defined yet." 3343 ) 3344 paramNames, commands = alias 3345 3346 # Check arguments 3347 arguments = list(arguments) 3348 if len(arguments) != len(paramNames): 3349 raise JournalParseError( 3350 f"Alias {name!r} requires {len(paramNames)} parameters," 3351 f" but you supplied {len(arguments)}." 3352 ) 3353 3354 # Find unique names 3355 uniques = set([ 3356 match.strip('{}') 3357 for match in re.findall('{_[0-9]*}', commands) 3358 ]) 3359 3360 # Build substitution dictionary that passes through uniques 3361 firstWave = {unique: '{' + unique + '}' for unique in uniques} 3362 3363 # Fill in each non-overridden & requested auto variable: 3364 graph = self.exploration.getSituation().graph 3365 if '{__here__}' in commands and '__here__' not in firstWave: 3366 firstWave['__here__'] = self.definiteDecisionTarget() 3367 if '{__hereName__}' in commands and '__hereName__' not in firstWave: 3368 firstWave['__hereName__'] = graph.nameFor( 3369 self.definiteDecisionTarget() 3370 ) 3371 if '{__zone__}' in commands and '__zone__' not in firstWave: 3372 baseDecision = self.definiteDecisionTarget() 3373 parents = sorted( 3374 ancestor 3375 for ancestor in graph.zoneAncestors(baseDecision) 3376 if graph.zoneHierarchyLevel(ancestor) == 0 3377 ) 3378 if len(parents) == 0: 3379 raise JournalParseError( 3380 f"Used __zone__ in a macro, but the current" 3381 f" decision {graph.identityOf(baseDecision)} is not" 3382 f" in any level-0 zones." 3383 ) 3384 firstWave['__zone__'] = parents[0] 3385 if '{__region__}' in commands and '__region__' not in firstWave: 3386 baseDecision = self.definiteDecisionTarget() 3387 grandparents = sorted( 3388 ancestor 3389 for ancestor in graph.zoneAncestors(baseDecision) 3390 if graph.zoneHierarchyLevel(ancestor) == 1 3391 ) 3392 if len(grandparents) == 0: 3393 raise JournalParseError( 3394 f"Used __region__ in a macro, but the current" 3395 f" decision {graph.identityOf(baseDecision)} is not" 3396 f" in any level-1 zones." 3397 ) 3398 firstWave['__region__'] = grandparents[0] 3399 if ( 3400 '{__transition__}' in commands 3401 and '__transition__' not in firstWave 3402 ): 3403 ctxTr = self.currentTransitionTarget() 3404 if ctxTr is None: 3405 raise JournalParseError( 3406 f"Can't deploy alias {name!r} because it has a" 3407 f" __transition__ auto-slot but there is no current" 3408 f" transition at the current exploration step." 3409 ) 3410 firstWave['__transition__'] = ctxTr[1] 3411 if '{__trBase__}' in commands and '__trBase__' not in firstWave: 3412 ctxTr = self.currentTransitionTarget() 3413 if ctxTr is None: 3414 raise JournalParseError( 3415 f"Can't deploy alias {name!r} because it has a" 3416 f" __transition__ auto-slot but there is no current" 3417 f" transition at the current exploration step." 3418 ) 3419 firstWave['__trBase__'] = ctxTr[0] 3420 if '{__trDest__}' in commands and '__trDest__' not in firstWave: 3421 ctxTr = self.currentTransitionTarget() 3422 if ctxTr is None: 3423 raise JournalParseError( 3424 f"Can't deploy alias {name!r} because it has a" 3425 f" __transition__ auto-slot but there is no current" 3426 f" transition at the current exploration step." 3427 ) 3428 firstWave['__trDest__'] = graph.getDestination(*ctxTr) 3429 if ( 3430 '{__reciprocal__}' in commands 3431 and '__reciprocal__' not in firstWave 3432 ): 3433 ctxTr = self.currentTransitionTarget() 3434 if ctxTr is None: 3435 raise JournalParseError( 3436 f"Can't deploy alias {name!r} because it has a" 3437 f" __transition__ auto-slot but there is no current" 3438 f" transition at the current exploration step." 3439 ) 3440 firstWave['__reciprocal__'] = graph.getReciprocal(*ctxTr) 3441 if '{__prev__}' in commands and '__prev__' not in firstWave: 3442 try: 3443 prevPrimary = self.exploration.primaryDecision(-2) 3444 except IndexError: 3445 raise JournalParseError( 3446 f"Can't deploy alias {name!r} because it has a" 3447 f" __prev__ auto-slot but there is no previous" 3448 f" exploration step." 3449 ) 3450 if prevPrimary is None: 3451 raise JournalParseError( 3452 f"Can't deploy alias {name!r} because it has a" 3453 f" __prev__ auto-slot but there is no primary" 3454 f" decision for the previous exploration step." 3455 ) 3456 firstWave['__prev__'] = prevPrimary 3457 3458 here = self.currentDecisionTarget() 3459 for match in re.findall(r'{__across__[^ ]\+__}', commands): 3460 if here is None: 3461 raise JournalParseError( 3462 f"Can't deploy alias {name!r} because it has an" 3463 f" __across__ auto-slot but there is no current" 3464 f" decision." 3465 ) 3466 transition = match[11:-3] 3467 dest = graph.getDestination(here, transition) 3468 firstWave[f'__across__{transition}__'] = dest 3469 firstWave.update({ 3470 param: value 3471 for (param, value) in zip(paramNames, arguments) 3472 }) 3473 3474 # Substitute parameter values 3475 commands = commands.format(**firstWave) 3476 3477 uniques = set([ 3478 match.strip('{}') 3479 for match in re.findall('{_[0-9]*}', commands) 3480 ]) 3481 3482 # Substitute for remaining unique names 3483 uniqueValues = { 3484 unique: self.nextUniqueName() 3485 for unique in sorted(uniques) # sort for stability 3486 } 3487 commands = commands.format(**uniqueValues) 3488 3489 # Now run the commands 3490 self.observe(commands)
Deploys an alias, taking its command string and substituting in the provided argument values for each of the alias' parameters, plus any unique names that it requests. Substitution happens first for named arguments and then for unique strings, so named arguments of the form '{_-n-}' where -n- is an integer will end up being substituted for unique names. Sets of curly braces that have at least one space immediately after the open brace or immediately before the closing brace will be interpreted as normal curly braces, NOT as the start/end of a substitution point.
There are a few automatic arguments (although these can be overridden if the alias definition uses the same argument name explicitly):
- '__here__' will substitute to the ID of the current decision
based on the
ObservationContext
, or will generate an error if there is none. This is the current decision at the moment the alias is deployed, NOT based on steps within the alias up to the substitution point. - '__hereName__' will substitute the name of the current decision.
- '__zone__' will substitute the name of the alphabetically first level-0 zone ancestor of the current decision.
- '__region__' will substitute the name of the alphabetically first level-1 zone ancestor of the current decision.
- '__transition__' will substitute to the name of the current transition, or will generate an error if there is none. Note that the current transition is sometimes NOT a valid transition from the current decision, because when you take a transition, that transition's name is current but the current decision is its destination.
- '__reciprocal__' will substitute to the name of the reciprocal of the current transition.
- '__trBase__' will substitute to the decision from which the current transition departs.
- '__trDest__' will substitute to the destination of the current transition.
- '__prev__' will substitute to the ID of the primary decision in
the previous exploration step, (which is NOT always the
previous current decision of the
ObservationContext
, especially in relative mode). - '__across__-name-__' where '-name-' is a transition name will substitute to the decision reached by traversing that transition from the '__here__' decision. Note that the transition name used must be a valid Python identifier.
Raises a JournalParseError
if the specified alias does not
exist, or if the wrong number of parameters has been supplied.
See defineAlias
for an example.
3492 def doDebug(self, action: DebugAction, arg: str = "") -> None: 3493 """ 3494 Prints out a debugging message to stderr. Useful for figuring 3495 out parsing errors. See also `DebugAction` and 3496 `JournalEntryType. Certain actions allow an extra argument. The 3497 action will be one of: 3498 - 'here': prints the ID and name of the current decision, or 3499 `None` if there isn't one. 3500 - 'transition': prints the name of the current transition, or `None` 3501 if there isn't one. 3502 - 'destinations': prints the ID and name of the current decision, 3503 followed by the names of each outgoing transition and their 3504 destinations. Includes any requirements the transitions have. 3505 If an extra argument is supplied, looks up that decision and 3506 prints destinations from there. 3507 - 'steps': prints out the number of steps in the current exploration, 3508 plus the number since the most recent use of 'steps'. 3509 - 'decisions': prints out the number of decisions in the current 3510 graph, plus the number added/removed since the most recent use of 3511 'decisions'. 3512 - 'active': prints out the names listing of all currently active 3513 decisions. 3514 - 'primary': prints out the identity of the current primary 3515 decision, or None if there is none. 3516 - 'saved': prints out the primary decision for the state saved in 3517 the default save slot, or for a specific save slot if a 3518 second argument is given. 3519 - 'inventory': Displays all current capabilities, tokens, and 3520 skills. 3521 - 'mechanisms': Displays all current mechanisms and their states. 3522 - 'equivalences': Displays all current equivalences, along with 3523 whether or not they're active. 3524 """ 3525 graph = self.exploration.getSituation().graph 3526 if arg != '' and action not in ('destinations', 'saved'): 3527 raise JournalParseError( 3528 f"Invalid debug command {action!r} with arg {arg!r}:" 3529 f" Only 'destination' and 'saved' actions may include a" 3530 f" second argument." 3531 ) 3532 if action == "here": 3533 dt = self.currentDecisionTarget() 3534 print( 3535 f"Current decision is: {graph.identityOf(dt)}", 3536 file=sys.stderr 3537 ) 3538 elif action == "transition": 3539 tTarget = self.currentTransitionTarget() 3540 if tTarget is None: 3541 print("Current transition is: None", file=sys.stderr) 3542 else: 3543 tDecision, tTransition = tTarget 3544 print( 3545 ( 3546 f"Current transition is {tTransition!r} from" 3547 f" {graph.identityOf(tDecision)}." 3548 ), 3549 file=sys.stderr 3550 ) 3551 elif action == "destinations": 3552 if arg == "": 3553 here = self.currentDecisionTarget() 3554 adjective = "current" 3555 if here is None: 3556 print("There is no current decision.", file=sys.stderr) 3557 else: 3558 adjective = "target" 3559 dHint = None 3560 zHint = None 3561 tSpec = self.decisionTargetSpecifier() 3562 if tSpec is not None: 3563 dHint = tSpec.domain 3564 zHint = tSpec.zone 3565 here = self.exploration.getSituation().graph.getDecision( 3566 self.parseFormat.parseDecisionSpecifier(arg), 3567 zoneHint=zHint, 3568 domainHint=dHint, 3569 ) 3570 if here is None: 3571 print("Decision {arg!r} was not found.", file=sys.stderr) 3572 3573 if here is not None: 3574 dests = graph.destinationsFrom(here) 3575 outgoing = { 3576 route: dests[route] 3577 for route in dests 3578 if dests[route] != here 3579 } 3580 actions = { 3581 route: dests[route] 3582 for route in dests 3583 if dests[route] == here 3584 } 3585 print( 3586 f"The {adjective} decision is: {graph.identityOf(here)}", 3587 file=sys.stderr 3588 ) 3589 if len(outgoing) == 0: 3590 print( 3591 ( 3592 "There are no outgoing transitions at this" 3593 " decision." 3594 ), 3595 file=sys.stderr 3596 ) 3597 else: 3598 print( 3599 ( 3600 f"There are {len(outgoing)} outgoing" 3601 f" transition(s):" 3602 ), 3603 file=sys.stderr 3604 ) 3605 for transition in outgoing: 3606 destination = outgoing[transition] 3607 req = graph.getTransitionRequirement( 3608 here, 3609 transition 3610 ) 3611 rstring = '' 3612 if req != base.ReqNothing(): 3613 rstring = f" (requires {req})" 3614 print( 3615 ( 3616 f" {transition!r} ->" 3617 f" {graph.identityOf(destination)}{rstring}" 3618 ), 3619 file=sys.stderr 3620 ) 3621 3622 if len(actions) > 0: 3623 print( 3624 f"There are {len(actions)} actions:", 3625 file=sys.stderr 3626 ) 3627 for oneAction in actions: 3628 req = graph.getTransitionRequirement( 3629 here, 3630 oneAction 3631 ) 3632 rstring = '' 3633 if req != base.ReqNothing(): 3634 rstring = f" (requires {req})" 3635 print( 3636 f" {oneAction!r}{rstring}", 3637 file=sys.stderr 3638 ) 3639 3640 elif action == "steps": 3641 steps = len(self.getExploration()) 3642 if self.prevSteps is not None: 3643 elapsed = steps - cast(int, self.prevSteps) 3644 print( 3645 ( 3646 f"There are {steps} steps in the current" 3647 f" exploration (which is {elapsed} more than" 3648 f" there were at the previous check)." 3649 ), 3650 file=sys.stderr 3651 ) 3652 else: 3653 print( 3654 ( 3655 f"There are {steps} steps in the current" 3656 f" exploration." 3657 ), 3658 file=sys.stderr 3659 ) 3660 self.prevSteps = steps 3661 3662 elif action == "decisions": 3663 count = len(self.getExploration().getSituation().graph) 3664 if self.prevDecisions is not None: 3665 elapsed = count - self.prevDecisions 3666 print( 3667 ( 3668 f"There are {count} decisions in the current" 3669 f" graph (which is {elapsed} more than there" 3670 f" were at the previous check)." 3671 ), 3672 file=sys.stderr 3673 ) 3674 else: 3675 print( 3676 ( 3677 f"There are {count} decisions in the current" 3678 f" graph." 3679 ), 3680 file=sys.stderr 3681 ) 3682 self.prevDecisions = count 3683 elif action == "active": 3684 active = self.exploration.getActiveDecisions() 3685 now = self.exploration.getSituation() 3686 print( 3687 "Active decisions:\n", 3688 now.graph.namesListing(active), 3689 file=sys.stderr 3690 ) 3691 elif action == "primary": 3692 e = self.exploration 3693 primary = e.primaryDecision() 3694 if primary is None: 3695 pr = "None" 3696 else: 3697 pr = e.getSituation().graph.identityOf(primary) 3698 print(f"Primary decision: {pr}", file=sys.stderr) 3699 elif action == "saved": 3700 now = self.exploration.getSituation() 3701 slot = base.DEFAULT_SAVE_SLOT 3702 if arg != "": 3703 slot = arg 3704 saved = now.saves.get(slot) 3705 if saved is None: 3706 print(f"Slot {slot!r} has no saved data.", file=sys.stderr) 3707 else: 3708 savedGraph, savedState = saved 3709 savedPrimary = savedGraph.identityOf( 3710 savedState['primaryDecision'] 3711 ) 3712 print(f"Saved at decision: {savedPrimary}", file=sys.stderr) 3713 elif action == "inventory": 3714 now = self.exploration.getSituation() 3715 commonCap = now.state['common']['capabilities'] 3716 activeCap = now.state['contexts'][now.state['activeContext']][ 3717 'capabilities' 3718 ] 3719 merged = base.mergeCapabilitySets(commonCap, activeCap) 3720 capCount = len(merged['capabilities']) 3721 tokCount = len(merged['tokens']) 3722 skillCount = len(merged['skills']) 3723 print( 3724 ( 3725 f"{capCount} capability/ies, {tokCount} token type(s)," 3726 f" and {skillCount} skill(s)" 3727 ), 3728 file=sys.stderr 3729 ) 3730 if capCount > 0: 3731 print("Capabilities (alphabetical order):", file=sys.stderr) 3732 for cap in sorted(merged['capabilities']): 3733 print(f" {cap!r}", file=sys.stderr) 3734 if tokCount > 0: 3735 print("Tokens (alphabetical order):", file=sys.stderr) 3736 for tok in sorted(merged['tokens']): 3737 print( 3738 f" {tok!r}: {merged['tokens'][tok]}", 3739 file=sys.stderr 3740 ) 3741 if skillCount > 0: 3742 print("Skill levels (alphabetical order):", file=sys.stderr) 3743 for skill in sorted(merged['skills']): 3744 print( 3745 f" {skill!r}: {merged['skills'][skill]}", 3746 file=sys.stderr 3747 ) 3748 elif action == "mechanisms": 3749 now = self.exploration.getSituation() 3750 grpah = now.graph 3751 mechs = now.state['mechanisms'] 3752 inGraph = set(graph.mechanisms) - set(mechs) 3753 print( 3754 ( 3755 f"{len(mechs)} mechanism(s) in known states;" 3756 f" {len(inGraph)} additional mechanism(s) in the" 3757 f" default state" 3758 ), 3759 file=sys.stderr 3760 ) 3761 if len(mechs) > 0: 3762 print("Mechanism(s) in known state(s):", file=sys.stderr) 3763 for mID in sorted(mechs): 3764 mState = mechs[mID] 3765 whereID, mName = graph.mechanisms[mID] 3766 if whereID is None: 3767 whereStr = " (global)" 3768 else: 3769 domain = graph.domainFor(whereID) 3770 whereStr = f" at {graph.identityOf(whereID)}" 3771 print( 3772 f" {mName}:{mState!r} - {mID}{whereStr}", 3773 file=sys.stderr 3774 ) 3775 if len(inGraph) > 0: 3776 print("Mechanism(s) in the default state:", file=sys.stderr) 3777 for mID in sorted(inGraph): 3778 whereID, mName = graph.mechanisms[mID] 3779 if whereID is None: 3780 whereStr = " (global)" 3781 else: 3782 domain = graph.domainFor(whereID) 3783 whereStr = f" at {graph.identityOf(whereID)}" 3784 print(f" {mID} - {mName}){whereStr}", file=sys.stderr) 3785 elif action == "equivalences": 3786 now = self.exploration.getSituation() 3787 eqDict = now.graph.equivalences 3788 if len(eqDict) > 0: 3789 print(f"{len(eqDict)} equivalences:", file=sys.stderr) 3790 for hasEq in eqDict: 3791 if isinstance(hasEq, tuple): 3792 assert len(hasEq) == 2 3793 assert isinstance(hasEq[0], base.MechanismID) 3794 assert isinstance(hasEq[1], base.MechanismState) 3795 mID, mState = hasEq 3796 mDetails = now.graph.mechanismDetails(mID) 3797 assert mDetails is not None 3798 mWhere, mName = mDetails 3799 if mWhere is None: 3800 whereStr = " (global)" 3801 else: 3802 whereStr = f" at {graph.identityOf(mWhere)}" 3803 eqStr = f"{mName}:{mState!r} - {mID}{whereStr}" 3804 else: 3805 assert isinstance(hasEq, base.Capability) 3806 eqStr = hasEq 3807 eqSet = eqDict[hasEq] 3808 print( 3809 f" {eqStr} has {len(eqSet)} equivalence(s):", 3810 file=sys.stderr 3811 ) 3812 for eqReq in eqDict[hasEq]: 3813 print(f" {eqReq}", file=sys.stderr) 3814 else: 3815 print( 3816 "There are no equivalences right now.", 3817 file=sys.stderr 3818 ) 3819 else: 3820 raise JournalParseError( 3821 f"Invalid debug command: {action!r}" 3822 )
Prints out a debugging message to stderr. Useful for figuring
out parsing errors. See also DebugAction
and
`JournalEntryType. Certain actions allow an extra argument. The
action will be one of:
- 'here': prints the ID and name of the current decision, or
None
if there isn't one. - 'transition': prints the name of the current transition, or
None
if there isn't one. - 'destinations': prints the ID and name of the current decision, followed by the names of each outgoing transition and their destinations. Includes any requirements the transitions have. If an extra argument is supplied, looks up that decision and prints destinations from there.
- 'steps': prints out the number of steps in the current exploration, plus the number since the most recent use of 'steps'.
- 'decisions': prints out the number of decisions in the current graph, plus the number added/removed since the most recent use of 'decisions'.
- 'active': prints out the names listing of all currently active decisions.
- 'primary': prints out the identity of the current primary decision, or None if there is none.
- 'saved': prints out the primary decision for the state saved in the default save slot, or for a specific save slot if a second argument is given.
- 'inventory': Displays all current capabilities, tokens, and skills.
- 'mechanisms': Displays all current mechanisms and their states.
- 'equivalences': Displays all current equivalences, along with whether or not they're active.
3824 def recordStart( 3825 self, 3826 where: Union[base.DecisionName, base.DecisionSpecifier], 3827 decisionType: base.DecisionType = 'imposed' 3828 ) -> None: 3829 """ 3830 Records the start of the exploration. Use only once in each new 3831 domain, as the very first action in that domain (possibly after 3832 some zone declarations). The contextual domain is used if the 3833 given `base.DecisionSpecifier` doesn't include a domain. 3834 3835 To create new decision points that are disconnected from the rest 3836 of the graph that aren't the first in their domain, use the 3837 `relative` method followed by `recordWarp`. 3838 3839 The default 'imposed' decision type can be overridden for the 3840 action that this generates. 3841 """ 3842 if self.inRelativeMode: 3843 raise JournalParseError( 3844 "Can't start the exploration in relative mode." 3845 ) 3846 3847 whereSpec: Union[base.DecisionID, base.DecisionSpecifier] 3848 if isinstance(where, base.DecisionName): 3849 whereSpec = self.parseFormat.parseDecisionSpecifier(where) 3850 if isinstance(whereSpec, base.DecisionID): 3851 raise JournalParseError( 3852 f"Can't use a number for a decision name. Got:" 3853 f" {where!r}" 3854 ) 3855 else: 3856 whereSpec = where 3857 3858 if whereSpec.domain is None: 3859 whereSpec = base.DecisionSpecifier( 3860 domain=self.context['domain'], 3861 zone=whereSpec.zone, 3862 name=whereSpec.name 3863 ) 3864 self.context['decision'] = self.exploration.start( 3865 whereSpec, 3866 decisionType=decisionType 3867 )
Records the start of the exploration. Use only once in each new
domain, as the very first action in that domain (possibly after
some zone declarations). The contextual domain is used if the
given base.DecisionSpecifier
doesn't include a domain.
To create new decision points that are disconnected from the rest
of the graph that aren't the first in their domain, use the
relative
method followed by recordWarp
.
The default 'imposed' decision type can be overridden for the action that this generates.
3869 def recordObserveAction(self, name: base.Transition) -> None: 3870 """ 3871 Records the observation of an action at the current decision, 3872 which has the given name. 3873 """ 3874 here = self.definiteDecisionTarget() 3875 self.exploration.getSituation().graph.addAction(here, name) 3876 self.context['transition'] = (here, name)
Records the observation of an action at the current decision, which has the given name.
3878 def recordObserve( 3879 self, 3880 name: base.Transition, 3881 destination: Optional[base.AnyDecisionSpecifier] = None, 3882 reciprocal: Optional[base.Transition] = None 3883 ) -> None: 3884 """ 3885 Records the observation of a new option at the current decision. 3886 3887 If two or three arguments are given, the destination is still 3888 marked as unexplored, but is given a name (with two arguments) 3889 and the reciprocal transition is named (with three arguments). 3890 3891 When a name or decision specifier is used for the destination, 3892 the domain and/or level-0 zone of the current decision are 3893 filled in if the specifier is a name or doesn't have domain 3894 and/or zone info. The first alphabetical level-0 zone is used if 3895 the current decision is in more than one. 3896 """ 3897 here = self.definiteDecisionTarget() 3898 3899 # Our observation matches `DiscreteExploration.observe` args 3900 obs: Union[ 3901 Tuple[base.Transition], 3902 Tuple[base.Transition, base.AnyDecisionSpecifier], 3903 Tuple[ 3904 base.Transition, 3905 base.AnyDecisionSpecifier, 3906 base.Transition 3907 ] 3908 ] 3909 3910 # If we have a destination, parse it as a decision specifier 3911 # (might be an ID) 3912 if isinstance(destination, str): 3913 destination = self.parseFormat.parseDecisionSpecifier( 3914 destination 3915 ) 3916 3917 # If we started with a name or some other kind of decision 3918 # specifier, replace missing domain and/or zone info with info 3919 # from the current decision. 3920 if isinstance(destination, base.DecisionSpecifier): 3921 destination = base.spliceDecisionSpecifiers( 3922 destination, 3923 self.decisionTargetSpecifier() 3924 ) 3925 # TODO: This is kinda janky because it only uses 1 zone, 3926 # whereas explore puts the new decision in all of them. 3927 3928 # Set up our observation argument 3929 if destination is not None: 3930 if reciprocal is not None: 3931 obs = (name, destination, reciprocal) 3932 else: 3933 obs = (name, destination) 3934 elif reciprocal is not None: 3935 # TODO: Allow this? (make the destination generic) 3936 raise JournalParseError( 3937 "You may not specify a reciprocal name without" 3938 " specifying a destination." 3939 ) 3940 else: 3941 obs = (name,) 3942 3943 self.exploration.observe(here, *obs) 3944 self.context['transition'] = (here, name)
Records the observation of a new option at the current decision.
If two or three arguments are given, the destination is still marked as unexplored, but is given a name (with two arguments) and the reciprocal transition is named (with three arguments).
When a name or decision specifier is used for the destination, the domain and/or level-0 zone of the current decision are filled in if the specifier is a name or doesn't have domain and/or zone info. The first alphabetical level-0 zone is used if the current decision is in more than one.
3946 def recordObservationIncomplete( 3947 self, 3948 decision: base.AnyDecisionSpecifier 3949 ): 3950 """ 3951 Marks a particular decision as being incompletely-observed. 3952 Normally whenever we leave a decision, we set its exploration 3953 status as 'explored' under the assumption that before moving on 3954 to another decision, we'll note down all of the options at this 3955 one first. Usually, to indicate further exploration 3956 possibilities in a room, you can include a transition, and you 3957 could even use `recordUnify` later to indicate that what seemed 3958 like a junction between two decisions really wasn't, and they 3959 should be merged. But in rare cases, it makes sense instead to 3960 indicate before you leave a decision that you expect to see more 3961 options there later, but you can't or won't observe them now. 3962 Once `recordObservationIncomplete` has been called, the default 3963 mechanism will never upgrade the decision to 'explored', and you 3964 will usually want to eventually call `recordStatus` to 3965 explicitly do that (which also removes it from the 3966 `dontFinalize` set that this method puts it in). 3967 3968 When called on a decision which already has exploration status 3969 'explored', this also sets the exploration status back to 3970 'exploring'. 3971 """ 3972 e = self.exploration 3973 dID = e.getSituation().graph.resolveDecision(decision) 3974 if e.getExplorationStatus(dID) == 'explored': 3975 e.setExplorationStatus(dID, 'exploring') 3976 self.dontFinalize.add(dID)
Marks a particular decision as being incompletely-observed.
Normally whenever we leave a decision, we set its exploration
status as 'explored' under the assumption that before moving on
to another decision, we'll note down all of the options at this
one first. Usually, to indicate further exploration
possibilities in a room, you can include a transition, and you
could even use recordUnify
later to indicate that what seemed
like a junction between two decisions really wasn't, and they
should be merged. But in rare cases, it makes sense instead to
indicate before you leave a decision that you expect to see more
options there later, but you can't or won't observe them now.
Once recordObservationIncomplete
has been called, the default
mechanism will never upgrade the decision to 'explored', and you
will usually want to eventually call recordStatus
to
explicitly do that (which also removes it from the
dontFinalize
set that this method puts it in).
When called on a decision which already has exploration status 'explored', this also sets the exploration status back to 'exploring'.
3978 def recordStatus( 3979 self, 3980 decision: base.AnyDecisionSpecifier, 3981 status: base.ExplorationStatus = 'explored' 3982 ): 3983 """ 3984 Explicitly records that a particular decision has the specified 3985 exploration status (default 'explored' meaning we think we've 3986 seen everything there). This helps analysts look for unexpected 3987 connections. 3988 3989 Note that normally, exploration statuses will be updated 3990 automatically whenever a decision is first observed (status 3991 'noticed'), first visited (status 'exploring') and first left 3992 behind (status 'explored'). However, using 3993 `recordObservationIncomplete` can prevent the automatic 3994 'explored' update. 3995 3996 This method also removes a decision's `dontFinalize` entry, 3997 although it's probably no longer relevant in any case. 3998 TODO: Still this? 3999 4000 A basic example: 4001 4002 >>> obs = JournalObserver() 4003 >>> e = obs.getExploration() 4004 >>> obs.recordStart('A') 4005 >>> e.getExplorationStatus('A', 0) 4006 'unknown' 4007 >>> e.getExplorationStatus('A', 1) 4008 'exploring' 4009 >>> obs.recordStatus('A') 4010 >>> e.getExplorationStatus('A', 1) 4011 'explored' 4012 >>> obs.recordStatus('A', 'hypothesized') 4013 >>> e.getExplorationStatus('A', 1) 4014 'hypothesized' 4015 4016 An example of usage in journal format: 4017 4018 >>> obs = JournalObserver() 4019 >>> obs.observe(''' 4020 ... # step 0 4021 ... S A # step 1 4022 ... x right B left # step 2 4023 ... ... 4024 ... x right C left # step 3 4025 ... t left # back to B; step 4 4026 ... o up 4027 ... . # now we think we've found all options 4028 ... x up D down # step 5 4029 ... t down # back to B again; step 6 4030 ... x down E up # surprise extra option; step 7 4031 ... w # step 8 4032 ... . hypothesized # explicit value 4033 ... t up # auto-updates to 'explored'; step 9 4034 ... ''') 4035 >>> e = obs.getExploration() 4036 >>> len(e) 4037 10 4038 >>> e.getExplorationStatus('A', 1) 4039 'exploring' 4040 >>> e.getExplorationStatus('A', 2) 4041 'explored' 4042 >>> e.getExplorationStatus('B', 1) 4043 Traceback (most recent call last): 4044 ... 4045 exploration.core.MissingDecisionError... 4046 >>> e.getExplorationStatus(1, 1) # the unknown node is created 4047 'unknown' 4048 >>> e.getExplorationStatus('B', 2) 4049 'exploring' 4050 >>> e.getExplorationStatus('B', 3) # not 'explored' yet 4051 'exploring' 4052 >>> e.getExplorationStatus('B', 4) # now explored 4053 'explored' 4054 >>> e.getExplorationStatus('B', 6) # still explored 4055 'explored' 4056 >>> e.getExplorationStatus('E', 7) # initial 4057 'exploring' 4058 >>> e.getExplorationStatus('E', 8) # explicit 4059 'hypothesized' 4060 >>> e.getExplorationStatus('E', 9) # auto-update on leave 4061 'explored' 4062 >>> g2 = e.getSituation(2).graph 4063 >>> g4 = e.getSituation(4).graph 4064 >>> g7 = e.getSituation(7).graph 4065 >>> g2.destinationsFrom('B') 4066 {'left': 0, 'right': 2} 4067 >>> g4.destinationsFrom('B') 4068 {'left': 0, 'right': 2, 'up': 3} 4069 >>> g7.destinationsFrom('B') 4070 {'left': 0, 'right': 2, 'up': 3, 'down': 4} 4071 """ 4072 e = self.exploration 4073 dID = e.getSituation().graph.resolveDecision(decision) 4074 if dID in self.dontFinalize: 4075 self.dontFinalize.remove(dID) 4076 e.setExplorationStatus(decision, status)
Explicitly records that a particular decision has the specified exploration status (default 'explored' meaning we think we've seen everything there). This helps analysts look for unexpected connections.
Note that normally, exploration statuses will be updated
automatically whenever a decision is first observed (status
'noticed'), first visited (status 'exploring') and first left
behind (status 'explored'). However, using
recordObservationIncomplete
can prevent the automatic
'explored' update.
This method also removes a decision's dontFinalize
entry,
although it's probably no longer relevant in any case.
TODO: Still this?
A basic example:
>>> obs = JournalObserver()
>>> e = obs.getExploration()
>>> obs.recordStart('A')
>>> e.getExplorationStatus('A', 0)
'unknown'
>>> e.getExplorationStatus('A', 1)
'exploring'
>>> obs.recordStatus('A')
>>> e.getExplorationStatus('A', 1)
'explored'
>>> obs.recordStatus('A', 'hypothesized')
>>> e.getExplorationStatus('A', 1)
'hypothesized'
An example of usage in journal format:
>>> obs = JournalObserver()
>>> obs.observe('''
... # step 0
... S A # step 1
... x right B left # step 2
... ...
... x right C left # step 3
... t left # back to B; step 4
... o up
... . # now we think we've found all options
... x up D down # step 5
... t down # back to B again; step 6
... x down E up # surprise extra option; step 7
... w # step 8
... . hypothesized # explicit value
... t up # auto-updates to 'explored'; step 9
... ''')
>>> e = obs.getExploration()
>>> len(e)
10
>>> e.getExplorationStatus('A', 1)
'exploring'
>>> e.getExplorationStatus('A', 2)
'explored'
>>> e.getExplorationStatus('B', 1)
Traceback (most recent call last):
...
exploration.core.MissingDecisionError...
>>> e.getExplorationStatus(1, 1) # the unknown node is created
'unknown'
>>> e.getExplorationStatus('B', 2)
'exploring'
>>> e.getExplorationStatus('B', 3) # not 'explored' yet
'exploring'
>>> e.getExplorationStatus('B', 4) # now explored
'explored'
>>> e.getExplorationStatus('B', 6) # still explored
'explored'
>>> e.getExplorationStatus('E', 7) # initial
'exploring'
>>> e.getExplorationStatus('E', 8) # explicit
'hypothesized'
>>> e.getExplorationStatus('E', 9) # auto-update on leave
'explored'
>>> g2 = e.getSituation(2).graph
>>> g4 = e.getSituation(4).graph
>>> g7 = e.getSituation(7).graph
>>> g2.destinationsFrom('B')
{'left': 0, 'right': 2}
>>> g4.destinationsFrom('B')
{'left': 0, 'right': 2, 'up': 3}
>>> g7.destinationsFrom('B')
{'left': 0, 'right': 2, 'up': 3, 'down': 4}
4078 def autoFinalizeExplorationStatuses(self): 4079 """ 4080 Looks at the set of nodes that were active in the previous 4081 exploration step but which are no longer active in this one, and 4082 sets their exploration statuses to 'explored' to indicate that 4083 we believe we've already at least observed all of their outgoing 4084 transitions. 4085 4086 Skips finalization for any decisions in our `dontFinalize` set 4087 (see `recordObservationIncomplete`). 4088 """ 4089 oldActive = self.exploration.getActiveDecisions(-2) 4090 newAcive = self.exploration.getActiveDecisions() 4091 for leftBehind in (oldActive - newAcive) - self.dontFinalize: 4092 self.exploration.setExplorationStatus( 4093 leftBehind, 4094 'explored' 4095 )
Looks at the set of nodes that were active in the previous exploration step but which are no longer active in this one, and sets their exploration statuses to 'explored' to indicate that we believe we've already at least observed all of their outgoing transitions.
Skips finalization for any decisions in our dontFinalize
set
(see recordObservationIncomplete
).
4097 def recordExplore( 4098 self, 4099 transition: base.AnyTransition, 4100 destination: Optional[base.AnyDecisionSpecifier] = None, 4101 reciprocal: Optional[base.Transition] = None, 4102 decisionType: base.DecisionType = 'active' 4103 ) -> None: 4104 """ 4105 Records the exploration of a transition which leads to a 4106 specific destination (possibly with outcomes specified for 4107 challenges that are part of that transition's consequences). The 4108 name of the reciprocal transition may also be specified, as can 4109 a non-default decision type (see `base.DecisionType`). Creates 4110 the transition if it needs to. 4111 4112 Note that if the destination specifier has no zone or domain 4113 information, even if a decision with that name already exists, if 4114 the current decision is in a level-0 zone and the existing 4115 decision is not in the same zone, a new decision with that name 4116 in the current level-0 zone will be created (otherwise, it would 4117 be an error to use 'explore' to connect to an already-visited 4118 decision). 4119 4120 If no destination name is specified, the destination node must 4121 already exist and the name of the destination must not begin 4122 with '_u.' otherwise a `JournalParseError` will be generated. 4123 4124 Sets the current transition to the transition taken. 4125 4126 Calls `autoFinalizeExplorationStatuses` to upgrade exploration 4127 statuses for no-longer-active nodes to 'explored'. 4128 4129 In relative mode, this makes all the same changes to the graph, 4130 without adding a new exploration step, applying transition 4131 effects, or changing exploration statuses. 4132 """ 4133 here = self.definiteDecisionTarget() 4134 4135 transitionName, outcomes = base.nameAndOutcomes(transition) 4136 4137 # Create transition if it doesn't already exist 4138 now = self.exploration.getSituation() 4139 graph = now.graph 4140 leadsTo = graph.getDestination(here, transitionName) 4141 4142 if isinstance(destination, str): 4143 destination = self.parseFormat.parseDecisionSpecifier( 4144 destination 4145 ) 4146 4147 newDomain: Optional[base.Domain] 4148 newZone: Union[ 4149 base.Zone, 4150 type[base.DefaultZone], 4151 None 4152 ] = base.DefaultZone 4153 newName: Optional[base.DecisionName] 4154 4155 # if a destination is specified, we need to check that it's not 4156 # an already-existing decision 4157 connectBack: bool = False # are we connecting to a known decision? 4158 if destination is not None: 4159 # If it's not an ID, splice in current node info: 4160 if isinstance(destination, base.DecisionName): 4161 destination = base.DecisionSpecifier(None, None, destination) 4162 if isinstance(destination, base.DecisionSpecifier): 4163 destination = base.spliceDecisionSpecifiers( 4164 destination, 4165 self.decisionTargetSpecifier() 4166 ) 4167 exists = graph.getDecision(destination) 4168 # if the specified decision doesn't exist; great. We'll 4169 # create it below 4170 if exists is not None: 4171 # If it does exist, we may have a problem. 'return' must 4172 # be used instead of 'explore' to connect to an existing 4173 # visited decision. But let's see if we really have a 4174 # conflict? 4175 otherZones = set( 4176 z 4177 for z in graph.zoneParents(exists) 4178 if graph.zoneHierarchyLevel(z) == 0 4179 ) 4180 currentZones = set( 4181 z 4182 for z in graph.zoneParents(here) 4183 if graph.zoneHierarchyLevel(z) == 0 4184 ) 4185 if ( 4186 len(otherZones & currentZones) != 0 4187 or ( 4188 len(otherZones) == 0 4189 and len(currentZones) == 0 4190 ) 4191 ): 4192 if self.exploration.hasBeenVisited(exists): 4193 # A decision by this name exists and shares at 4194 # least one level-0 zone with the current 4195 # decision. That means that 'return' should have 4196 # been used. 4197 raise JournalParseError( 4198 f"Destiation {destination} is invalid" 4199 f" because that decision has already been" 4200 f" visited in the current zone. Use" 4201 f" 'return' to record a new connection to" 4202 f" an already-visisted decision." 4203 ) 4204 else: 4205 connectBack = True 4206 else: 4207 connectBack = True 4208 # Otherwise, we can continue; the DefaultZone setting 4209 # already in place will prevail below 4210 4211 # Figure out domain & zone info for new destination 4212 if isinstance(destination, base.DecisionSpecifier): 4213 # Use current decision's domain by default 4214 if destination.domain is not None: 4215 newDomain = destination.domain 4216 else: 4217 newDomain = graph.domainFor(here) 4218 4219 # Use specified zone if there is one, else leave it as 4220 # DefaultZone to inherit zone(s) from the current decision. 4221 if destination.zone is not None: 4222 newZone = destination.zone 4223 4224 newName = destination.name 4225 # TODO: Some way to specify non-zone placement in explore? 4226 4227 elif isinstance(destination, base.DecisionID): 4228 if connectBack: 4229 newDomain = graph.domainFor(here) 4230 newZone = None 4231 newName = None 4232 else: 4233 raise JournalParseError( 4234 f"You cannot use a decision ID when specifying a" 4235 f" new name for an exploration destination (got:" 4236 f" {repr(destination)})" 4237 ) 4238 4239 elif isinstance(destination, base.DecisionName): 4240 newDomain = None 4241 newZone = base.DefaultZone 4242 newName = destination 4243 4244 else: # must be None 4245 assert destination is None 4246 newDomain = None 4247 newZone = base.DefaultZone 4248 newName = None 4249 4250 if leadsTo is None: 4251 if newName is None and not connectBack: 4252 raise JournalParseError( 4253 f"Transition {transition!r} at decision" 4254 f" {graph.identityOf(here)} does not already exist," 4255 f" so a destination name must be provided." 4256 ) 4257 else: 4258 graph.addUnexploredEdge( 4259 here, 4260 transitionName, 4261 toDomain=newDomain # None is the default anyways 4262 ) 4263 # Zone info only added in next step 4264 elif newName is None: 4265 # TODO: Generalize this... ? 4266 currentName = graph.nameFor(leadsTo) 4267 if currentName.startswith('_u.'): 4268 raise JournalParseError( 4269 f"Destination {graph.identityOf(leadsTo)} from" 4270 f" decision {graph.identityOf(here)} via transition" 4271 f" {transition!r} must be named when explored," 4272 f" because its current name is a placeholder." 4273 ) 4274 else: 4275 newName = currentName 4276 4277 # TODO: Check for incompatible domain/zone in destination 4278 # specifier? 4279 4280 if self.inRelativeMode: 4281 if connectBack: # connect to existing unconfirmed decision 4282 assert exists is not None 4283 graph.replaceUnconfirmed( 4284 here, 4285 transitionName, 4286 exists, 4287 reciprocal 4288 ) # we assume zones are already in place here 4289 self.exploration.setExplorationStatus( 4290 exists, 4291 'noticed', 4292 upgradeOnly=True 4293 ) 4294 else: # connect to a new decision 4295 graph.replaceUnconfirmed( 4296 here, 4297 transitionName, 4298 newName, 4299 reciprocal, 4300 placeInZone=newZone, 4301 forceNew=True 4302 ) 4303 destID = graph.destination(here, transitionName) 4304 self.exploration.setExplorationStatus( 4305 destID, 4306 'noticed', 4307 upgradeOnly=True 4308 ) 4309 self.context['decision'] = graph.destination( 4310 here, 4311 transitionName 4312 ) 4313 self.context['transition'] = (here, transitionName) 4314 else: 4315 if connectBack: # to a known but unvisited decision 4316 destID = self.exploration.explore( 4317 (transitionName, outcomes), 4318 exists, 4319 reciprocal, 4320 zone=newZone, 4321 decisionType=decisionType 4322 ) 4323 else: # to an entirely new decision 4324 destID = self.exploration.explore( 4325 (transitionName, outcomes), 4326 newName, 4327 reciprocal, 4328 zone=newZone, 4329 decisionType=decisionType 4330 ) 4331 self.context['decision'] = destID 4332 self.context['transition'] = (here, transitionName) 4333 self.autoFinalizeExplorationStatuses()
Records the exploration of a transition which leads to a
specific destination (possibly with outcomes specified for
challenges that are part of that transition's consequences). The
name of the reciprocal transition may also be specified, as can
a non-default decision type (see base.DecisionType
). Creates
the transition if it needs to.
Note that if the destination specifier has no zone or domain information, even if a decision with that name already exists, if the current decision is in a level-0 zone and the existing decision is not in the same zone, a new decision with that name in the current level-0 zone will be created (otherwise, it would be an error to use 'explore' to connect to an already-visited decision).
If no destination name is specified, the destination node must
already exist and the name of the destination must not begin
with '_u.' otherwise a JournalParseError
will be generated.
Sets the current transition to the transition taken.
Calls autoFinalizeExplorationStatuses
to upgrade exploration
statuses for no-longer-active nodes to 'explored'.
In relative mode, this makes all the same changes to the graph, without adding a new exploration step, applying transition effects, or changing exploration statuses.
4335 def recordRetrace( 4336 self, 4337 transition: base.AnyTransition, 4338 decisionType: base.DecisionType = 'active', 4339 isAction: Optional[bool] = None 4340 ) -> None: 4341 """ 4342 Records retracing a transition which leads to a known 4343 destination. A non-default decision type can be specified. If 4344 `isAction` is True or False, the transition must be (or must not 4345 be) an action (i.e., a transition whose destination is the same 4346 as its source). If `isAction` is left as `None` (the default) 4347 then either normal or action transitions can be retraced. 4348 4349 Sets the current transition to the transition taken. 4350 4351 Calls `autoFinalizeExplorationStatuses` unless in relative mode. 4352 4353 In relative mode, simply sets the current transition target to 4354 the transition taken and sets the current decision target to its 4355 destination (it does not apply transition effects). 4356 """ 4357 here = self.definiteDecisionTarget() 4358 4359 transitionName, outcomes = base.nameAndOutcomes(transition) 4360 4361 graph = self.exploration.getSituation().graph 4362 destination = graph.getDestination(here, transitionName) 4363 if destination is None: 4364 valid = graph.destinationsListing(graph.destinationsFrom(here)) 4365 raise JournalParseError( 4366 f"Cannot retrace transition {transitionName!r} from" 4367 f" decision {graph.identityOf(here)}: that transition" 4368 f" does not exist. Destinations available are:" 4369 f"\n{valid}" 4370 ) 4371 if isAction is True and destination != here: 4372 raise JournalParseError( 4373 f"Cannot retrace transition {transitionName!r} from" 4374 f" decision {graph.identityOf(here)}: that transition" 4375 f" leads to {graph.identityOf(destination)} but you" 4376 f" specified that an existing action should be retraced," 4377 f" not a normal transition. Use `recordAction` instead" 4378 f" to record a new action (including converting an" 4379 f" unconfirmed transition into an action). Leave" 4380 f" `isAction` unspeicfied or set it to `False` to" 4381 f" retrace a normal transition." 4382 ) 4383 elif isAction is False and destination == here: 4384 raise JournalParseError( 4385 f"Cannot retrace transition {transitionName!r} from" 4386 f" decision {graph.identityOf(here)}: that transition" 4387 f" leads back to {graph.identityOf(destination)} but you" 4388 f" specified that an outgoing transition should be" 4389 f" retraced, not an action. Use `recordAction` instead" 4390 f" to record a new action (which must not have the same" 4391 f" name as any outgoing transition). Leave `isAction`" 4392 f" unspeicfied or set it to `True` to retrace an action." 4393 ) 4394 4395 if not self.inRelativeMode: 4396 destID = self.exploration.retrace( 4397 (transitionName, outcomes), 4398 decisionType=decisionType 4399 ) 4400 self.autoFinalizeExplorationStatuses() 4401 self.context['decision'] = destID 4402 self.context['transition'] = (here, transitionName)
Records retracing a transition which leads to a known
destination. A non-default decision type can be specified. If
isAction
is True or False, the transition must be (or must not
be) an action (i.e., a transition whose destination is the same
as its source). If isAction
is left as None
(the default)
then either normal or action transitions can be retraced.
Sets the current transition to the transition taken.
Calls autoFinalizeExplorationStatuses
unless in relative mode.
In relative mode, simply sets the current transition target to the transition taken and sets the current decision target to its destination (it does not apply transition effects).
4404 def recordAction( 4405 self, 4406 action: base.AnyTransition, 4407 decisionType: base.DecisionType = 'active' 4408 ) -> None: 4409 """ 4410 Records a new action taken at the current decision. A 4411 non-standard decision type may be specified. If a transition of 4412 that name already existed, it will be converted into an action 4413 assuming that its destination is unexplored and has no 4414 connections yet, and that its reciprocal also has no special 4415 properties yet. If those assumptions do not hold, a 4416 `JournalParseError` will be raised under the assumption that the 4417 name collision was an accident, not intentional, since the 4418 destination and reciprocal are deleted in the process of 4419 converting a normal transition into an action. 4420 4421 This cannot be used to re-triggger an existing action, use 4422 'retrace' for that. 4423 4424 In relative mode, the action is created (or the transition is 4425 converted into an action) but effects are not applied. 4426 4427 Although this does not usually change which decisions are 4428 active, it still calls `autoFinalizeExplorationStatuses` unless 4429 in relative mode. 4430 4431 Example: 4432 4433 >>> o = JournalObserver() 4434 >>> e = o.getExploration() 4435 >>> o.recordStart('start') 4436 >>> o.recordObserve('transition') 4437 >>> e.effectiveCapabilities()['capabilities'] 4438 set() 4439 >>> o.recordObserveAction('action') 4440 >>> o.recordTransitionConsequence([base.effect(gain="capability")]) 4441 >>> o.recordRetrace('action', isAction=True) 4442 >>> e.effectiveCapabilities()['capabilities'] 4443 {'capability'} 4444 >>> o.recordAction('another') # add effects after... 4445 >>> effect = base.effect(lose="capability") 4446 >>> # This applies the effect and then adds it to the 4447 >>> # transition, since we already took the transition 4448 >>> o.recordAdditionalTransitionConsequence([effect]) 4449 >>> e.effectiveCapabilities()['capabilities'] 4450 set() 4451 >>> len(e) 4452 4 4453 >>> e.getActiveDecisions(0) 4454 set() 4455 >>> e.getActiveDecisions(1) 4456 {0} 4457 >>> e.getActiveDecisions(2) 4458 {0} 4459 >>> e.getActiveDecisions(3) 4460 {0} 4461 >>> e.getSituation(0).action 4462 ('start', 0, 0, 'main', None, None, None) 4463 >>> e.getSituation(1).action 4464 ('take', 'active', 0, ('action', [])) 4465 >>> e.getSituation(2).action 4466 ('take', 'active', 0, ('another', [])) 4467 """ 4468 here = self.definiteDecisionTarget() 4469 4470 actionName, outcomes = base.nameAndOutcomes(action) 4471 4472 # Check if the transition already exists 4473 now = self.exploration.getSituation() 4474 graph = now.graph 4475 hereIdent = graph.identityOf(here) 4476 destinations = graph.destinationsFrom(here) 4477 4478 # A transition going somewhere else 4479 if actionName in destinations: 4480 if destinations[actionName] == here: 4481 raise JournalParseError( 4482 f"Action {actionName!r} already exists as an action" 4483 f" at decision {hereIdent!r}. Use 'retrace' to" 4484 " re-activate an existing action." 4485 ) 4486 else: 4487 destination = destinations[actionName] 4488 reciprocal = graph.getReciprocal(here, actionName) 4489 # To replace a transition with an action, the transition 4490 # may only have outgoing properties. Otherwise we assume 4491 # it's an error to name the action after a transition 4492 # which was intended to be a real transition. 4493 if ( 4494 graph.isConfirmed(destination) 4495 or self.exploration.hasBeenVisited(destination) 4496 or cast(int, graph.degree(destination)) > 2 4497 # TODO: Fix MultiDigraph type stubs... 4498 ): 4499 raise JournalParseError( 4500 f"Action {actionName!r} has the same name as" 4501 f" outgoing transition {actionName!r} at" 4502 f" decision {hereIdent!r}. We cannot turn that" 4503 f" transition into an action since its" 4504 f" destination is already explored or has been" 4505 f" connected to." 4506 ) 4507 if ( 4508 reciprocal is not None 4509 and graph.getTransitionProperties( 4510 destination, 4511 reciprocal 4512 ) != { 4513 'requirement': base.ReqNothing(), 4514 'effects': [], 4515 'tags': {}, 4516 'annotations': [] 4517 } 4518 ): 4519 raise JournalParseError( 4520 f"Action {actionName!r} has the same name as" 4521 f" outgoing transition {actionName!r} at" 4522 f" decision {hereIdent!r}. We cannot turn that" 4523 f" transition into an action since its" 4524 f" reciprocal has custom properties." 4525 ) 4526 4527 if ( 4528 graph.decisionAnnotations(destination) != [] 4529 or graph.decisionTags(destination) != {'unknown': 1} 4530 ): 4531 raise JournalParseError( 4532 f"Action {actionName!r} has the same name as" 4533 f" outgoing transition {actionName!r} at" 4534 f" decision {hereIdent!r}. We cannot turn that" 4535 f" transition into an action since its" 4536 f" destination has tags and/or annotations." 4537 ) 4538 4539 # If we get here, re-target the transition, and then 4540 # destroy the old destination along with the old 4541 # reciprocal edge. 4542 graph.retargetTransition( 4543 here, 4544 actionName, 4545 here, 4546 swapReciprocal=False 4547 ) 4548 graph.removeDecision(destination) 4549 4550 # This will either take the existing action OR create it if 4551 # necessary 4552 if self.inRelativeMode: 4553 if actionName not in destinations: 4554 graph.addAction(here, actionName) 4555 else: 4556 destID = self.exploration.takeAction( 4557 (actionName, outcomes), 4558 fromDecision=here, 4559 decisionType=decisionType 4560 ) 4561 self.autoFinalizeExplorationStatuses() 4562 self.context['decision'] = destID 4563 self.context['transition'] = (here, actionName)
Records a new action taken at the current decision. A
non-standard decision type may be specified. If a transition of
that name already existed, it will be converted into an action
assuming that its destination is unexplored and has no
connections yet, and that its reciprocal also has no special
properties yet. If those assumptions do not hold, a
JournalParseError
will be raised under the assumption that the
name collision was an accident, not intentional, since the
destination and reciprocal are deleted in the process of
converting a normal transition into an action.
This cannot be used to re-triggger an existing action, use 'retrace' for that.
In relative mode, the action is created (or the transition is converted into an action) but effects are not applied.
Although this does not usually change which decisions are
active, it still calls autoFinalizeExplorationStatuses
unless
in relative mode.
Example:
>>> o = JournalObserver()
>>> e = o.getExploration()
>>> o.recordStart('start')
>>> o.recordObserve('transition')
>>> e.effectiveCapabilities()['capabilities']
set()
>>> o.recordObserveAction('action')
>>> o.recordTransitionConsequence([base.effect(gain="capability")])
>>> o.recordRetrace('action', isAction=True)
>>> e.effectiveCapabilities()['capabilities']
{'capability'}
>>> o.recordAction('another') # add effects after...
>>> effect = base.effect(lose="capability")
>>> # This applies the effect and then adds it to the
>>> # transition, since we already took the transition
>>> o.recordAdditionalTransitionConsequence([effect])
>>> e.effectiveCapabilities()['capabilities']
set()
>>> len(e)
4
>>> e.getActiveDecisions(0)
set()
>>> e.getActiveDecisions(1)
{0}
>>> e.getActiveDecisions(2)
{0}
>>> e.getActiveDecisions(3)
{0}
>>> e.getSituation(0).action
('start', 0, 0, 'main', None, None, None)
>>> e.getSituation(1).action
('take', 'active', 0, ('action', []))
>>> e.getSituation(2).action
('take', 'active', 0, ('another', []))
4565 def recordReturn( 4566 self, 4567 transition: base.AnyTransition, 4568 destination: Optional[base.AnyDecisionSpecifier] = None, 4569 reciprocal: Optional[base.Transition] = None, 4570 decisionType: base.DecisionType = 'active' 4571 ) -> None: 4572 """ 4573 Records an exploration which leads back to a 4574 previously-encountered decision. If a reciprocal is specified, 4575 we connect to that transition as our reciprocal (it must have 4576 led to an unknown area or not have existed) or if not, we make a 4577 new connection with an automatic reciprocal name. 4578 A non-standard decision type may be specified. 4579 4580 If no destination is specified, then the destination of the 4581 transition must already exist. 4582 4583 If the specified transition does not exist, it will be created. 4584 4585 Sets the current transition to the transition taken. 4586 4587 Calls `autoFinalizeExplorationStatuses` unless in relative mode. 4588 4589 In relative mode, does the same stuff but doesn't apply any 4590 transition effects. 4591 """ 4592 here = self.definiteDecisionTarget() 4593 now = self.exploration.getSituation() 4594 graph = now.graph 4595 4596 transitionName, outcomes = base.nameAndOutcomes(transition) 4597 4598 if destination is None: 4599 destination = graph.getDestination(here, transitionName) 4600 if destination is None: 4601 raise JournalParseError( 4602 f"Cannot 'return' across transition" 4603 f" {transitionName!r} from decision" 4604 f" {graph.identityOf(here)} without specifying a" 4605 f" destination, because that transition does not" 4606 f" already have a destination." 4607 ) 4608 4609 if isinstance(destination, str): 4610 destination = self.parseFormat.parseDecisionSpecifier( 4611 destination 4612 ) 4613 4614 # If we started with a name or some other kind of decision 4615 # specifier, replace missing domain and/or zone info with info 4616 # from the current decision. 4617 if isinstance(destination, base.DecisionSpecifier): 4618 destination = base.spliceDecisionSpecifiers( 4619 destination, 4620 self.decisionTargetSpecifier() 4621 ) 4622 4623 # Add an unexplored edge just before doing the return if the 4624 # named transition didn't already exist. 4625 if graph.getDestination(here, transitionName) is None: 4626 graph.addUnexploredEdge(here, transitionName) 4627 4628 # Works differently in relative mode 4629 if self.inRelativeMode: 4630 graph.replaceUnconfirmed( 4631 here, 4632 transitionName, 4633 destination, 4634 reciprocal 4635 ) 4636 self.context['decision'] = graph.resolveDecision(destination) 4637 self.context['transition'] = (here, transitionName) 4638 else: 4639 destID = self.exploration.returnTo( 4640 (transitionName, outcomes), 4641 destination, 4642 reciprocal, 4643 decisionType=decisionType 4644 ) 4645 self.autoFinalizeExplorationStatuses() 4646 self.context['decision'] = destID 4647 self.context['transition'] = (here, transitionName)
Records an exploration which leads back to a previously-encountered decision. If a reciprocal is specified, we connect to that transition as our reciprocal (it must have led to an unknown area or not have existed) or if not, we make a new connection with an automatic reciprocal name. A non-standard decision type may be specified.
If no destination is specified, then the destination of the transition must already exist.
If the specified transition does not exist, it will be created.
Sets the current transition to the transition taken.
Calls autoFinalizeExplorationStatuses
unless in relative mode.
In relative mode, does the same stuff but doesn't apply any transition effects.
4649 def recordWarp( 4650 self, 4651 destination: base.AnyDecisionSpecifier, 4652 decisionType: base.DecisionType = 'active' 4653 ) -> None: 4654 """ 4655 Records a warp to a specific destination without creating a 4656 transition. If the destination did not exist, it will be 4657 created (but only if a `base.DecisionName` or 4658 `base.DecisionSpecifier` was supplied; a destination cannot be 4659 created based on a non-existent `base.DecisionID`). 4660 A non-standard decision type may be specified. 4661 4662 If the destination already exists its zones won't be changed. 4663 However, if the destination gets created, it will be in the same 4664 domain and added to the same zones as the previous position, or 4665 to whichever zone was specified as the zone component of a 4666 `base.DecisionSpecifier`, if any. 4667 4668 Sets the current transition to `None`. 4669 4670 In relative mode, simply updates the current target decision and 4671 sets the current target transition to `None`. It will still 4672 create the destination if necessary, possibly putting it in a 4673 zone. In relative mode, the destination's exploration status is 4674 set to "noticed" (and no exploration step is created), while in 4675 normal mode, the exploration status is set to 'unknown' in the 4676 original current step, and then a new step is added which will 4677 set the status to 'exploring'. 4678 4679 Calls `autoFinalizeExplorationStatuses` unless in relative mode. 4680 """ 4681 now = self.exploration.getSituation() 4682 graph = now.graph 4683 4684 if isinstance(destination, str): 4685 destination = self.parseFormat.parseDecisionSpecifier( 4686 destination 4687 ) 4688 4689 destID = graph.getDecision(destination) 4690 4691 newZone: Union[ 4692 base.Zone, 4693 type[base.DefaultZone], 4694 None 4695 ] = base.DefaultZone 4696 here = self.currentDecisionTarget() 4697 newDomain: Optional[base.Domain] = None 4698 if here is not None: 4699 newDomain = graph.domainFor(here) 4700 if self.inRelativeMode: # create the decision if it didn't exist 4701 if destID not in graph: # including if it's None 4702 if isinstance(destination, base.DecisionID): 4703 raise JournalParseError( 4704 f"Cannot go to decision {destination} because that" 4705 f" decision ID does not exist, and we cannot create" 4706 f" a new decision based only on a decision ID. Use" 4707 f" a DecisionSpecifier or DecisionName to go to a" 4708 f" new decision that needs to be created." 4709 ) 4710 elif isinstance(destination, base.DecisionName): 4711 newName = destination 4712 newZone = base.DefaultZone 4713 elif isinstance(destination, base.DecisionSpecifier): 4714 specDomain, newZone, newName = destination 4715 if specDomain is not None: 4716 newDomain = specDomain 4717 else: 4718 raise JournalParseError( 4719 f"Invalid decision specifier: {repr(destination)}." 4720 f" The destination must be a decision ID, a" 4721 f" decision name, or a decision specifier." 4722 ) 4723 destID = graph.addDecision(newName, domain=newDomain) 4724 if newZone is base.DefaultZone: 4725 ctxDecision = self.context['decision'] 4726 if ctxDecision is not None: 4727 for zp in graph.zoneParents(ctxDecision): 4728 graph.addDecisionToZone(destID, zp) 4729 elif newZone is not None: 4730 graph.addDecisionToZone(destID, newZone) 4731 # TODO: If this zone is new create it & add it to 4732 # parent zones of old level-0 zone(s)? 4733 4734 base.setExplorationStatus( 4735 now, 4736 destID, 4737 'noticed', 4738 upgradeOnly=True 4739 ) 4740 # TODO: Some way to specify 'hypothesized' here instead? 4741 4742 else: 4743 # in normal mode, 'DiscreteExploration.warp' takes care of 4744 # creating the decision if needed 4745 whichFocus = None 4746 if self.context['focus'] is not None: 4747 whichFocus = ( 4748 self.context['context'], 4749 self.context['domain'], 4750 self.context['focus'] 4751 ) 4752 if destination is None: 4753 destination = destID 4754 4755 if isinstance(destination, base.DecisionSpecifier): 4756 newZone = destination.zone 4757 if destination.domain is not None: 4758 newDomain = destination.domain 4759 else: 4760 newZone = base.DefaultZone 4761 4762 destID = self.exploration.warp( 4763 destination, 4764 domain=newDomain, 4765 zone=newZone, 4766 whichFocus=whichFocus, 4767 inCommon=self.context['context'] == 'common', 4768 decisionType=decisionType 4769 ) 4770 self.autoFinalizeExplorationStatuses() 4771 4772 self.context['decision'] = destID 4773 self.context['transition'] = None
Records a warp to a specific destination without creating a
transition. If the destination did not exist, it will be
created (but only if a base.DecisionName
or
base.DecisionSpecifier
was supplied; a destination cannot be
created based on a non-existent base.DecisionID
).
A non-standard decision type may be specified.
If the destination already exists its zones won't be changed.
However, if the destination gets created, it will be in the same
domain and added to the same zones as the previous position, or
to whichever zone was specified as the zone component of a
base.DecisionSpecifier
, if any.
Sets the current transition to None
.
In relative mode, simply updates the current target decision and
sets the current target transition to None
. It will still
create the destination if necessary, possibly putting it in a
zone. In relative mode, the destination's exploration status is
set to "noticed" (and no exploration step is created), while in
normal mode, the exploration status is set to 'unknown' in the
original current step, and then a new step is added which will
set the status to 'exploring'.
Calls autoFinalizeExplorationStatuses
unless in relative mode.
4775 def recordWait( 4776 self, 4777 decisionType: base.DecisionType = 'active' 4778 ) -> None: 4779 """ 4780 Records a wait step. Does not modify the current transition. 4781 A non-standard decision type may be specified. 4782 4783 Raises a `JournalParseError` in relative mode, since it wouldn't 4784 have any effect. 4785 """ 4786 if self.inRelativeMode: 4787 raise JournalParseError("Can't wait in relative mode.") 4788 else: 4789 self.exploration.wait(decisionType=decisionType)
Records a wait step. Does not modify the current transition. A non-standard decision type may be specified.
Raises a JournalParseError
in relative mode, since it wouldn't
have any effect.
4791 def recordObserveEnding(self, name: base.DecisionName) -> None: 4792 """ 4793 Records the observation of an action which warps to an ending, 4794 although unlike `recordEnd` we don't use that action yet. This 4795 does NOT update the current decision, although it sets the 4796 current transition to the action it creates. 4797 4798 The action created has the same name as the ending it warps to. 4799 4800 Note that normally, we just warp to endings, so there's no need 4801 to use `recordObserveEnding`. But if there's a player-controlled 4802 option to end the game at a particular node that is noticed 4803 before it's actually taken, this is the right thing to do. 4804 4805 We set up player-initiated ending transitions as actions with a 4806 goto rather than usual transitions because endings exist in a 4807 separate domain, and are active simultaneously with normal 4808 decisions. 4809 """ 4810 graph = self.exploration.getSituation().graph 4811 here = self.definiteDecisionTarget() 4812 # Add the ending decision or grab the ID of the existing ending 4813 eID = graph.endingID(name) 4814 # Create action & add goto consequence 4815 graph.addAction(here, name) 4816 graph.setConsequence(here, name, [base.effect(goto=eID)]) 4817 # Set the exploration status 4818 self.exploration.setExplorationStatus( 4819 eID, 4820 'noticed', 4821 upgradeOnly=True 4822 ) 4823 self.context['transition'] = (here, name) 4824 # TODO: Prevent things like adding unexplored nodes to the 4825 # an ending...
Records the observation of an action which warps to an ending,
although unlike recordEnd
we don't use that action yet. This
does NOT update the current decision, although it sets the
current transition to the action it creates.
The action created has the same name as the ending it warps to.
Note that normally, we just warp to endings, so there's no need
to use recordObserveEnding
. But if there's a player-controlled
option to end the game at a particular node that is noticed
before it's actually taken, this is the right thing to do.
We set up player-initiated ending transitions as actions with a goto rather than usual transitions because endings exist in a separate domain, and are active simultaneously with normal decisions.
4827 def recordEnd( 4828 self, 4829 name: base.DecisionName, 4830 voluntary: bool = False, 4831 decisionType: Optional[base.DecisionType] = None 4832 ) -> None: 4833 """ 4834 Records an ending. If `voluntary` is `False` (the default) then 4835 this becomes a warp that activates the specified ending (which 4836 is in the `core.ENDINGS_DOMAIN` domain, so that doesn't leave 4837 the current decision). 4838 4839 If `voluntary` is `True` then we also record an action with a 4840 'goto' effect that activates the specified ending, and record an 4841 exploration step that takes that action, instead of just a warp 4842 (`recordObserveEnding` would set up such an action without 4843 taking it). 4844 4845 The specified ending decision is created if it didn't already 4846 exist. If `voluntary` is True and an action that warps to the 4847 specified ending already exists with the correct name, we will 4848 simply take that action. 4849 4850 If it created an action, it sets the current transition to the 4851 action that warps to the ending. Endings are not added to zones; 4852 otherwise it sets the current transition to None. 4853 4854 In relative mode, an ending is still added, possibly with an 4855 action that warps to it, and the current decision is set to that 4856 ending node, but the transition doesn't actually get taken. 4857 4858 If not in relative mode, sets the exploration status of the 4859 current decision to `explored` if it wasn't in the 4860 `dontFinalize` set, even though we do not deactivate that 4861 transition. 4862 4863 When `voluntary` is not set, the decision type for the warp will 4864 be 'imposed', otherwise it will be 'active'. However, if an 4865 explicit `decisionType` is specified, that will override these 4866 defaults. 4867 """ 4868 graph = self.exploration.getSituation().graph 4869 here = self.definiteDecisionTarget() 4870 4871 # Add our warping action if we need to 4872 if voluntary: 4873 # If voluntary, check for an existing warp action and set 4874 # one up if we don't have one. 4875 aDest = graph.getDestination(here, name) 4876 eID = graph.getDecision( 4877 base.DecisionSpecifier(core.ENDINGS_DOMAIN, None, name) 4878 ) 4879 if aDest is None: 4880 # Okay we can just create the action 4881 self.recordObserveEnding(name) 4882 # else check if the existing transition is an action 4883 # that warps to the correct ending already 4884 elif ( 4885 aDest != here 4886 or eID is None 4887 or not any( 4888 c == base.effect(goto=eID) 4889 for c in graph.getConsequence(here, name) 4890 ) 4891 ): 4892 raise JournalParseError( 4893 f"Attempting to add voluntary ending {name!r} at" 4894 f" decision {graph.identityOf(here)} but that" 4895 f" decision already has an action with that name" 4896 f" and it's not set up to warp to that ending" 4897 f" already." 4898 ) 4899 4900 # Grab ending ID (creates the decision if necessary) 4901 eID = graph.endingID(name) 4902 4903 # Update our context variables 4904 self.context['decision'] = eID 4905 if voluntary: 4906 self.context['transition'] = (here, name) 4907 else: 4908 self.context['transition'] = None 4909 4910 # Update exploration status in relative mode, or possibly take 4911 # action in normal mode 4912 if self.inRelativeMode: 4913 self.exploration.setExplorationStatus( 4914 eID, 4915 "noticed", 4916 upgradeOnly=True 4917 ) 4918 else: 4919 # Either take the action we added above, or just warp 4920 if decisionType is None: 4921 decisionType = 'active' if voluntary else 'imposed' 4922 4923 if voluntary: 4924 # Taking the action warps us to the ending 4925 self.exploration.takeAction( 4926 name, 4927 decisionType=decisionType 4928 ) 4929 else: 4930 # We'll use a warp to get there 4931 self.exploration.warp( 4932 base.DecisionSpecifier(core.ENDINGS_DOMAIN, None, name), 4933 zone=None, 4934 decisionType=decisionType 4935 ) 4936 if ( 4937 here not in self.dontFinalize 4938 and ( 4939 self.exploration.getExplorationStatus(here) 4940 == "exploring" 4941 ) 4942 ): 4943 self.exploration.setExplorationStatus(here, "explored") 4944 # TODO: Prevent things like adding unexplored nodes to the 4945 # ending...
Records an ending. If voluntary
is False
(the default) then
this becomes a warp that activates the specified ending (which
is in the core.ENDINGS_DOMAIN
domain, so that doesn't leave
the current decision).
If voluntary
is True
then we also record an action with a
'goto' effect that activates the specified ending, and record an
exploration step that takes that action, instead of just a warp
(recordObserveEnding
would set up such an action without
taking it).
The specified ending decision is created if it didn't already
exist. If voluntary
is True and an action that warps to the
specified ending already exists with the correct name, we will
simply take that action.
If it created an action, it sets the current transition to the action that warps to the ending. Endings are not added to zones; otherwise it sets the current transition to None.
In relative mode, an ending is still added, possibly with an action that warps to it, and the current decision is set to that ending node, but the transition doesn't actually get taken.
If not in relative mode, sets the exploration status of the
current decision to explored
if it wasn't in the
dontFinalize
set, even though we do not deactivate that
transition.
When voluntary
is not set, the decision type for the warp will
be 'imposed', otherwise it will be 'active'. However, if an
explicit decisionType
is specified, that will override these
defaults.
4947 def recordMechanism( 4948 self, 4949 where: Optional[base.AnyDecisionSpecifier], 4950 name: base.MechanismName, 4951 startingState: base.MechanismState = base.DEFAULT_MECHANISM_STATE 4952 ) -> None: 4953 """ 4954 Records the existence of a mechanism at the specified decision 4955 with the specified starting state (or the default starting 4956 state). Set `where` to `None` to set up a global mechanism that's 4957 not tied to any particular decision. 4958 """ 4959 graph = self.exploration.getSituation().graph 4960 # TODO: a way to set up global mechanisms 4961 newID = graph.addMechanism(name, where) 4962 if startingState != base.DEFAULT_MECHANISM_STATE: 4963 self.exploration.setMechanismStateNow(newID, startingState)
Records the existence of a mechanism at the specified decision
with the specified starting state (or the default starting
state). Set where
to None
to set up a global mechanism that's
not tied to any particular decision.
4965 def recordRequirement(self, req: Union[base.Requirement, str]) -> None: 4966 """ 4967 Records a requirement observed on the most recently 4968 defined/taken transition. If a string is given, 4969 `ParseFormat.parseRequirement` will be used to parse it. 4970 """ 4971 if isinstance(req, str): 4972 req = self.parseFormat.parseRequirement(req) 4973 target = self.currentTransitionTarget() 4974 if target is None: 4975 raise JournalParseError( 4976 "Can't set a requirement because there is no current" 4977 " transition." 4978 ) 4979 graph = self.exploration.getSituation().graph 4980 graph.setTransitionRequirement( 4981 *target, 4982 req 4983 )
Records a requirement observed on the most recently
defined/taken transition. If a string is given,
ParseFormat.parseRequirement
will be used to parse it.
4985 def recordReciprocalRequirement( 4986 self, 4987 req: Union[base.Requirement, str] 4988 ) -> None: 4989 """ 4990 Records a requirement observed on the reciprocal of the most 4991 recently defined/taken transition. If a string is given, 4992 `ParseFormat.parseRequirement` will be used to parse it. 4993 """ 4994 if isinstance(req, str): 4995 req = self.parseFormat.parseRequirement(req) 4996 target = self.currentReciprocalTarget() 4997 if target is None: 4998 raise JournalParseError( 4999 "Can't set a reciprocal requirement because there is no" 5000 " current transition or it doesn't have a reciprocal." 5001 ) 5002 graph = self.exploration.getSituation().graph 5003 graph.setTransitionRequirement(*target, req)
Records a requirement observed on the reciprocal of the most
recently defined/taken transition. If a string is given,
ParseFormat.parseRequirement
will be used to parse it.
5005 def recordTransitionConsequence( 5006 self, 5007 consequence: base.Consequence 5008 ) -> None: 5009 """ 5010 Records a transition consequence, which gets added to any 5011 existing consequences of the currently-relevant transition (the 5012 most-recently created or taken transition). A `JournalParseError` 5013 will be raised if there is no current transition. 5014 """ 5015 target = self.currentTransitionTarget() 5016 if target is None: 5017 raise JournalParseError( 5018 "Cannot apply a consequence because there is no current" 5019 " transition." 5020 ) 5021 5022 now = self.exploration.getSituation() 5023 now.graph.addConsequence(*target, consequence)
Records a transition consequence, which gets added to any
existing consequences of the currently-relevant transition (the
most-recently created or taken transition). A JournalParseError
will be raised if there is no current transition.
5025 def recordReciprocalConsequence( 5026 self, 5027 consequence: base.Consequence 5028 ) -> None: 5029 """ 5030 Like `recordTransitionConsequence` but applies the effect to the 5031 reciprocal of the current transition. Will cause a 5032 `JournalParseError` if the current transition has no reciprocal 5033 (e.g., it's an ending transition). 5034 """ 5035 target = self.currentReciprocalTarget() 5036 if target is None: 5037 raise JournalParseError( 5038 "Cannot apply a reciprocal effect because there is no" 5039 " current transition, or it doesn't have a reciprocal." 5040 ) 5041 5042 now = self.exploration.getSituation() 5043 now.graph.addConsequence(*target, consequence)
Like recordTransitionConsequence
but applies the effect to the
reciprocal of the current transition. Will cause a
JournalParseError
if the current transition has no reciprocal
(e.g., it's an ending transition).
5045 def recordAdditionalTransitionConsequence( 5046 self, 5047 consequence: base.Consequence, 5048 hideEffects: bool = True 5049 ) -> None: 5050 """ 5051 Records the addition of a new consequence to the current 5052 relevant transition, while also triggering the effects of that 5053 consequence (but not the other effects of that transition, which 5054 we presume have just been applied already). 5055 5056 By default each effect added this way automatically gets the 5057 "hidden" property added to it, because the assumption is if it 5058 were a foreseeable effect, you would have added it to the 5059 transition before taking it. If you set `hideEffects` to 5060 `False`, this won't be done. 5061 5062 This modifies the current state but does not add a step to the 5063 exploration. It does NOT call `autoFinalizeExplorationStatuses`, 5064 which means that if a 'bounce' or 'goto' effect ends up making 5065 one or more decisions no-longer-active, they do NOT get their 5066 exploration statuses upgraded to 'explored'. 5067 """ 5068 # Receive begin/end indices from `addConsequence` and send them 5069 # to `applyTransitionConsequence` to limit which # parts of the 5070 # expanded consequence are actually applied. 5071 currentTransition = self.currentTransitionTarget() 5072 if currentTransition is None: 5073 consRepr = self.parseFormat.unparseConsequence(consequence) 5074 raise JournalParseError( 5075 f"Can't apply an additional consequence to a transition" 5076 f" when there is no current transition. Got" 5077 f" consequence:\n{consRepr}" 5078 ) 5079 5080 if hideEffects: 5081 for (index, item) in base.walkParts(consequence): 5082 if isinstance(item, dict) and 'value' in item: 5083 assert 'hidden' in item 5084 item = cast(base.Effect, item) 5085 item['hidden'] = True 5086 5087 now = self.exploration.getSituation() 5088 begin, end = now.graph.addConsequence( 5089 *currentTransition, 5090 consequence 5091 ) 5092 self.exploration.applyTransitionConsequence( 5093 *currentTransition, 5094 moveWhich=self.context['focus'], 5095 policy="specified", 5096 fromIndex=begin, 5097 toIndex=end 5098 ) 5099 # This tracks trigger counts and obeys 5100 # charges/delays, unlike 5101 # applyExtraneousConsequence, but some effects 5102 # like 'bounce' still can't be properly applied
Records the addition of a new consequence to the current relevant transition, while also triggering the effects of that consequence (but not the other effects of that transition, which we presume have just been applied already).
By default each effect added this way automatically gets the
"hidden" property added to it, because the assumption is if it
were a foreseeable effect, you would have added it to the
transition before taking it. If you set hideEffects
to
False
, this won't be done.
This modifies the current state but does not add a step to the
exploration. It does NOT call autoFinalizeExplorationStatuses
,
which means that if a 'bounce' or 'goto' effect ends up making
one or more decisions no-longer-active, they do NOT get their
exploration statuses upgraded to 'explored'.
5104 def recordTagStep( 5105 self, 5106 tag: base.Tag, 5107 value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue 5108 ) -> None: 5109 """ 5110 Records a tag to be applied to the current exploration step. 5111 """ 5112 self.exploration.tagStep(tag, value)
Records a tag to be applied to the current exploration step.
5114 def recordTagDecision( 5115 self, 5116 tag: base.Tag, 5117 value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue 5118 ) -> None: 5119 """ 5120 Records a tag to be applied to the current decision. 5121 """ 5122 now = self.exploration.getSituation() 5123 now.graph.tagDecision( 5124 self.definiteDecisionTarget(), 5125 tag, 5126 value 5127 )
Records a tag to be applied to the current decision.
5129 def recordTagTranstion( 5130 self, 5131 tag: base.Tag, 5132 value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue 5133 ) -> None: 5134 """ 5135 Records a tag to be applied to the most-recently-defined or 5136 -taken transition. 5137 """ 5138 target = self.currentTransitionTarget() 5139 if target is None: 5140 raise JournalParseError( 5141 "Cannot tag a transition because there is no current" 5142 " transition." 5143 ) 5144 5145 now = self.exploration.getSituation() 5146 now.graph.tagTransition(*target, tag, value)
Records a tag to be applied to the most-recently-defined or -taken transition.
5148 def recordTagReciprocal( 5149 self, 5150 tag: base.Tag, 5151 value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue 5152 ) -> None: 5153 """ 5154 Records a tag to be applied to the reciprocal of the 5155 most-recently-defined or -taken transition. 5156 """ 5157 target = self.currentReciprocalTarget() 5158 if target is None: 5159 raise JournalParseError( 5160 "Cannot tag a transition because there is no current" 5161 " transition." 5162 ) 5163 5164 now = self.exploration.getSituation() 5165 now.graph.tagTransition(*target, tag, value)
Records a tag to be applied to the reciprocal of the most-recently-defined or -taken transition.
5167 def currentZoneAtLevel(self, level: int) -> base.Zone: 5168 """ 5169 Returns a zone in the current graph that applies to the current 5170 decision which is at the specified hierarchy level. If there is 5171 no such zone, raises a `JournalParseError`. If there are 5172 multiple such zones, returns the zone which includes the fewest 5173 decisions, breaking ties alphabetically by zone name. 5174 """ 5175 here = self.definiteDecisionTarget() 5176 graph = self.exploration.getSituation().graph 5177 ancestors = graph.zoneAncestors(here) 5178 candidates = [ 5179 ancestor 5180 for ancestor in ancestors 5181 if graph.zoneHierarchyLevel(ancestor) == level 5182 ] 5183 if len(candidates) == 0: 5184 raise JournalParseError( 5185 ( 5186 f"Cannot find any level-{level} zones for the" 5187 f" current decision {graph.identityOf(here)}. That" 5188 f" decision is" 5189 ) + ( 5190 " in the following zones:" 5191 + '\n'.join( 5192 f" level {graph.zoneHierarchyLevel(z)}: {z!r}" 5193 for z in ancestors 5194 ) 5195 ) if len(ancestors) > 0 else ( 5196 " not in any zones." 5197 ) 5198 ) 5199 candidates.sort( 5200 key=lambda zone: (len(graph.allDecisionsInZone(zone)), zone) 5201 ) 5202 return candidates[0]
Returns a zone in the current graph that applies to the current
decision which is at the specified hierarchy level. If there is
no such zone, raises a JournalParseError
. If there are
multiple such zones, returns the zone which includes the fewest
decisions, breaking ties alphabetically by zone name.
5204 def recordTagZone( 5205 self, 5206 level: int, 5207 tag: base.Tag, 5208 value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue 5209 ) -> None: 5210 """ 5211 Records a tag to be applied to one of the zones that the current 5212 decision is in, at a specific hierarchy level. There must be at 5213 least one zone ancestor of the current decision at that hierarchy 5214 level; if there are multiple then the tag is applied to the 5215 smallest one, breaking ties by alphabetical order. 5216 """ 5217 applyTo = self.currentZoneAtLevel(level) 5218 self.exploration.getSituation().graph.tagZone(applyTo, tag, value)
Records a tag to be applied to one of the zones that the current decision is in, at a specific hierarchy level. There must be at least one zone ancestor of the current decision at that hierarchy level; if there are multiple then the tag is applied to the smallest one, breaking ties by alphabetical order.
5220 def recordAnnotateStep( 5221 self, 5222 *annotations: base.Annotation 5223 ) -> None: 5224 """ 5225 Records annotations to be applied to the current exploration 5226 step. 5227 """ 5228 self.exploration.annotateStep(annotations) 5229 pf = self.parseFormat 5230 now = self.exploration.getSituation() 5231 for a in annotations: 5232 if a.startswith("at:"): 5233 expects = pf.parseDecisionSpecifier(a[3:]) 5234 if isinstance(expects, base.DecisionSpecifier): 5235 if expects.domain is None and expects.zone is None: 5236 expects = base.spliceDecisionSpecifiers( 5237 expects, 5238 self.decisionTargetSpecifier() 5239 ) 5240 eID = now.graph.getDecision(expects) 5241 primaryNow: Optional[base.DecisionID] 5242 if self.inRelativeMode: 5243 primaryNow = self.definiteDecisionTarget() 5244 else: 5245 primaryNow = now.state['primaryDecision'] 5246 if eID is None: 5247 self.warn( 5248 f"'at' annotation expects position {expects!r}" 5249 f" but that's not a valid decision specifier in" 5250 f" the current graph." 5251 ) 5252 elif eID != primaryNow: 5253 self.warn( 5254 f"'at' annotation expects position {expects!r}" 5255 f" which is decision" 5256 f" {now.graph.identityOf(eID)}, but the current" 5257 f" primary decision is" 5258 f" {now.graph.identityOf(primaryNow)}" 5259 ) 5260 elif a.startswith("active:"): 5261 expects = pf.parseDecisionSpecifier(a[3:]) 5262 eID = now.graph.getDecision(expects) 5263 atNow = base.combinedDecisionSet(now.state) 5264 if eID is None: 5265 self.warn( 5266 f"'active' annotation expects decision {expects!r}" 5267 f" but that's not a valid decision specifier in" 5268 f" the current graph." 5269 ) 5270 elif eID not in atNow: 5271 self.warn( 5272 f"'active' annotation expects decision {expects!r}" 5273 f" which is {now.graph.identityOf(eID)}, but" 5274 f" the current active position(s) is/are:" 5275 f"\n{now.graph.namesListing(atNow)}" 5276 ) 5277 elif a.startswith("has:"): 5278 ea = pf.parseOneEffectArg(pf.lex(a[4:]))[0] 5279 if ( 5280 isinstance(ea, tuple) 5281 and len(ea) == 2 5282 and isinstance(ea[0], base.Token) 5283 and isinstance(ea[1], base.TokenCount) 5284 ): 5285 countNow = base.combinedTokenCount(now.state, ea[0]) 5286 if countNow != ea[1]: 5287 self.warn( 5288 f"'has' annotation expects {ea[1]} {ea[0]!r}" 5289 f" token(s) but the current state has" 5290 f" {countNow} of them." 5291 ) 5292 else: 5293 self.warn( 5294 f"'has' annotation expects tokens {a[4:]!r} but" 5295 f" that's not a (token, count) pair." 5296 ) 5297 elif a.startswith("level:"): 5298 ea = pf.parseOneEffectArg(pf.lex(a[6:]))[0] 5299 if ( 5300 isinstance(ea, tuple) 5301 and len(ea) == 3 5302 and ea[0] == 'skill' 5303 and isinstance(ea[1], base.Skill) 5304 and isinstance(ea[2], base.Level) 5305 ): 5306 levelNow = base.getSkillLevel(now.state, ea[1]) 5307 if levelNow != ea[2]: 5308 self.warn( 5309 f"'level' annotation expects skill {ea[1]!r}" 5310 f" to be at level {ea[2]} but the current" 5311 f" level for that skill is {levelNow}." 5312 ) 5313 else: 5314 self.warn( 5315 f"'level' annotation expects skill {a[6:]!r} but" 5316 f" that's not a (skill, level) pair." 5317 ) 5318 elif a.startswith("can:"): 5319 try: 5320 req = pf.parseRequirement(a[4:]) 5321 except parsing.ParseError: 5322 self.warn( 5323 f"'can' annotation expects requirement {a[4:]!r}" 5324 f" but that's not parsable as a requirement." 5325 ) 5326 req = None 5327 if req is not None: 5328 ctx = base.genericContextForSituation(now) 5329 if not req.satisfied(ctx): 5330 self.warn( 5331 f"'can' annotation expects requirement" 5332 f" {req!r} to be satisfied but it's not in" 5333 f" the current situation." 5334 ) 5335 elif a.startswith("state:"): 5336 ctx = base.genericContextForSituation( 5337 now 5338 ) 5339 ea = pf.parseOneEffectArg(pf.lex(a[6:]))[0] 5340 if ( 5341 isinstance(ea, tuple) 5342 and len(ea) == 2 5343 and isinstance(ea[0], tuple) 5344 and len(ea[0]) == 4 5345 and (ea[0][0] is None or isinstance(ea[0][0], base.Domain)) 5346 and (ea[0][1] is None or isinstance(ea[0][1], base.Zone)) 5347 and ( 5348 ea[0][2] is None 5349 or isinstance(ea[0][2], base.DecisionName) 5350 ) 5351 and isinstance(ea[0][3], base.MechanismName) 5352 and isinstance(ea[1], base.MechanismState) 5353 ): 5354 mID = now.graph.resolveMechanism(ea[0], ctx.searchFrom) 5355 stateNow = base.stateOfMechanism(ctx, mID) 5356 if not base.mechanismInStateOrEquivalent( 5357 mID, 5358 ea[1], 5359 ctx 5360 ): 5361 self.warn( 5362 f"'state' annotation expects mechanism {mID}" 5363 f" {ea[0]!r} to be in state {ea[1]!r} but" 5364 f" its current state is {stateNow!r} and no" 5365 f" equivalence makes it count as being in" 5366 f" state {ea[1]!r}." 5367 ) 5368 else: 5369 self.warn( 5370 f"'state' annotation expects mechanism state" 5371 f" {a[6:]!r} but that's not a mechanism/state" 5372 f" pair." 5373 ) 5374 elif a.startswith("exists:"): 5375 expects = pf.parseDecisionSpecifier(a[7:]) 5376 try: 5377 now.graph.resolveDecision(expects) 5378 except core.MissingDecisionError: 5379 self.warn( 5380 f"'exists' annotation expects decision" 5381 f" {a[7:]!r} but that decision does not exist." 5382 )
Records annotations to be applied to the current exploration step.
5384 def recordAnnotateDecision( 5385 self, 5386 *annotations: base.Annotation 5387 ) -> None: 5388 """ 5389 Records annotations to be applied to the current decision. 5390 """ 5391 now = self.exploration.getSituation() 5392 now.graph.annotateDecision(self.definiteDecisionTarget(), annotations)
Records annotations to be applied to the current decision.
5394 def recordAnnotateTranstion( 5395 self, 5396 *annotations: base.Annotation 5397 ) -> None: 5398 """ 5399 Records annotations to be applied to the most-recently-defined 5400 or -taken transition. 5401 """ 5402 target = self.currentTransitionTarget() 5403 if target is None: 5404 raise JournalParseError( 5405 "Cannot annotate a transition because there is no" 5406 " current transition." 5407 ) 5408 5409 now = self.exploration.getSituation() 5410 now.graph.annotateTransition(*target, annotations)
Records annotations to be applied to the most-recently-defined or -taken transition.
5412 def recordAnnotateReciprocal( 5413 self, 5414 *annotations: base.Annotation 5415 ) -> None: 5416 """ 5417 Records annotations to be applied to the reciprocal of the 5418 most-recently-defined or -taken transition. 5419 """ 5420 target = self.currentReciprocalTarget() 5421 if target is None: 5422 raise JournalParseError( 5423 "Cannot annotate a reciprocal because there is no" 5424 " current transition or because it doens't have a" 5425 " reciprocal." 5426 ) 5427 5428 now = self.exploration.getSituation() 5429 now.graph.annotateTransition(*target, annotations)
Records annotations to be applied to the reciprocal of the most-recently-defined or -taken transition.
5431 def recordAnnotateZone( 5432 self, 5433 level, 5434 *annotations: base.Annotation 5435 ) -> None: 5436 """ 5437 Records annotations to be applied to the zone at the specified 5438 hierarchy level which contains the current decision. If there are 5439 multiple such zones, it picks the smallest one, breaking ties 5440 alphabetically by zone name (see `currentZoneAtLevel`). 5441 """ 5442 applyTo = self.currentZoneAtLevel(level) 5443 self.exploration.getSituation().graph.annotateZone( 5444 applyTo, 5445 annotations 5446 )
Records annotations to be applied to the zone at the specified
hierarchy level which contains the current decision. If there are
multiple such zones, it picks the smallest one, breaking ties
alphabetically by zone name (see currentZoneAtLevel
).
5448 def recordContextSwap( 5449 self, 5450 targetContext: Optional[base.FocalContextName] 5451 ) -> None: 5452 """ 5453 Records a swap of the active focal context, and/or a swap into 5454 "common"-context mode where all effects modify the common focal 5455 context instead of the active one. Use `None` as the argument to 5456 swap to common mode; use another specific value so swap to 5457 normal mode and set that context as the active one. 5458 5459 In relative mode, swaps the active context without adding an 5460 exploration step. Swapping into the common context never results 5461 in a new exploration step. 5462 """ 5463 if targetContext is None: 5464 self.context['context'] = "common" 5465 else: 5466 self.context['context'] = "active" 5467 e = self.getExploration() 5468 if self.inRelativeMode: 5469 e.setActiveContext(targetContext) 5470 else: 5471 e.advanceSituation(('swap', targetContext))
Records a swap of the active focal context, and/or a swap into
"common"-context mode where all effects modify the common focal
context instead of the active one. Use None
as the argument to
swap to common mode; use another specific value so swap to
normal mode and set that context as the active one.
In relative mode, swaps the active context without adding an exploration step. Swapping into the common context never results in a new exploration step.
5473 def recordZone(self, level: int, zone: base.Zone) -> None: 5474 """ 5475 Records a new current zone to be swapped with the zone(s) at the 5476 specified hierarchy level for the current decision target. See 5477 `core.DiscreteExploration.reZone` and 5478 `core.DecisionGraph.replaceZonesInHierarchy` for details on what 5479 exactly happens; the summary is that the zones at the specified 5480 hierarchy level are replaced with the provided zone (which is 5481 created if necessary) and their children are re-parented onto 5482 the provided zone, while that zone is also set as a child of 5483 their parents. 5484 5485 Does the same thing in relative mode as in normal mode. 5486 """ 5487 self.exploration.reZone( 5488 zone, 5489 self.definiteDecisionTarget(), 5490 level 5491 )
Records a new current zone to be swapped with the zone(s) at the
specified hierarchy level for the current decision target. See
core.DiscreteExploration.reZone
and
core.DecisionGraph.replaceZonesInHierarchy
for details on what
exactly happens; the summary is that the zones at the specified
hierarchy level are replaced with the provided zone (which is
created if necessary) and their children are re-parented onto
the provided zone, while that zone is also set as a child of
their parents.
Does the same thing in relative mode as in normal mode.
5493 def recordUnify( 5494 self, 5495 merge: base.AnyDecisionSpecifier, 5496 mergeInto: Optional[base.AnyDecisionSpecifier] = None 5497 ) -> None: 5498 """ 5499 Records a unification between two decisions. This marks an 5500 observation that they are actually the same decision and it 5501 merges them. If only one decision is given the current decision 5502 is merged into that one. After the merge, the first decision (or 5503 the current decision if only one was given) will no longer 5504 exist. 5505 5506 If one of the merged decisions was the current position in a 5507 singular-focalized domain, or one of the current positions in a 5508 plural- or spreading-focalized domain, the merged decision will 5509 replace it as a current decision after the merge, and this 5510 happens even when in relative mode. The target decision is also 5511 updated if it needs to be. 5512 5513 A `TransitionCollisionError` will be raised if the two decisions 5514 have outgoing transitions that share a name. 5515 5516 Logs a `JournalParseWarning` if the two decisions were in 5517 different zones. 5518 5519 Any transitions between the two merged decisions will remain in 5520 place as actions. 5521 5522 TODO: Option for removing self-edges after the merge? Option for 5523 doing that for just effect-less edges? 5524 """ 5525 if mergeInto is None: 5526 mergeInto = merge 5527 merge = self.definiteDecisionTarget() 5528 5529 if isinstance(merge, str): 5530 merge = self.parseFormat.parseDecisionSpecifier(merge) 5531 5532 if isinstance(mergeInto, str): 5533 mergeInto = self.parseFormat.parseDecisionSpecifier(mergeInto) 5534 5535 now = self.exploration.getSituation() 5536 5537 if not isinstance(merge, base.DecisionID): 5538 merge = now.graph.resolveDecision(merge) 5539 5540 merge = cast(base.DecisionID, merge) 5541 5542 now.graph.mergeDecisions(merge, mergeInto) 5543 5544 mergedID = now.graph.resolveDecision(mergeInto) 5545 5546 # Update FocalContexts & ObservationContexts as necessary 5547 self.cleanupContexts(remapped={merge: mergedID})
Records a unification between two decisions. This marks an observation that they are actually the same decision and it merges them. If only one decision is given the current decision is merged into that one. After the merge, the first decision (or the current decision if only one was given) will no longer exist.
If one of the merged decisions was the current position in a singular-focalized domain, or one of the current positions in a plural- or spreading-focalized domain, the merged decision will replace it as a current decision after the merge, and this happens even when in relative mode. The target decision is also updated if it needs to be.
A TransitionCollisionError
will be raised if the two decisions
have outgoing transitions that share a name.
Logs a JournalParseWarning
if the two decisions were in
different zones.
Any transitions between the two merged decisions will remain in place as actions.
TODO: Option for removing self-edges after the merge? Option for doing that for just effect-less edges?
5549 def recordUnifyTransition(self, target: base.Transition) -> None: 5550 """ 5551 Records a unification between the most-recently-defined or 5552 -taken transition and the specified transition (which must be 5553 outgoing from the same decision). This marks an observation that 5554 two transitions are actually the same transition and it merges 5555 them. 5556 5557 After the merge, the target transition will still exist but the 5558 previously most-recent transition will have been deleted. 5559 5560 Their reciprocals will also be merged. 5561 5562 A `JournalParseError` is raised if there is no most-recent 5563 transition. 5564 """ 5565 now = self.exploration.getSituation() 5566 graph = now.graph 5567 affected = self.currentTransitionTarget() 5568 if affected is None or affected[1] is None: 5569 raise JournalParseError( 5570 "Cannot unify transitions: there is no current" 5571 " transition." 5572 ) 5573 5574 decision, transition = affected 5575 5576 # If they don't share a target, then the current transition must 5577 # lead to an unknown node, which we will dispose of 5578 destination = graph.getDestination(decision, transition) 5579 if destination is None: 5580 raise JournalParseError( 5581 f"Cannot unify transitions: transition" 5582 f" {transition!r} at decision" 5583 f" {graph.identityOf(decision)} has no destination." 5584 ) 5585 5586 finalDestination = graph.getDestination(decision, target) 5587 if finalDestination is None: 5588 raise JournalParseError( 5589 f"Cannot unify transitions: transition" 5590 f" {target!r} at decision {graph.identityOf(decision)}" 5591 f" has no destination." 5592 ) 5593 5594 if destination != finalDestination: 5595 if graph.isConfirmed(destination): 5596 raise JournalParseError( 5597 f"Cannot unify transitions: destination" 5598 f" {graph.identityOf(destination)} of transition" 5599 f" {transition!r} at decision" 5600 f" {graph.identityOf(decision)} is not an" 5601 f" unconfirmed decision." 5602 ) 5603 # Retarget and delete the unknown node that we abandon 5604 # TODO: Merge nodes instead? 5605 now.graph.retargetTransition( 5606 decision, 5607 transition, 5608 finalDestination 5609 ) 5610 now.graph.removeDecision(destination) 5611 5612 # Now we can merge transitions 5613 now.graph.mergeTransitions(decision, transition, target) 5614 5615 # Update targets if they were merged 5616 self.cleanupContexts( 5617 remappedTransitions={ 5618 (decision, transition): (decision, target) 5619 } 5620 )
Records a unification between the most-recently-defined or -taken transition and the specified transition (which must be outgoing from the same decision). This marks an observation that two transitions are actually the same transition and it merges them.
After the merge, the target transition will still exist but the previously most-recent transition will have been deleted.
Their reciprocals will also be merged.
A JournalParseError
is raised if there is no most-recent
transition.
5622 def recordUnifyReciprocal( 5623 self, 5624 target: base.Transition 5625 ) -> None: 5626 """ 5627 Records a unification between the reciprocal of the 5628 most-recently-defined or -taken transition and the specified 5629 transition, which must be outgoing from the current transition's 5630 destination. This marks an observation that two transitions are 5631 actually the same transition and it merges them, deleting the 5632 original reciprocal. Note that the current transition will also 5633 be merged with the reciprocal of the target. 5634 5635 A `JournalParseError` is raised if there is no current 5636 transition, or if it does not have a reciprocal. 5637 """ 5638 now = self.exploration.getSituation() 5639 graph = now.graph 5640 affected = self.currentReciprocalTarget() 5641 if affected is None or affected[1] is None: 5642 raise JournalParseError( 5643 "Cannot unify transitions: there is no current" 5644 " transition." 5645 ) 5646 5647 decision, transition = affected 5648 5649 destination = graph.destination(decision, transition) 5650 reciprocal = graph.getReciprocal(decision, transition) 5651 if reciprocal is None: 5652 raise JournalParseError( 5653 "Cannot unify reciprocal: there is no reciprocal of the" 5654 " current transition." 5655 ) 5656 5657 # If they don't share a target, then the current transition must 5658 # lead to an unknown node, which we will dispose of 5659 finalDestination = graph.getDestination(destination, target) 5660 if finalDestination is None: 5661 raise JournalParseError( 5662 f"Cannot unify reciprocal: transition" 5663 f" {target!r} at decision" 5664 f" {graph.identityOf(destination)} has no destination." 5665 ) 5666 5667 if decision != finalDestination: 5668 if graph.isConfirmed(decision): 5669 raise JournalParseError( 5670 f"Cannot unify reciprocal: destination" 5671 f" {graph.identityOf(decision)} of transition" 5672 f" {reciprocal!r} at decision" 5673 f" {graph.identityOf(destination)} is not an" 5674 f" unconfirmed decision." 5675 ) 5676 # Retarget and delete the unknown node that we abandon 5677 # TODO: Merge nodes instead? 5678 graph.retargetTransition( 5679 destination, 5680 reciprocal, 5681 finalDestination 5682 ) 5683 graph.removeDecision(decision) 5684 5685 # Actually merge the transitions 5686 graph.mergeTransitions(destination, reciprocal, target) 5687 5688 # Update targets if they were merged 5689 self.cleanupContexts( 5690 remappedTransitions={ 5691 (decision, transition): (decision, target) 5692 } 5693 )
Records a unification between the reciprocal of the most-recently-defined or -taken transition and the specified transition, which must be outgoing from the current transition's destination. This marks an observation that two transitions are actually the same transition and it merges them, deleting the original reciprocal. Note that the current transition will also be merged with the reciprocal of the target.
A JournalParseError
is raised if there is no current
transition, or if it does not have a reciprocal.
5695 def recordObviate( 5696 self, 5697 transition: base.Transition, 5698 otherDecision: base.AnyDecisionSpecifier, 5699 otherTransition: base.Transition 5700 ) -> None: 5701 """ 5702 Records the obviation of a transition at another decision. This 5703 is the observation that a specific transition at the current 5704 decision is the reciprocal of a different transition at another 5705 decision which previously led to an unknown area. The difference 5706 between this and `recordReturn` is that `recordReturn` logs 5707 movement across the newly-connected transition, while this 5708 leaves the player at their original decision (and does not even 5709 add a step to the current exploration). 5710 5711 Both transitions will be created if they didn't already exist. 5712 5713 In relative mode does the same thing and doesn't move the current 5714 decision across the transition updated. 5715 5716 If the destination is unknown, it will remain unknown after this 5717 operation. 5718 """ 5719 now = self.exploration.getSituation() 5720 graph = now.graph 5721 here = self.definiteDecisionTarget() 5722 5723 if isinstance(otherDecision, str): 5724 otherDecision = self.parseFormat.parseDecisionSpecifier( 5725 otherDecision 5726 ) 5727 5728 # If we started with a name or some other kind of decision 5729 # specifier, replace missing domain and/or zone info with info 5730 # from the current decision. 5731 if isinstance(otherDecision, base.DecisionSpecifier): 5732 otherDecision = base.spliceDecisionSpecifiers( 5733 otherDecision, 5734 self.decisionTargetSpecifier() 5735 ) 5736 5737 otherDestination = graph.getDestination( 5738 otherDecision, 5739 otherTransition 5740 ) 5741 if otherDestination is not None: 5742 if graph.isConfirmed(otherDestination): 5743 raise JournalParseError( 5744 f"Cannot obviate transition {otherTransition!r} at" 5745 f" decision {graph.identityOf(otherDecision)}: that" 5746 f" transition leads to decision" 5747 f" {graph.identityOf(otherDestination)} which has" 5748 f" already been visited." 5749 ) 5750 else: 5751 # We must create the other destination 5752 graph.addUnexploredEdge(otherDecision, otherTransition) 5753 5754 destination = graph.getDestination(here, transition) 5755 if destination is not None: 5756 if graph.isConfirmed(destination): 5757 raise JournalParseError( 5758 f"Cannot obviate using transition {transition!r} at" 5759 f" decision {graph.identityOf(here)}: that" 5760 f" transition leads to decision" 5761 f" {graph.identityOf(destination)} which is not an" 5762 f" unconfirmed decision." 5763 ) 5764 else: 5765 # we need to create it 5766 graph.addUnexploredEdge(here, transition) 5767 5768 # Track exploration status of destination (because 5769 # `replaceUnconfirmed` will overwrite it but we want to preserve 5770 # it in this case. 5771 if otherDecision is not None: 5772 prevStatus = base.explorationStatusOf(now, otherDecision) 5773 5774 # Now connect the transitions and clean up the unknown nodes 5775 graph.replaceUnconfirmed( 5776 here, 5777 transition, 5778 otherDecision, 5779 otherTransition 5780 ) 5781 # Restore exploration status 5782 base.setExplorationStatus(now, otherDecision, prevStatus) 5783 5784 # Update context 5785 self.context['transition'] = (here, transition)
Records the obviation of a transition at another decision. This
is the observation that a specific transition at the current
decision is the reciprocal of a different transition at another
decision which previously led to an unknown area. The difference
between this and recordReturn
is that recordReturn
logs
movement across the newly-connected transition, while this
leaves the player at their original decision (and does not even
add a step to the current exploration).
Both transitions will be created if they didn't already exist.
In relative mode does the same thing and doesn't move the current decision across the transition updated.
If the destination is unknown, it will remain unknown after this operation.
5787 def cleanupContexts( 5788 self, 5789 remapped: Optional[Dict[base.DecisionID, base.DecisionID]] = None, 5790 remappedTransitions: Optional[ 5791 Dict[ 5792 Tuple[base.DecisionID, base.Transition], 5793 Tuple[base.DecisionID, base.Transition] 5794 ] 5795 ] = None 5796 ) -> None: 5797 """ 5798 Checks the validity of context decision and transition entries, 5799 and sets them to `None` in situations where they are no longer 5800 valid, affecting both the current and stored contexts. 5801 5802 Also updates position information in focal contexts in the 5803 current exploration step. 5804 5805 If a `remapped` dictionary is provided, decisions in the keys of 5806 that dictionary will be replaced with the corresponding value 5807 before being checked. 5808 5809 Similarly a `remappedTransitions` dicitonary may provide info on 5810 renamed transitions using (`base.DecisionID`, `base.Transition`) 5811 pairs as both keys and values. 5812 """ 5813 if remapped is None: 5814 remapped = {} 5815 5816 if remappedTransitions is None: 5817 remappedTransitions = {} 5818 5819 # Fix broken position information in the current focal contexts 5820 now = self.exploration.getSituation() 5821 graph = now.graph 5822 state = now.state 5823 for ctx in ( 5824 state['common'], 5825 state['contexts'][state['activeContext']] 5826 ): 5827 active = ctx['activeDecisions'] 5828 for domain in active: 5829 aVal = active[domain] 5830 if isinstance(aVal, base.DecisionID): 5831 if aVal in remapped: # check for remap 5832 aVal = remapped[aVal] 5833 active[domain] = aVal 5834 if graph.getDecision(aVal) is None: # Ultimately valid? 5835 active[domain] = None 5836 elif isinstance(aVal, dict): 5837 for fpName in aVal: 5838 fpVal = aVal[fpName] 5839 if fpVal is None: 5840 aVal[fpName] = None 5841 elif fpVal in remapped: # check for remap 5842 aVal[fpName] = remapped[fpVal] 5843 elif graph.getDecision(fpVal) is None: # valid? 5844 aVal[fpName] = None 5845 elif isinstance(aVal, set): 5846 for r in remapped: 5847 if r in aVal: 5848 aVal.remove(r) 5849 aVal.add(remapped[r]) 5850 discard = [] 5851 for dID in aVal: 5852 if graph.getDecision(dID) is None: 5853 discard.append(dID) 5854 for dID in discard: 5855 aVal.remove(dID) 5856 elif aVal is not None: 5857 raise RuntimeError( 5858 f"Invalid active decisions for domain" 5859 f" {repr(domain)}: {repr(aVal)}" 5860 ) 5861 5862 # Fix up our ObservationContexts 5863 fix = [self.context] 5864 if self.storedContext is not None: 5865 fix.append(self.storedContext) 5866 5867 graph = self.exploration.getSituation().graph 5868 for obsCtx in fix: 5869 cdID = obsCtx['decision'] 5870 if cdID in remapped: 5871 cdID = remapped[cdID] 5872 obsCtx['decision'] = cdID 5873 5874 if cdID not in graph: 5875 obsCtx['decision'] = None 5876 5877 transition = obsCtx['transition'] 5878 if transition is not None: 5879 tSourceID = transition[0] 5880 if tSourceID in remapped: 5881 tSourceID = remapped[tSourceID] 5882 obsCtx['transition'] = (tSourceID, transition[1]) 5883 5884 if transition in remappedTransitions: 5885 obsCtx['transition'] = remappedTransitions[transition] 5886 5887 tDestID = graph.getDestination(tSourceID, transition[1]) 5888 if tDestID is None: 5889 obsCtx['transition'] = None
Checks the validity of context decision and transition entries,
and sets them to None
in situations where they are no longer
valid, affecting both the current and stored contexts.
Also updates position information in focal contexts in the current exploration step.
If a remapped
dictionary is provided, decisions in the keys of
that dictionary will be replaced with the corresponding value
before being checked.
Similarly a remappedTransitions
dicitonary may provide info on
renamed transitions using (base.DecisionID
, base.Transition
)
pairs as both keys and values.
5891 def recordExtinguishDecision( 5892 self, 5893 target: base.AnyDecisionSpecifier 5894 ) -> None: 5895 """ 5896 Records the deletion of a decision. The decision and all 5897 transitions connected to it will be removed from the current 5898 graph. Does not create a new exploration step. If the current 5899 position is deleted, the position will be set to `None`, or if 5900 we're in relative mode, the decision target will be set to 5901 `None` if it gets deleted. Likewise, all stored and/or current 5902 transitions which no longer exist are erased to `None`. 5903 """ 5904 # Erase target if it's going to be removed 5905 now = self.exploration.getSituation() 5906 5907 if isinstance(target, str): 5908 target = self.parseFormat.parseDecisionSpecifier(target) 5909 5910 # TODO: Do we need to worry about the node being part of any 5911 # focal context data structures? 5912 5913 # Actually remove it 5914 now.graph.removeDecision(target) 5915 5916 # Clean up our contexts 5917 self.cleanupContexts()
Records the deletion of a decision. The decision and all
transitions connected to it will be removed from the current
graph. Does not create a new exploration step. If the current
position is deleted, the position will be set to None
, or if
we're in relative mode, the decision target will be set to
None
if it gets deleted. Likewise, all stored and/or current
transitions which no longer exist are erased to None
.
5919 def recordExtinguishTransition( 5920 self, 5921 source: base.AnyDecisionSpecifier, 5922 target: base.Transition, 5923 deleteReciprocal: bool = True 5924 ) -> None: 5925 """ 5926 Records the deletion of a named transition coming from a 5927 specific source. The reciprocal will also be removed, unless 5928 `deleteReciprocal` is set to False. If `deleteReciprocal` is 5929 used and this results in the complete isolation of an unknown 5930 node, that node will be deleted as well. Cleans up any saved 5931 transition targets that are no longer valid by setting them to 5932 `None`. Does not create a graph step. 5933 """ 5934 now = self.exploration.getSituation() 5935 graph = now.graph 5936 dest = graph.destination(source, target) 5937 5938 # Remove the transition 5939 graph.removeTransition(source, target, deleteReciprocal) 5940 5941 # Remove the old destination if it's unconfirmed and no longer 5942 # connected anywhere 5943 if ( 5944 not graph.isConfirmed(dest) 5945 and len(graph.destinationsFrom(dest)) == 0 5946 ): 5947 graph.removeDecision(dest) 5948 5949 # Clean up our contexts 5950 self.cleanupContexts()
Records the deletion of a named transition coming from a
specific source. The reciprocal will also be removed, unless
deleteReciprocal
is set to False. If deleteReciprocal
is
used and this results in the complete isolation of an unknown
node, that node will be deleted as well. Cleans up any saved
transition targets that are no longer valid by setting them to
None
. Does not create a graph step.
5952 def recordComplicate( 5953 self, 5954 target: base.Transition, 5955 newDecision: base.DecisionName, # TODO: Allow zones/domain here 5956 newReciprocal: Optional[base.Transition], 5957 newReciprocalReciprocal: Optional[base.Transition] 5958 ) -> base.DecisionID: 5959 """ 5960 Records the complication of a transition and its reciprocal into 5961 a new decision. The old transition and its old reciprocal (if 5962 there was one) both point to the new decision. The 5963 `newReciprocal` becomes the new reciprocal of the original 5964 transition, and the `newReciprocalReciprocal` becomes the new 5965 reciprocal of the old reciprocal. Either may be set explicitly to 5966 `None` to leave the corresponding new transition without a 5967 reciprocal (but they don't default to `None`). If there was no 5968 old reciprocal, but `newReciprocalReciprocal` is specified, then 5969 that transition is created linking the new node to the old 5970 destination, without a reciprocal. 5971 5972 The current decision & transition information is not updated. 5973 5974 Returns the decision ID for the new node. 5975 """ 5976 now = self.exploration.getSituation() 5977 graph = now.graph 5978 here = self.definiteDecisionTarget() 5979 domain = graph.domainFor(here) 5980 5981 oldDest = graph.destination(here, target) 5982 oldReciprocal = graph.getReciprocal(here, target) 5983 5984 # Create the new decision: 5985 newID = graph.addDecision(newDecision, domain=domain) 5986 # Note that the new decision is NOT an unknown decision 5987 # We copy the exploration status from the current decision 5988 self.exploration.setExplorationStatus( 5989 newID, 5990 self.exploration.getExplorationStatus(here) 5991 ) 5992 # Copy over zone info 5993 for zp in graph.zoneParents(here): 5994 graph.addDecisionToZone(newID, zp) 5995 5996 # Retarget the transitions 5997 graph.retargetTransition( 5998 here, 5999 target, 6000 newID, 6001 swapReciprocal=False 6002 ) 6003 if oldReciprocal is not None: 6004 graph.retargetTransition( 6005 oldDest, 6006 oldReciprocal, 6007 newID, 6008 swapReciprocal=False 6009 ) 6010 6011 # Add a new reciprocal edge 6012 if newReciprocal is not None: 6013 graph.addTransition(newID, newReciprocal, here) 6014 graph.setReciprocal(here, target, newReciprocal) 6015 6016 # Add a new double-reciprocal edge (even if there wasn't a 6017 # reciprocal before) 6018 if newReciprocalReciprocal is not None: 6019 graph.addTransition( 6020 newID, 6021 newReciprocalReciprocal, 6022 oldDest 6023 ) 6024 if oldReciprocal is not None: 6025 graph.setReciprocal( 6026 oldDest, 6027 oldReciprocal, 6028 newReciprocalReciprocal 6029 ) 6030 6031 return newID
Records the complication of a transition and its reciprocal into
a new decision. The old transition and its old reciprocal (if
there was one) both point to the new decision. The
newReciprocal
becomes the new reciprocal of the original
transition, and the newReciprocalReciprocal
becomes the new
reciprocal of the old reciprocal. Either may be set explicitly to
None
to leave the corresponding new transition without a
reciprocal (but they don't default to None
). If there was no
old reciprocal, but newReciprocalReciprocal
is specified, then
that transition is created linking the new node to the old
destination, without a reciprocal.
The current decision & transition information is not updated.
Returns the decision ID for the new node.
6033 def recordRevert( 6034 self, 6035 slot: base.SaveSlot, 6036 aspects: Set[str], 6037 decisionType: base.DecisionType = 'active' 6038 ) -> None: 6039 """ 6040 Records a reversion to a previous state (possibly for only some 6041 aspects of the current state). See `base.revertedState` for the 6042 allowed values and meanings of strings in the aspects set. 6043 Uses the specified decision type, or 'active' by default. 6044 6045 Reversion counts as an exploration step. 6046 6047 This sets the current decision to the primary decision for the 6048 reverted state (which might be `None` in some cases) and sets 6049 the current transition to None. 6050 """ 6051 self.exploration.revert(slot, aspects, decisionType=decisionType) 6052 newPrimary = self.exploration.getSituation().state['primaryDecision'] 6053 self.context['decision'] = newPrimary 6054 self.context['transition'] = None
Records a reversion to a previous state (possibly for only some
aspects of the current state). See base.revertedState
for the
allowed values and meanings of strings in the aspects set.
Uses the specified decision type, or 'active' by default.
Reversion counts as an exploration step.
This sets the current decision to the primary decision for the
reverted state (which might be None
in some cases) and sets
the current transition to None.
6056 def recordFulfills( 6057 self, 6058 requirement: Union[str, base.Requirement], 6059 fulfilled: Union[ 6060 base.Capability, 6061 Tuple[base.MechanismID, base.MechanismState] 6062 ] 6063 ) -> None: 6064 """ 6065 Records the observation that a certain requirement fulfills the 6066 same role as (i.e., is equivalent to) a specific capability, or a 6067 specific mechanism being in a specific state. Transitions that 6068 require that capability or mechanism state will count as 6069 traversable even if that capability is not obtained or that 6070 mechanism is in another state, as long as the requirement for the 6071 fulfillment is satisfied. If multiple equivalences are 6072 established, any one of them being satisfied will count as that 6073 capability being obtained (or the mechanism being in the 6074 specified state). Note that if a circular dependency is created, 6075 the capability or mechanism (unless actually obtained or in the 6076 target state) will be considered as not being obtained (or in the 6077 target state) during recursive checks. 6078 """ 6079 if isinstance(requirement, str): 6080 requirement = self.parseFormat.parseRequirement(requirement) 6081 6082 self.getExploration().getSituation().graph.addEquivalence( 6083 requirement, 6084 fulfilled 6085 )
Records the observation that a certain requirement fulfills the same role as (i.e., is equivalent to) a specific capability, or a specific mechanism being in a specific state. Transitions that require that capability or mechanism state will count as traversable even if that capability is not obtained or that mechanism is in another state, as long as the requirement for the fulfillment is satisfied. If multiple equivalences are established, any one of them being satisfied will count as that capability being obtained (or the mechanism being in the specified state). Note that if a circular dependency is created, the capability or mechanism (unless actually obtained or in the target state) will be considered as not being obtained (or in the target state) during recursive checks.
6087 def recordFocusOn( 6088 self, 6089 newFocalPoint: base.FocalPointName, 6090 inDomain: Optional[base.Domain] = None, 6091 inCommon: bool = False 6092 ): 6093 """ 6094 Records a swap to a new focal point, setting that focal point as 6095 the active focal point in the observer's current domain, or in 6096 the specified domain if one is specified. 6097 6098 A `JournalParseError` is raised if the current/specified domain 6099 does not have plural focalization. If it doesn't have a focal 6100 point with that name, then one is created and positioned at the 6101 observer's current decision (which must be in the appropriate 6102 domain). 6103 6104 If `inCommon` is set to `True` (default is `False`) then the 6105 changes will be applied to the common context instead of the 6106 active context. 6107 6108 Note that this does *not* make the target domain active; use 6109 `recordDomainFocus` for that if you need to. 6110 """ 6111 if inDomain is None: 6112 inDomain = self.context['domain'] 6113 6114 if inCommon: 6115 ctx = self.getExploration().getCommonContext() 6116 else: 6117 ctx = self.getExploration().getActiveContext() 6118 6119 if ctx['focalization'].get('domain') != 'plural': 6120 raise JournalParseError( 6121 f"Domain {inDomain!r} does not exist or does not have" 6122 f" plural focalization, so we can't set a focal point" 6123 f" in it." 6124 ) 6125 6126 focalPointMap = ctx['activeDecisions'].setdefault(inDomain, {}) 6127 if not isinstance(focalPointMap, dict): 6128 raise RuntimeError( 6129 f"Plural-focalized domain {inDomain!r} has" 6130 f" non-dictionary active" 6131 f" decisions:\n{repr(focalPointMap)}" 6132 ) 6133 6134 if newFocalPoint not in focalPointMap: 6135 focalPointMap[newFocalPoint] = self.context['decision'] 6136 6137 self.context['focus'] = newFocalPoint 6138 self.context['decision'] = focalPointMap[newFocalPoint]
Records a swap to a new focal point, setting that focal point as the active focal point in the observer's current domain, or in the specified domain if one is specified.
A JournalParseError
is raised if the current/specified domain
does not have plural focalization. If it doesn't have a focal
point with that name, then one is created and positioned at the
observer's current decision (which must be in the appropriate
domain).
If inCommon
is set to True
(default is False
) then the
changes will be applied to the common context instead of the
active context.
Note that this does not make the target domain active; use
recordDomainFocus
for that if you need to.
6140 def recordDomainUnfocus( 6141 self, 6142 domain: base.Domain, 6143 inCommon: bool = False 6144 ): 6145 """ 6146 Records a domain losing focus. Does not raise an error if the 6147 target domain was not active (in that case, it doesn't need to 6148 do anything). 6149 6150 If `inCommon` is set to `True` (default is `False`) then the 6151 domain changes will be applied to the common context instead of 6152 the active context. 6153 """ 6154 if inCommon: 6155 ctx = self.getExploration().getCommonContext() 6156 else: 6157 ctx = self.getExploration().getActiveContext() 6158 6159 try: 6160 ctx['activeDomains'].remove(domain) 6161 except KeyError: 6162 pass
Records a domain losing focus. Does not raise an error if the target domain was not active (in that case, it doesn't need to do anything).
If inCommon
is set to True
(default is False
) then the
domain changes will be applied to the common context instead of
the active context.
6164 def recordDomainFocus( 6165 self, 6166 domain: base.Domain, 6167 exclusive: bool = False, 6168 inCommon: bool = False 6169 ): 6170 """ 6171 Records a domain gaining focus, activating that domain in the 6172 current focal context and setting it as the observer's current 6173 domain. If the domain named doesn't exist yet, it will be 6174 created first (with default focalization) and then focused. 6175 6176 If `exclusive` is set to `True` (default is `False`) then all 6177 other active domains will be deactivated. 6178 6179 If `inCommon` is set to `True` (default is `False`) then the 6180 domain changes will be applied to the common context instead of 6181 the active context. 6182 """ 6183 if inCommon: 6184 ctx = self.getExploration().getCommonContext() 6185 else: 6186 ctx = self.getExploration().getActiveContext() 6187 6188 if exclusive: 6189 ctx['activeDomains'] = set() 6190 6191 if domain not in ctx['focalization']: 6192 self.recordNewDomain(domain, inCommon=inCommon) 6193 else: 6194 ctx['activeDomains'].add(domain) 6195 6196 self.context['domain'] = domain
Records a domain gaining focus, activating that domain in the current focal context and setting it as the observer's current domain. If the domain named doesn't exist yet, it will be created first (with default focalization) and then focused.
If exclusive
is set to True
(default is False
) then all
other active domains will be deactivated.
If inCommon
is set to True
(default is False
) then the
domain changes will be applied to the common context instead of
the active context.
6198 def recordNewDomain( 6199 self, 6200 domain: base.Domain, 6201 focalization: base.DomainFocalization = "singular", 6202 inCommon: bool = False 6203 ): 6204 """ 6205 Records a new domain, setting it up with the specified 6206 focalization. Sets that domain as an active domain and as the 6207 journal's current domain so that subsequent entries will create 6208 decisions in that domain. However, it does not activate any 6209 decisions within that domain. 6210 6211 Raises a `JournalParseError` if the specified domain already 6212 exists. 6213 6214 If `inCommon` is set to `True` (default is `False`) then the new 6215 domain will be made active in the common context instead of the 6216 active context. 6217 """ 6218 if inCommon: 6219 ctx = self.getExploration().getCommonContext() 6220 else: 6221 ctx = self.getExploration().getActiveContext() 6222 6223 if domain in ctx['focalization']: 6224 raise JournalParseError( 6225 f"Cannot create domain {domain!r}: that domain already" 6226 f" exists." 6227 ) 6228 6229 ctx['focalization'][domain] = focalization 6230 ctx['activeDecisions'][domain] = None 6231 ctx['activeDomains'].add(domain) 6232 self.context['domain'] = domain
Records a new domain, setting it up with the specified focalization. Sets that domain as an active domain and as the journal's current domain so that subsequent entries will create decisions in that domain. However, it does not activate any decisions within that domain.
Raises a JournalParseError
if the specified domain already
exists.
If inCommon
is set to True
(default is False
) then the new
domain will be made active in the common context instead of the
active context.
6234 def relative( 6235 self, 6236 where: Optional[base.AnyDecisionSpecifier] = None, 6237 transition: Optional[base.Transition] = None, 6238 ) -> None: 6239 """ 6240 Enters 'relative mode' where the exploration ceases to add new 6241 steps but edits can still be performed on the current graph. This 6242 also changes the current decision/transition settings so that 6243 edits can be applied anywhere. It can accept 0, 1, or 2 6244 arguments. With 0 arguments, it simply enters relative mode but 6245 maintains the current position as the target decision and the 6246 last-taken or last-created transition as the target transition 6247 (note that that transition usually originates at a different 6248 decision). With 1 argument, it sets the target decision to the 6249 decision named, and sets the target transition to None. With 2 6250 arguments, it sets the target decision to the decision named, and 6251 the target transition to the transition named, which must 6252 originate at that target decision. If the first argument is None, 6253 the current decision is used. 6254 6255 If given the name of a decision which does not yet exist, it will 6256 create that decision in the current graph, disconnected from the 6257 rest of the graph. In that case, it is an error to also supply a 6258 transition to target (you can use other commands once in relative 6259 mode to build more transitions and decisions out from the 6260 newly-created decision). 6261 6262 When called in relative mode, it updates the current position 6263 and/or decision, or if called with no arguments, it exits 6264 relative mode. When exiting relative mode, the current decision 6265 is set back to the graph's current position, and the current 6266 transition is set to whatever it was before relative mode was 6267 entered. 6268 6269 Raises a `TypeError` if a transition is specified without 6270 specifying a decision. Raises a `ValueError` if given no 6271 arguments and the exploration does not have a current position. 6272 Also raises a `ValueError` if told to target a specific 6273 transition which does not exist. 6274 6275 TODO: Example here! 6276 """ 6277 # TODO: Not this? 6278 if where is None: 6279 if transition is None and self.inRelativeMode: 6280 # If we're in relative mode, cancel it 6281 self.inRelativeMode = False 6282 6283 # Here we restore saved sate 6284 if self.storedContext is None: 6285 raise RuntimeError( 6286 "No stored context despite being in relative" 6287 "mode." 6288 ) 6289 self.context = self.storedContext 6290 self.storedContext = None 6291 6292 else: 6293 # Enter or stay in relative mode and set up the current 6294 # decision/transition as the targets 6295 6296 # Ensure relative mode 6297 self.inRelativeMode = True 6298 6299 # Store state 6300 self.storedContext = self.context 6301 where = self.storedContext['decision'] 6302 if where is None: 6303 raise ValueError( 6304 "Cannot enter relative mode at the current" 6305 " position because there is no current" 6306 " position." 6307 ) 6308 6309 self.context = observationContext( 6310 context=self.storedContext['context'], 6311 domain=self.storedContext['domain'], 6312 focus=self.storedContext['focus'], 6313 decision=where, 6314 transition=( 6315 None 6316 if transition is None 6317 else (where, transition) 6318 ) 6319 ) 6320 6321 else: # we have at least a decision to target 6322 # If we're entering relative mode instead of just changing 6323 # focus, we need to set up the current transition if no 6324 # transition was specified. 6325 entering: Optional[ 6326 Tuple[ 6327 base.ContextSpecifier, 6328 base.Domain, 6329 Optional[base.FocalPointName] 6330 ] 6331 ] = None 6332 if not self.inRelativeMode: 6333 # We'll be entering relative mode, so store state 6334 entering = ( 6335 self.context['context'], 6336 self.context['domain'], 6337 self.context['focus'] 6338 ) 6339 self.storedContext = self.context 6340 if transition is None: 6341 oldTransitionPair = self.context['transition'] 6342 if oldTransitionPair is not None: 6343 oldBase, oldTransition = oldTransitionPair 6344 if oldBase == where: 6345 transition = oldTransition 6346 6347 # Enter (or stay in) relative mode 6348 self.inRelativeMode = True 6349 6350 now = self.exploration.getSituation() 6351 whereID: Optional[base.DecisionID] 6352 whereSpec: Optional[base.DecisionSpecifier] = None 6353 if isinstance(where, str): 6354 where = self.parseFormat.parseDecisionSpecifier(where) 6355 # might turn it into a DecisionID 6356 6357 if isinstance(where, base.DecisionID): 6358 whereID = where 6359 elif isinstance(where, base.DecisionSpecifier): 6360 # Add in current zone + domain info if those things 6361 # aren't explicit 6362 if self.currentDecisionTarget() is not None: 6363 where = base.spliceDecisionSpecifiers( 6364 where, 6365 self.decisionTargetSpecifier() 6366 ) 6367 elif where.domain is None: 6368 # Splice in current domain if needed 6369 where = base.DecisionSpecifier( 6370 domain=self.context['domain'], 6371 zone=where.zone, 6372 name=where.name 6373 ) 6374 whereID = now.graph.getDecision(where) # might be None 6375 whereSpec = where 6376 else: 6377 raise TypeError(f"Invalid decision specifier: {where!r}") 6378 6379 # Create a new decision if necessary 6380 if whereID is None: 6381 if transition is not None: 6382 raise TypeError( 6383 f"Cannot specify a target transition when" 6384 f" entering relative mode at previously" 6385 f" non-existent decision" 6386 f" {now.graph.identityOf(where)}." 6387 ) 6388 assert whereSpec is not None 6389 whereID = now.graph.addDecision( 6390 whereSpec.name, 6391 domain=whereSpec.domain 6392 ) 6393 if whereSpec.zone is not None: 6394 now.graph.addDecisionToZone(whereID, whereSpec.zone) 6395 6396 # Create the new context if we're entering relative mode 6397 if entering is not None: 6398 self.context = observationContext( 6399 context=entering[0], 6400 domain=entering[1], 6401 focus=entering[2], 6402 decision=whereID, 6403 transition=( 6404 None 6405 if transition is None 6406 else (whereID, transition) 6407 ) 6408 ) 6409 6410 # Target the specified decision 6411 self.context['decision'] = whereID 6412 6413 # Target the specified transition 6414 if transition is not None: 6415 self.context['transition'] = (whereID, transition) 6416 if now.graph.getDestination(where, transition) is None: 6417 raise ValueError( 6418 f"Cannot target transition {transition!r} at" 6419 f" decision {now.graph.identityOf(where)}:" 6420 f" there is no such transition." 6421 ) 6422 # otherwise leave self.context['transition'] alone
Enters 'relative mode' where the exploration ceases to add new steps but edits can still be performed on the current graph. This also changes the current decision/transition settings so that edits can be applied anywhere. It can accept 0, 1, or 2 arguments. With 0 arguments, it simply enters relative mode but maintains the current position as the target decision and the last-taken or last-created transition as the target transition (note that that transition usually originates at a different decision). With 1 argument, it sets the target decision to the decision named, and sets the target transition to None. With 2 arguments, it sets the target decision to the decision named, and the target transition to the transition named, which must originate at that target decision. If the first argument is None, the current decision is used.
If given the name of a decision which does not yet exist, it will create that decision in the current graph, disconnected from the rest of the graph. In that case, it is an error to also supply a transition to target (you can use other commands once in relative mode to build more transitions and decisions out from the newly-created decision).
When called in relative mode, it updates the current position and/or decision, or if called with no arguments, it exits relative mode. When exiting relative mode, the current decision is set back to the graph's current position, and the current transition is set to whatever it was before relative mode was entered.
Raises a TypeError
if a transition is specified without
specifying a decision. Raises a ValueError
if given no
arguments and the exploration does not have a current position.
Also raises a ValueError
if told to target a specific
transition which does not exist.
TODO: Example here!
6429def convertJournal( 6430 journal: str, 6431 fmt: Optional[JournalParseFormat] = None 6432) -> core.DiscreteExploration: 6433 """ 6434 Converts a journal in text format into a `core.DiscreteExploration` 6435 object, using a fresh `JournalObserver`. An optional `ParseFormat` 6436 may be specified if the journal doesn't follow the default parse 6437 format. 6438 """ 6439 obs = JournalObserver(fmt) 6440 obs.observe(journal) 6441 return obs.getExploration()
Converts a journal in text format into a core.DiscreteExploration
object, using a fresh JournalObserver
. An optional ParseFormat
may be specified if the journal doesn't follow the default parse
format.