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