exploration.core
- Authors: Peter Mawhorter
- Consulted:
- Date: 2022-3-3
- Purpose: Core types and tools for dealing with them.
This file defines the main types used for processing and storing
DiscreteExploration
objects. Note that the types in open.py
like
OpenExploration
represent more generic and broadly capable types.
Key types defined here are:
DecisionGraph
: Represents a graph of decisions, including observed connections to unknown destinations. This works well for games that focus on rooms with discrete exits where there are few open spaces to explore, and where pretending that each decision has a discrete set of options is not too much of a distortion.DiscreteExploration
: A list ofDecisionGraph
s with position and transition information representing exploration over time.
1""" 2- Authors: Peter Mawhorter 3- Consulted: 4- Date: 2022-3-3 5- Purpose: Core types and tools for dealing with them. 6 7This file defines the main types used for processing and storing 8`DiscreteExploration` objects. Note that the types in `open.py` like 9`OpenExploration` represent more generic and broadly capable types. 10 11Key types defined here are: 12 13- `DecisionGraph`: Represents a graph of decisions, including observed 14 connections to unknown destinations. This works well for games that 15 focus on rooms with discrete exits where there are few open spaces to 16 explore, and where pretending that each decision has a discrete set 17 of options is not too much of a distortion. 18- `DiscreteExploration`: A list of `DecisionGraph`s with position and 19 transition information representing exploration over time. 20""" 21 22# TODO: Some way to specify the visibility conditions of a transition, 23# separately from its traversal conditions? Or at least a way to specify 24# that a transition is only visible when traversable (like successive 25# upgrades or warp zone connections). 26 27from typing import ( 28 Any, Optional, List, Set, Union, cast, Tuple, Dict, TypedDict, 29 Sequence, Collection, Literal, get_args, Callable, TypeVar, 30 Iterator, Generator 31) 32 33import copy 34import warnings 35import inspect 36 37from . import graphs 38from . import base 39from . import utils 40from . import commands 41 42 43#---------# 44# Globals # 45#---------# 46 47ENDINGS_DOMAIN = 'endings' 48""" 49Domain value for endings. 50""" 51 52TRIGGERS_DOMAIN = 'triggers' 53""" 54Domain value for triggers. 55""" 56 57 58#------------------# 59# Supporting types # 60#------------------# 61 62 63LookupResult = TypeVar('LookupResult') 64""" 65A type variable for lookup results from the generic 66`DecisionGraph.localLookup` function. 67""" 68 69LookupLayersList = List[Union[None, int, str]] 70""" 71A list of layers to look things up in, consisting of `None` for the 72starting provided decision set, integers for zone heights, and some 73custom strings like "fallback" and "all" for fallback sets. 74""" 75 76 77class DecisionInfo(TypedDict): 78 """ 79 The information stored per-decision in a `DecisionGraph` includes 80 the decision name (since the key is a decision ID), the domain, a 81 tags dictionary, and an annotations list. 82 """ 83 name: base.DecisionName 84 domain: base.Domain 85 tags: Dict[base.Tag, base.TagValue] 86 annotations: List[base.Annotation] 87 88 89#-----------------------# 90# Transition properties # 91#-----------------------# 92 93class TransitionProperties(TypedDict, total=False): 94 """ 95 Represents bundled properties of a transition, including a 96 requirement, effects, tags, and/or annotations. Does not include the 97 reciprocal. Has the following slots: 98 99 - `'requirement'`: The requirement for the transition. This is 100 always a `Requirement`, although it might be `ReqNothing` if 101 nothing special is required. 102 - `'consequence'`: The `Consequence` of the transition. 103 - `'tags'`: Any tags applied to the transition (as a dictionary). 104 - `'annotations'`: A list of annotations applied to the transition. 105 """ 106 requirement: base.Requirement 107 consequence: base.Consequence 108 tags: Dict[base.Tag, base.TagValue] 109 annotations: List[base.Annotation] 110 111 112def mergeProperties( 113 a: Optional[TransitionProperties], 114 b: Optional[TransitionProperties] 115) -> TransitionProperties: 116 """ 117 Merges two sets of transition properties, following these rules: 118 119 1. Tags and annotations are combined. Annotations from the 120 second property set are ordered after those from the first. 121 2. If one of the transitions has a `ReqNothing` instance as its 122 requirement, we use the other requirement. If both have 123 complex requirements, we create a new `ReqAll` which 124 combines them as the requirement. 125 3. The consequences are merged by placing all of the consequences of 126 the first transition before those of the second one. This may in 127 some cases change the net outcome of those consequences, 128 because not all transition properties are compatible. (Imagine 129 merging two transitions one of which causes a capability to be 130 gained and the other of which causes a capability to be lost. 131 What should happen?). 132 4. The result will not list a reciprocal. 133 134 If either transition is `None`, then a deep copy of the other is 135 returned. If both are `None`, then an empty transition properties 136 dictionary is returned, with `ReqNothing` as the requirement, no 137 effects, no tags, and no annotations. 138 139 Deep copies of consequences are always made, so that any `Effects` 140 applications which edit effects won't end up with entangled effects. 141 """ 142 if a is None: 143 if b is None: 144 return { 145 "requirement": base.ReqNothing(), 146 "consequence": [], 147 "tags": {}, 148 "annotations": [] 149 } 150 else: 151 return copy.deepcopy(b) 152 elif b is None: 153 return copy.deepcopy(a) 154 # implicitly neither a or b is None below 155 156 result: TransitionProperties = { 157 "requirement": base.ReqNothing(), 158 "consequence": copy.deepcopy(a["consequence"] + b["consequence"]), 159 "tags": a["tags"] | b["tags"], 160 "annotations": a["annotations"] + b["annotations"] 161 } 162 163 if a["requirement"] == base.ReqNothing(): 164 result["requirement"] = b["requirement"] 165 elif b["requirement"] == base.ReqNothing(): 166 result["requirement"] = a["requirement"] 167 else: 168 result["requirement"] = base.ReqAll( 169 [a["requirement"], b["requirement"]] 170 ) 171 172 return result 173 174 175#---------------------# 176# Errors and warnings # 177#---------------------# 178 179class TransitionBlockedWarning(Warning): 180 """ 181 An warning type for indicating that a transition which has been 182 requested does not have its requirements satisfied by the current 183 game state. 184 """ 185 pass 186 187 188class BadStart(ValueError): 189 """ 190 An error raised when the start method is used improperly. 191 """ 192 pass 193 194 195class MissingDecisionError(KeyError): 196 """ 197 An error raised when attempting to use a decision that does not 198 exist. 199 """ 200 pass 201 202 203class AmbiguousDecisionSpecifierError(KeyError): 204 """ 205 An error raised when an ambiguous decision specifier is provided. 206 Note that if a decision specifier simply doesn't match anything, you 207 will get a `MissingDecisionError` instead. 208 """ 209 pass 210 211 212class AmbiguousTransitionError(KeyError): 213 """ 214 An error raised when an ambiguous transition is specified. 215 If a transition specifier simply doesn't match anything, you 216 will get a `MissingTransitionError` instead. 217 """ 218 pass 219 220 221class MissingTransitionError(KeyError): 222 """ 223 An error raised when attempting to use a transition that does not 224 exist. 225 """ 226 pass 227 228 229class MissingMechanismError(KeyError): 230 """ 231 An error raised when attempting to use a mechanism that does not 232 exist. 233 """ 234 pass 235 236 237class MissingZoneError(KeyError): 238 """ 239 An error raised when attempting to use a zone that does not exist. 240 """ 241 pass 242 243 244class InvalidLevelError(ValueError): 245 """ 246 An error raised when an operation fails because of an invalid zone 247 level. 248 """ 249 pass 250 251 252class InvalidDestinationError(ValueError): 253 """ 254 An error raised when attempting to perform an operation with a 255 transition but that transition does not lead to a destination that's 256 compatible with the operation. 257 """ 258 pass 259 260 261class ExplorationStatusError(ValueError): 262 """ 263 An error raised when attempting to perform an operation that 264 requires a previously-visited destination with a decision that 265 represents a not-yet-visited decision, or vice versa. For 266 `Situation`s, Exploration states 'unknown', 'hypothesized', and 267 'noticed' count as "not-yet-visited" while 'exploring' and 'explored' 268 count as "visited" (see `base.hasBeenVisited`) Meanwhile, in a 269 `DecisionGraph` where exploration statuses are not present, the 270 presence or absence of the 'unconfirmed' tag is used to determine 271 whether something has been confirmed or not. 272 """ 273 pass 274 275 276WARN_OF_NAME_COLLISIONS = False 277""" 278Whether or not to issue warnings when two decision names are the same. 279""" 280 281 282class DecisionCollisionWarning(Warning): 283 """ 284 A warning raised when attempting to create a new decision using the 285 name of a decision that already exists. 286 """ 287 pass 288 289 290class TransitionCollisionError(ValueError): 291 """ 292 An error raised when attempting to re-use a transition name for a 293 new transition, or otherwise when a transition name conflicts with 294 an already-established transition. 295 """ 296 pass 297 298 299class MechanismCollisionError(ValueError): 300 """ 301 An error raised when attempting to re-use a mechanism name at the 302 same decision where a mechanism with that name already exists. 303 """ 304 pass 305 306 307class DomainCollisionError(KeyError): 308 """ 309 An error raised when attempting to create a domain with the same 310 name as an existing domain. 311 """ 312 pass 313 314 315class MissingFocalContextError(KeyError): 316 """ 317 An error raised when attempting to pick out a focal context with a 318 name that doesn't exist. 319 """ 320 pass 321 322 323class FocalContextCollisionError(KeyError): 324 """ 325 An error raised when attempting to create a focal context with the 326 same name as an existing focal context. 327 """ 328 pass 329 330 331class InvalidActionError(TypeError): 332 """ 333 An error raised when attempting to take an exploration action which 334 is not correctly formed. 335 """ 336 pass 337 338 339class ImpossibleActionError(ValueError): 340 """ 341 An error raised when attempting to take an exploration action which 342 is correctly formed but which specifies an action that doesn't match 343 up with the graph state. 344 """ 345 pass 346 347 348class DoubleActionError(ValueError): 349 """ 350 An error raised when attempting to set up an `ExplorationAction` 351 when the current situation already has an action specified. 352 """ 353 pass 354 355 356class InactiveDomainWarning(Warning): 357 """ 358 A warning used when an inactive domain is referenced but the 359 operation in progress can still succeed (for example when 360 deactivating an already-inactive domain). 361 """ 362 363 364class ZoneCollisionError(ValueError): 365 """ 366 An error raised when attempting to re-use a zone name for a new zone, 367 or otherwise when a zone name conflicts with an already-established 368 zone. 369 """ 370 pass 371 372 373#---------------------# 374# DecisionGraph class # 375#---------------------# 376 377class DecisionGraph( 378 graphs.UniqueExitsGraph[base.DecisionID, base.Transition] 379): 380 """ 381 Represents a view of the world as a topological graph at a moment in 382 time. It derives from `networkx.MultiDiGraph`. 383 384 Each node (a `Decision`) represents a place in the world where there 385 are multiple opportunities for travel/action, or a dead end where 386 you must turn around and go back; typically this is a single room in 387 a game, but sometimes one room has multiple decision points. Edges 388 (`Transition`s) represent choices that can be made to travel to 389 other decision points (e.g., taking the left door), or when they are 390 self-edges, they represent actions that can be taken within a 391 location that affect the world or the game state. 392 393 Each `Transition` includes a `Effects` dictionary 394 indicating the effects that it has. Other effects of the transition 395 that are not simple enough to be included in this format may be 396 represented in an `DiscreteExploration` by changing the graph in the 397 next step to reflect further effects of a transition. 398 399 In addition to normal transitions between decisions, a 400 `DecisionGraph` can represent potential transitions which lead to 401 unknown destinations. These are represented by adding decisions with 402 the `'unconfirmed'` tag (whose names where not specified begin with 403 `'_u.'`) with a separate unconfirmed decision for each transition 404 (although where it's known that two transitions lead to the same 405 unconfirmed decision, this can be represented as well). 406 407 Both nodes and edges can have `Annotation`s associated with them that 408 include extra details about the explorer's perception of the 409 situation. They can also have `Tag`s, which represent specific 410 categories a transition or decision falls into. 411 412 Nodes can also be part of one or more `Zones`, and zones can also be 413 part of other zones, allowing for a hierarchical description of the 414 underlying space. 415 416 Equivalences can be specified to mark that some combination of 417 capabilities can stand in for another capability. 418 """ 419 def __init__(self) -> None: 420 super().__init__() 421 422 self.zones: Dict[base.Zone, base.ZoneInfo] = {} 423 """ 424 Mapping from zone names to zone info 425 """ 426 427 self.unknownCount: int = 0 428 """ 429 Number of unknown decisions that have been created (not number 430 of current unknown decisions, which is likely lower) 431 """ 432 433 self.equivalences: base.Equivalences = {} 434 """ 435 See `base.Equivalences`. Determines what capabilities and/or 436 mechanism states can count as active based on alternate 437 requirements. 438 """ 439 440 self.reversionTypes: Dict[str, Set[str]] = {} 441 """ 442 This tracks shorthand reversion types. See `base.revertedState` 443 for how these are applied. Keys are custom names and values are 444 reversion type strings that `base.revertedState` could access. 445 """ 446 447 self.nextID: base.DecisionID = 0 448 """ 449 The ID to use for the next new decision we create. 450 """ 451 452 self.nextMechanismID: base.MechanismID = 0 453 """ 454 ID for the next mechanism. 455 """ 456 457 self.mechanisms: Dict[ 458 base.MechanismID, 459 Tuple[Optional[base.DecisionID], base.MechanismName] 460 ] = {} 461 """ 462 Mapping from `MechanismID`s to (`DecisionID`, `MechanismName`) 463 pairs. For global mechanisms, the `DecisionID` is None. 464 """ 465 466 self.globalMechanisms: Dict[ 467 base.MechanismName, 468 base.MechanismID 469 ] = {} 470 """ 471 Global mechanisms 472 """ 473 474 self.nameLookup: Dict[base.DecisionName, List[base.DecisionID]] = {} 475 """ 476 A cache for name -> ID lookups 477 """ 478 479 # Note: not hashable 480 481 def __eq__(self, other): 482 """ 483 Equality checker. `DecisionGraph`s can only be equal to other 484 `DecisionGraph`s, not to other kinds of things. 485 """ 486 if not isinstance(other, DecisionGraph): 487 return False 488 else: 489 # Checks nodes, edges, and all attached data 490 if not super().__eq__(other): 491 return False 492 493 # Check unknown count 494 if self.unknownCount != other.unknownCount: 495 return False 496 497 # Check zones 498 if self.zones != other.zones: 499 return False 500 501 # Check equivalences 502 if self.equivalences != other.equivalences: 503 return False 504 505 # Check reversion types 506 if self.reversionTypes != other.reversionTypes: 507 return False 508 509 # Check mechanisms 510 if self.nextMechanismID != other.nextMechanismID: 511 return False 512 513 if self.mechanisms != other.mechanisms: 514 return False 515 516 if self.globalMechanisms != other.globalMechanisms: 517 return False 518 519 # Check names: 520 if self.nameLookup != other.nameLookup: 521 return False 522 523 return True 524 525 def listDifferences( 526 self, 527 other: 'DecisionGraph' 528 ) -> Generator[str, None, None]: 529 """ 530 Generates strings describing differences between this graph and 531 another graph. This does NOT perform graph matching, so it will 532 consider graphs different even if they have identical structures 533 but use different IDs for the nodes in those structures. 534 """ 535 if not isinstance(other, DecisionGraph): 536 yield "other is not a graph" 537 else: 538 suppress = False 539 myNodes = set(self.nodes) 540 theirNodes = set(other.nodes) 541 for n in myNodes: 542 if n not in theirNodes: 543 suppress = True 544 yield ( 545 f"other graph missing node {n}" 546 ) 547 else: 548 if self.nodes[n] != other.nodes[n]: 549 suppress = True 550 yield ( 551 f"other graph has differences at node {n}:" 552 f"\n Ours: {self.nodes[n]}" 553 f"\nTheirs: {other.nodes[n]}" 554 ) 555 myDests = self.destinationsFrom(n) 556 theirDests = other.destinationsFrom(n) 557 for tr in myDests: 558 myTo = myDests[tr] 559 if tr not in theirDests: 560 suppress = True 561 yield ( 562 f"at {self.identityOf(n)}: other graph" 563 f" missing transition {tr!r}" 564 ) 565 else: 566 theirTo = theirDests[tr] 567 if myTo != theirTo: 568 suppress = True 569 yield ( 570 f"at {self.identityOf(n)}: other" 571 f" graph transition {tr!r} leads to" 572 f" {theirTo} instead of {myTo}" 573 ) 574 else: 575 myProps = self.edges[n, myTo, tr] # type:ignore [index] # noqa 576 theirProps = other.edges[n, myTo, tr] # type:ignore [index] # noqa 577 if myProps != theirProps: 578 suppress = True 579 yield ( 580 f"at {self.identityOf(n)}: other" 581 f" graph transition {tr!r} has" 582 f" different properties:" 583 f"\n Ours: {myProps}" 584 f"\nTheirs: {theirProps}" 585 ) 586 for extra in theirNodes - myNodes: 587 suppress = True 588 yield ( 589 f"other graph has extra node {extra}" 590 ) 591 592 # TODO: Fix networkx stubs! 593 if self.graph != other.graph: # type:ignore [attr-defined] 594 suppress = True 595 yield ( 596 " different graph attributes:" # type:ignore [attr-defined] # noqa 597 f"\n Ours: {self.graph}" 598 f"\nTheirs: {other.graph}" 599 ) 600 601 # Checks any other graph data we might have missed 602 if not super().__eq__(other) and not suppress: 603 for attr in dir(self): 604 if attr.startswith('__') and attr.endswith('__'): 605 continue 606 if not hasattr(other, attr): 607 yield f"other graph missing attribute: {attr!r}" 608 else: 609 myVal = getattr(self, attr) 610 theirVal = getattr(other, attr) 611 if ( 612 myVal != theirVal 613 and not ((callable(myVal) and callable(theirVal))) 614 ): 615 yield ( 616 f"other has different val for {attr!r}:" 617 f"\n Ours: {myVal}" 618 f"\nTheirs: {theirVal}" 619 ) 620 for attr in sorted(set(dir(other)) - set(dir(self))): 621 yield f"other has extra attribute: {attr!r}" 622 yield "graph data is different" 623 # TODO: More detail here! 624 625 # Check unknown count 626 if self.unknownCount != other.unknownCount: 627 yield "unknown count is different" 628 629 # Check zones 630 if self.zones != other.zones: 631 yield "zones are different" 632 633 # Check equivalences 634 if self.equivalences != other.equivalences: 635 yield "equivalences are different" 636 637 # Check reversion types 638 if self.reversionTypes != other.reversionTypes: 639 yield "reversionTypes are different" 640 641 # Check mechanisms 642 if self.nextMechanismID != other.nextMechanismID: 643 yield "nextMechanismID is different" 644 645 if self.mechanisms != other.mechanisms: 646 yield "mechanisms are different" 647 648 if self.globalMechanisms != other.globalMechanisms: 649 yield "global mechanisms are different" 650 651 # Check names: 652 if self.nameLookup != other.nameLookup: 653 for name in self.nameLookup: 654 if name not in other.nameLookup: 655 yield ( 656 f"other graph is missing name lookup entry" 657 f" for {name!r}" 658 ) 659 else: 660 mine = self.nameLookup[name] 661 theirs = other.nameLookup[name] 662 if theirs != mine: 663 yield ( 664 f"name lookup for {name!r} is {theirs}" 665 f" instead of {mine}" 666 ) 667 extras = set(other.nameLookup) - set(self.nameLookup) 668 if extras: 669 yield ( 670 f"other graph has extra name lookup entries:" 671 f" {extras}" 672 ) 673 674 def _assignID(self) -> base.DecisionID: 675 """ 676 Returns the next `base.DecisionID` to use and increments the 677 next ID counter. 678 """ 679 result = self.nextID 680 self.nextID += 1 681 return result 682 683 def _assignMechanismID(self) -> base.MechanismID: 684 """ 685 Returns the next `base.MechanismID` to use and increments the 686 next ID counter. 687 """ 688 result = self.nextMechanismID 689 self.nextMechanismID += 1 690 return result 691 692 def decisionInfo(self, dID: base.DecisionID) -> DecisionInfo: 693 """ 694 Retrieves the decision info for the specified decision, as a 695 live editable dictionary. 696 697 For example: 698 699 >>> g = DecisionGraph() 700 >>> g.addDecision('A') 701 0 702 >>> g.annotateDecision('A', 'note') 703 >>> g.decisionInfo(0) 704 {'name': 'A', 'domain': 'main', 'tags': {}, 'annotations': ['note']} 705 """ 706 return cast(DecisionInfo, self.nodes[dID]) 707 708 def resolveDecision( 709 self, 710 spec: base.AnyDecisionSpecifier, 711 zoneHint: Optional[base.Zone] = None, 712 domainHint: Optional[base.Domain] = None 713 ) -> base.DecisionID: 714 """ 715 Given a decision specifier returns the ID associated with that 716 decision, or raises an `AmbiguousDecisionSpecifierError` or a 717 `MissingDecisionError` if the specified decision is either 718 missing or ambiguous. Cannot handle strings that contain domain 719 and/or zone parts; use 720 `parsing.ParseFormat.parseDecisionSpecifier` to turn such 721 strings into `DecisionSpecifier`s if you need to first. 722 723 Examples: 724 725 >>> g = DecisionGraph() 726 >>> g.addDecision('A') 727 0 728 >>> g.addDecision('B') 729 1 730 >>> g.addDecision('C') 731 2 732 >>> g.addDecision('A') 733 3 734 >>> g.addDecision('B', 'menu') 735 4 736 >>> g.createZone('Z', 0) 737 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 738 annotations=[]) 739 >>> g.createZone('Z2', 0) 740 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 741 annotations=[]) 742 >>> g.createZone('Zup', 1) 743 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 744 annotations=[]) 745 >>> g.addDecisionToZone(0, 'Z') 746 >>> g.addDecisionToZone(1, 'Z') 747 >>> g.addDecisionToZone(2, 'Z') 748 >>> g.addDecisionToZone(3, 'Z2') 749 >>> g.addZoneToZone('Z', 'Zup') 750 >>> g.addZoneToZone('Z2', 'Zup') 751 >>> g.resolveDecision(1) 752 1 753 >>> g.resolveDecision('A') 754 Traceback (most recent call last): 755 ... 756 exploration.core.AmbiguousDecisionSpecifierError... 757 >>> g.resolveDecision('B') 758 Traceback (most recent call last): 759 ... 760 exploration.core.AmbiguousDecisionSpecifierError... 761 >>> g.resolveDecision('C') 762 2 763 >>> g.resolveDecision('A', 'Z') 764 0 765 >>> g.resolveDecision('A', zoneHint='Z2') 766 3 767 >>> g.resolveDecision('B', domainHint='main') 768 1 769 >>> g.resolveDecision('B', None, 'menu') 770 4 771 >>> g.resolveDecision('B', zoneHint='Z2') 772 Traceback (most recent call last): 773 ... 774 exploration.core.MissingDecisionError... 775 >>> g.resolveDecision('A', domainHint='menu') 776 Traceback (most recent call last): 777 ... 778 exploration.core.MissingDecisionError... 779 >>> g.resolveDecision('A', domainHint='madeup') 780 Traceback (most recent call last): 781 ... 782 exploration.core.MissingDecisionError... 783 >>> g.resolveDecision('A', zoneHint='madeup') 784 Traceback (most recent call last): 785 ... 786 exploration.core.MissingDecisionError... 787 """ 788 # Parse it to either an ID or specifier if it's a string: 789 if isinstance(spec, str): 790 try: 791 spec = int(spec) 792 except ValueError: 793 pass 794 795 # If it's an ID, check for existence: 796 if isinstance(spec, base.DecisionID): 797 if spec in self: 798 return spec 799 else: 800 raise MissingDecisionError( 801 f"There is no decision with ID {spec!r}." 802 ) 803 else: 804 if isinstance(spec, base.DecisionName): 805 spec = base.DecisionSpecifier( 806 domain=None, 807 zone=None, 808 name=spec 809 ) 810 elif not isinstance(spec, base.DecisionSpecifier): 811 raise TypeError( 812 f"Specification is not provided as a" 813 f" DecisionSpecifier or other valid type. (got type" 814 f" {type(spec)})." 815 ) 816 817 # Merge domain hints from spec/args 818 if ( 819 spec.domain is not None 820 and domainHint is not None 821 and spec.domain != domainHint 822 ): 823 raise ValueError( 824 f"Specifier {repr(spec)} includes domain hint" 825 f" {repr(spec.domain)} which is incompatible with" 826 f" explicit domain hint {repr(domainHint)}." 827 ) 828 else: 829 domainHint = spec.domain or domainHint 830 831 # Merge zone hints from spec/args 832 if ( 833 spec.zone is not None 834 and zoneHint is not None 835 and spec.zone != zoneHint 836 ): 837 raise ValueError( 838 f"Specifier {repr(spec)} includes zone hint" 839 f" {repr(spec.zone)} which is incompatible with" 840 f" explicit zone hint {repr(zoneHint)}." 841 ) 842 else: 843 zoneHint = spec.zone or zoneHint 844 845 if spec.name not in self.nameLookup: 846 raise MissingDecisionError( 847 f"No decision named {repr(spec.name)}." 848 ) 849 else: 850 options = self.nameLookup[spec.name] 851 if len(options) == 0: 852 raise MissingDecisionError( 853 f"No decision named {repr(spec.name)}." 854 ) 855 filtered = [ 856 opt 857 for opt in options 858 if ( 859 domainHint is None 860 or self.domainFor(opt) == domainHint 861 ) and ( 862 zoneHint is None 863 or zoneHint in self.zoneAncestors(opt) 864 ) 865 ] 866 if len(filtered) == 1: 867 return filtered[0] 868 else: 869 filterDesc = "" 870 if domainHint is not None: 871 filterDesc += f" in domain {repr(domainHint)}" 872 if zoneHint is not None: 873 filterDesc += f" in zone {repr(zoneHint)}" 874 if len(filtered) == 0: 875 raise MissingDecisionError( 876 f"No decisions named" 877 f" {repr(spec.name)}{filterDesc}." 878 ) 879 else: 880 raise AmbiguousDecisionSpecifierError( 881 f"There are {len(filtered)} decisions" 882 f" named {repr(spec.name)}{filterDesc}." 883 ) 884 885 def getDecision( 886 self, 887 decision: base.AnyDecisionSpecifier, 888 zoneHint: Optional[base.Zone] = None, 889 domainHint: Optional[base.Domain] = None 890 ) -> Optional[base.DecisionID]: 891 """ 892 Works like `resolveDecision` but returns None instead of raising 893 a `MissingDecisionError` if the specified decision isn't listed. 894 May still raise an `AmbiguousDecisionSpecifierError`. 895 """ 896 try: 897 return self.resolveDecision( 898 decision, 899 zoneHint, 900 domainHint 901 ) 902 except MissingDecisionError: 903 return None 904 905 def nameFor( 906 self, 907 decision: base.AnyDecisionSpecifier 908 ) -> base.DecisionName: 909 """ 910 Returns the name of the specified decision. Note that names are 911 not necessarily unique. 912 913 Example: 914 915 >>> d = DecisionGraph() 916 >>> d.addDecision('A') 917 0 918 >>> d.addDecision('B') 919 1 920 >>> d.addDecision('B') 921 2 922 >>> d.nameFor(0) 923 'A' 924 >>> d.nameFor(1) 925 'B' 926 >>> d.nameFor(2) 927 'B' 928 >>> d.nameFor(3) 929 Traceback (most recent call last): 930 ... 931 exploration.core.MissingDecisionError... 932 """ 933 dID = self.resolveDecision(decision) 934 return self.nodes[dID]['name'] 935 936 def identityOf( 937 self, 938 decision: Optional[base.AnyDecisionSpecifier], 939 includeZones: bool = True, 940 alwaysDomain: Optional[bool] = None 941 ) -> str: 942 """ 943 Returns a string containing the given decision ID and the name 944 for that decision in parentheses afterwards. If the value 945 provided is `None`, it returns the string "(nowhere)". 946 947 If `includeZones` is true (the default) then zone information 948 is included before the decision name. 949 950 If `alwaysDomain` is true or false, then the domain information 951 will always (or never) be included. If it's `None` (the default) 952 then domain info will only be included for decisions which are 953 not in the default domain. 954 """ 955 if decision is None: 956 return "(nowhere)" 957 else: 958 dID = self.resolveDecision(decision) 959 thisDomain = self.domainFor(dID) 960 dSpec = '' 961 zSpec = '' 962 if ( 963 alwaysDomain is True 964 or ( 965 alwaysDomain is None 966 and thisDomain != base.DEFAULT_DOMAIN 967 ) 968 ): 969 dSpec = thisDomain + '//' # TODO: Don't hardcode this? 970 if includeZones: 971 zones = [ 972 z 973 for z in self.zoneParents(dID) 974 if self.zones[z].level == 0 975 ] 976 if len(zones) == 1: 977 zSpec = zones[0] + '::' # TODO: Don't hardcode this? 978 elif len(zones) > 1: 979 zSpec = '[' + ', '.join(sorted(zones)) + ']::' 980 # else leave zSpec empty 981 982 return f"{dID} ({dSpec}{zSpec}{self.nameFor(dID)})" 983 984 def namesListing( 985 self, 986 decisions: Collection[base.DecisionID], 987 includeZones: bool = True, 988 indent: int = 2 989 ) -> str: 990 """ 991 Returns a multi-line string containing an indented listing of 992 the provided decision IDs with their names in parentheses after 993 each. Useful for debugging & error messages. 994 995 Includes level-0 zones where applicable, with a zone separator 996 before the decision, unless `includeZones` is set to False. Where 997 there are multiple level-0 zones, they're listed together in 998 brackets. 999 1000 Uses the string '(none)' when there are no decisions are in the 1001 list. 1002 1003 Set `indent` to something other than 2 to control how much 1004 indentation is added. 1005 1006 For example: 1007 1008 >>> g = DecisionGraph() 1009 >>> g.addDecision('A') 1010 0 1011 >>> g.addDecision('B') 1012 1 1013 >>> g.addDecision('C') 1014 2 1015 >>> g.namesListing(['A', 'C', 'B']) 1016 ' 0 (A)\\n 2 (C)\\n 1 (B)\\n' 1017 >>> g.namesListing([]) 1018 ' (none)\\n' 1019 >>> g.createZone('zone', 0) 1020 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1021 annotations=[]) 1022 >>> g.createZone('zone2', 0) 1023 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1024 annotations=[]) 1025 >>> g.createZone('zoneUp', 1) 1026 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 1027 annotations=[]) 1028 >>> g.addDecisionToZone(0, 'zone') 1029 >>> g.addDecisionToZone(1, 'zone') 1030 >>> g.addDecisionToZone(1, 'zone2') 1031 >>> g.addDecisionToZone(2, 'zoneUp') # won't be listed: it's level-1 1032 >>> g.namesListing(['A', 'C', 'B']) 1033 ' 0 (zone::A)\\n 2 (C)\\n 1 ([zone, zone2]::B)\\n' 1034 """ 1035 ind = ' ' * indent 1036 if len(decisions) == 0: 1037 return ind + '(none)\n' 1038 else: 1039 result = '' 1040 for dID in decisions: 1041 result += ind + self.identityOf(dID, includeZones) + '\n' 1042 return result 1043 1044 def destinationsListing( 1045 self, 1046 destinations: Dict[base.Transition, base.DecisionID], 1047 includeZones: bool = True, 1048 indent: int = 2 1049 ) -> str: 1050 """ 1051 Returns a multi-line string containing an indented listing of 1052 the provided transitions along with their destinations and the 1053 names of those destinations in parentheses. Useful for debugging 1054 & error messages. (Use e.g., `destinationsFrom` to get a 1055 transitions -> destinations dictionary in the required format.) 1056 1057 Uses the string '(no transitions)' when there are no transitions 1058 in the dictionary. 1059 1060 Set `indent` to something other than 2 to control how much 1061 indentation is added. 1062 1063 For example: 1064 1065 >>> g = DecisionGraph() 1066 >>> g.addDecision('A') 1067 0 1068 >>> g.addDecision('B') 1069 1 1070 >>> g.addDecision('C') 1071 2 1072 >>> g.addTransition('A', 'north', 'B', 'south') 1073 >>> g.addTransition('B', 'east', 'C', 'west') 1074 >>> g.addTransition('C', 'southwest', 'A', 'northeast') 1075 >>> g.destinationsListing(g.destinationsFrom('A')) 1076 ' north to 1 (B)\\n northeast to 2 (C)\\n' 1077 >>> g.destinationsListing(g.destinationsFrom('B')) 1078 ' south to 0 (A)\\n east to 2 (C)\\n' 1079 >>> g.destinationsListing({}) 1080 ' (none)\\n' 1081 >>> g.createZone('zone', 0) 1082 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1083 annotations=[]) 1084 >>> g.addDecisionToZone(0, 'zone') 1085 >>> g.destinationsListing(g.destinationsFrom('B')) 1086 ' south to 0 (zone::A)\\n east to 2 (C)\\n' 1087 """ 1088 ind = ' ' * indent 1089 if len(destinations) == 0: 1090 return ind + '(none)\n' 1091 else: 1092 result = '' 1093 for transition, dID in destinations.items(): 1094 line = f"{transition} to {self.identityOf(dID, includeZones)}" 1095 result += ind + line + '\n' 1096 return result 1097 1098 def domainFor(self, decision: base.AnyDecisionSpecifier) -> base.Domain: 1099 """ 1100 Returns the domain that a decision belongs to. 1101 """ 1102 dID = self.resolveDecision(decision) 1103 return self.nodes[dID]['domain'] 1104 1105 def allDecisionsInDomain( 1106 self, 1107 domain: base.Domain 1108 ) -> Set[base.DecisionID]: 1109 """ 1110 Returns the set of all `DecisionID`s for decisions in the 1111 specified domain. 1112 """ 1113 return set(dID for dID in self if self.nodes[dID]['domain'] == domain) 1114 1115 def destination( 1116 self, 1117 decision: base.AnyDecisionSpecifier, 1118 transition: base.Transition 1119 ) -> base.DecisionID: 1120 """ 1121 Overrides base `UniqueExitsGraph.destination` to raise 1122 `MissingDecisionError` or `MissingTransitionError` as 1123 appropriate, and to work with an `AnyDecisionSpecifier`. 1124 """ 1125 dID = self.resolveDecision(decision) 1126 try: 1127 return super().destination(dID, transition) 1128 except KeyError: 1129 raise MissingTransitionError( 1130 f"Transition {transition!r} does not exist at decision" 1131 f" {self.identityOf(dID)}." 1132 ) 1133 1134 def getDestination( 1135 self, 1136 decision: base.AnyDecisionSpecifier, 1137 transition: base.Transition, 1138 default: Any = None 1139 ) -> Optional[base.DecisionID]: 1140 """ 1141 Overrides base `UniqueExitsGraph.getDestination` with different 1142 argument names, since those matter for the edit DSL. 1143 """ 1144 dID = self.resolveDecision(decision) 1145 return super().getDestination(dID, transition) 1146 1147 def destinationsFrom( 1148 self, 1149 decision: base.AnyDecisionSpecifier 1150 ) -> Dict[base.Transition, base.DecisionID]: 1151 """ 1152 Override that just changes the type of the exception from a 1153 `KeyError` to a `MissingDecisionError` when the source does not 1154 exist. 1155 """ 1156 dID = self.resolveDecision(decision) 1157 return super().destinationsFrom(dID) 1158 1159 def bothEnds( 1160 self, 1161 decision: base.AnyDecisionSpecifier, 1162 transition: base.Transition 1163 ) -> Set[base.DecisionID]: 1164 """ 1165 Returns a set containing the `DecisionID`(s) for both the start 1166 and end of the specified transition. Raises a 1167 `MissingDecisionError` or `MissingTransitionError`if the 1168 specified decision and/or transition do not exist. 1169 1170 Note that for actions since the source and destination are the 1171 same, the set will have only one element. 1172 """ 1173 dID = self.resolveDecision(decision) 1174 result = {dID} 1175 dest = self.destination(dID, transition) 1176 if dest is not None: 1177 result.add(dest) 1178 return result 1179 1180 def decisionActions( 1181 self, 1182 decision: base.AnyDecisionSpecifier 1183 ) -> Set[base.Transition]: 1184 """ 1185 Retrieves the set of self-edges at a decision. Editing the set 1186 will not affect the graph. 1187 1188 Example: 1189 1190 >>> g = DecisionGraph() 1191 >>> g.addDecision('A') 1192 0 1193 >>> g.addDecision('B') 1194 1 1195 >>> g.addDecision('C') 1196 2 1197 >>> g.addAction('A', 'action1') 1198 >>> g.addAction('A', 'action2') 1199 >>> g.addAction('B', 'action3') 1200 >>> sorted(g.decisionActions('A')) 1201 ['action1', 'action2'] 1202 >>> g.decisionActions('B') 1203 {'action3'} 1204 >>> g.decisionActions('C') 1205 set() 1206 """ 1207 result = set() 1208 dID = self.resolveDecision(decision) 1209 for transition, dest in self.destinationsFrom(dID).items(): 1210 if dest == dID: 1211 result.add(transition) 1212 return result 1213 1214 def getTransitionProperties( 1215 self, 1216 decision: base.AnyDecisionSpecifier, 1217 transition: base.Transition 1218 ) -> TransitionProperties: 1219 """ 1220 Returns a dictionary containing transition properties for the 1221 specified transition from the specified decision. The properties 1222 included are: 1223 1224 - 'requirement': The requirement for the transition. 1225 - 'consequence': Any consequence of the transition. 1226 - 'tags': Any tags applied to the transition. 1227 - 'annotations': Any annotations on the transition. 1228 1229 The reciprocal of the transition is not included. 1230 1231 The result is a clone of the stored properties; edits to the 1232 dictionary will NOT modify the graph. 1233 """ 1234 dID = self.resolveDecision(decision) 1235 dest = self.destination(dID, transition) 1236 1237 info: TransitionProperties = copy.deepcopy( 1238 self.edges[dID, dest, transition] # type:ignore 1239 ) 1240 return { 1241 'requirement': info.get('requirement', base.ReqNothing()), 1242 'consequence': info.get('consequence', []), 1243 'tags': info.get('tags', {}), 1244 'annotations': info.get('annotations', []) 1245 } 1246 1247 def setTransitionProperties( 1248 self, 1249 decision: base.AnyDecisionSpecifier, 1250 transition: base.Transition, 1251 requirement: Optional[base.Requirement] = None, 1252 consequence: Optional[base.Consequence] = None, 1253 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 1254 annotations: Optional[List[base.Annotation]] = None 1255 ) -> None: 1256 """ 1257 Sets one or more transition properties all at once. Can be used 1258 to set the requirement, consequence, tags, and/or annotations. 1259 Old values are overwritten, although if `None`s are provided (or 1260 arguments are omitted), corresponding properties are not 1261 updated. 1262 1263 To add tags or annotations to existing tags/annotations instead 1264 of replacing them, use `tagTransition` or `annotateTransition` 1265 instead. 1266 """ 1267 dID = self.resolveDecision(decision) 1268 if requirement is not None: 1269 self.setTransitionRequirement(dID, transition, requirement) 1270 if consequence is not None: 1271 self.setConsequence(dID, transition, consequence) 1272 if tags is not None: 1273 dest = self.destination(dID, transition) 1274 # TODO: Submit pull request to update MultiDiGraph stubs in 1275 # types-networkx to include OutMultiEdgeView that accepts 1276 # from/to/key tuples as indices. 1277 info = cast( 1278 TransitionProperties, 1279 self.edges[dID, dest, transition] # type:ignore 1280 ) 1281 info['tags'] = tags 1282 if annotations is not None: 1283 dest = self.destination(dID, transition) 1284 info = cast( 1285 TransitionProperties, 1286 self.edges[dID, dest, transition] # type:ignore 1287 ) 1288 info['annotations'] = annotations 1289 1290 def getTransitionRequirement( 1291 self, 1292 decision: base.AnyDecisionSpecifier, 1293 transition: base.Transition 1294 ) -> base.Requirement: 1295 """ 1296 Returns the `Requirement` for accessing a specific transition at 1297 a specific decision. For transitions which don't have 1298 requirements, returns a `ReqNothing` instance. 1299 """ 1300 dID = self.resolveDecision(decision) 1301 dest = self.destination(dID, transition) 1302 1303 info = cast( 1304 TransitionProperties, 1305 self.edges[dID, dest, transition] # type:ignore 1306 ) 1307 1308 return info.get('requirement', base.ReqNothing()) 1309 1310 def setTransitionRequirement( 1311 self, 1312 decision: base.AnyDecisionSpecifier, 1313 transition: base.Transition, 1314 requirement: Optional[base.Requirement] 1315 ) -> None: 1316 """ 1317 Sets the `Requirement` for accessing a specific transition at 1318 a specific decision. Raises a `KeyError` if the decision or 1319 transition does not exist. 1320 1321 Deletes the requirement if `None` is given as the requirement. 1322 1323 Use `parsing.ParseFormat.parseRequirement` first if you have a 1324 requirement in string format. 1325 1326 Does not raise an error if deletion is requested for a 1327 non-existent requirement, and silently overwrites any previous 1328 requirement. 1329 """ 1330 dID = self.resolveDecision(decision) 1331 1332 dest = self.destination(dID, transition) 1333 1334 info = cast( 1335 TransitionProperties, 1336 self.edges[dID, dest, transition] # type:ignore 1337 ) 1338 1339 if requirement is None: 1340 try: 1341 del info['requirement'] 1342 except KeyError: 1343 pass 1344 else: 1345 if not isinstance(requirement, base.Requirement): 1346 raise TypeError( 1347 f"Invalid requirement type: {type(requirement)}" 1348 ) 1349 1350 info['requirement'] = requirement 1351 1352 def getConsequence( 1353 self, 1354 decision: base.AnyDecisionSpecifier, 1355 transition: base.Transition 1356 ) -> base.Consequence: 1357 """ 1358 Retrieves the consequence of a transition. 1359 1360 A `KeyError` is raised if the specified decision/transition 1361 combination doesn't exist. 1362 """ 1363 dID = self.resolveDecision(decision) 1364 1365 dest = self.destination(dID, transition) 1366 1367 info = cast( 1368 TransitionProperties, 1369 self.edges[dID, dest, transition] # type:ignore 1370 ) 1371 1372 return info.get('consequence', []) 1373 1374 def addConsequence( 1375 self, 1376 decision: base.AnyDecisionSpecifier, 1377 transition: base.Transition, 1378 consequence: base.Consequence 1379 ) -> Tuple[int, int]: 1380 """ 1381 Adds the given `Consequence` to the consequence list for the 1382 specified transition, extending that list at the end. Note that 1383 this does NOT make a copy of the consequence, so it should not 1384 be used to copy consequences from one transition to another 1385 without making a deep copy first. 1386 1387 A `MissingDecisionError` or a `MissingTransitionError` is raised 1388 if the specified decision/transition combination doesn't exist. 1389 1390 Returns a pair of integers indicating the minimum and maximum 1391 depth-first-traversal-indices of the added consequence part(s). 1392 The outer consequence list itself (index 0) is not counted. 1393 1394 >>> d = DecisionGraph() 1395 >>> d.addDecision('A') 1396 0 1397 >>> d.addDecision('B') 1398 1 1399 >>> d.addTransition('A', 'fwd', 'B', 'rev') 1400 >>> d.addConsequence('A', 'fwd', [base.effect(gain='sword')]) 1401 (1, 1) 1402 >>> d.addConsequence('B', 'rev', [base.effect(lose='sword')]) 1403 (1, 1) 1404 >>> ef = d.getConsequence('A', 'fwd') 1405 >>> er = d.getConsequence('B', 'rev') 1406 >>> ef == [base.effect(gain='sword')] 1407 True 1408 >>> er == [base.effect(lose='sword')] 1409 True 1410 >>> d.addConsequence('A', 'fwd', [base.effect(deactivate=True)]) 1411 (2, 2) 1412 >>> ef = d.getConsequence('A', 'fwd') 1413 >>> ef == [base.effect(gain='sword'), base.effect(deactivate=True)] 1414 True 1415 >>> d.addConsequence( 1416 ... 'A', 1417 ... 'fwd', # adding to consequence with 3 parts already 1418 ... [ # outer list not counted because it merges 1419 ... base.challenge( # 1 part 1420 ... None, 1421 ... 0, 1422 ... [base.effect(gain=('flowers', 3))], # 2 parts 1423 ... [base.effect(gain=('flowers', 1))] # 2 parts 1424 ... ) 1425 ... ] 1426 ... ) # note indices below are inclusive; indices are 3, 4, 5, 6, 7 1427 (3, 7) 1428 """ 1429 dID = self.resolveDecision(decision) 1430 1431 dest = self.destination(dID, transition) 1432 1433 info = cast( 1434 TransitionProperties, 1435 self.edges[dID, dest, transition] # type:ignore 1436 ) 1437 1438 existing = info.setdefault('consequence', []) 1439 startIndex = base.countParts(existing) 1440 existing.extend(consequence) 1441 endIndex = base.countParts(existing) - 1 1442 return (startIndex, endIndex) 1443 1444 def setConsequence( 1445 self, 1446 decision: base.AnyDecisionSpecifier, 1447 transition: base.Transition, 1448 consequence: base.Consequence 1449 ) -> None: 1450 """ 1451 Replaces the transition consequence for the given transition at 1452 the given decision. Any previous consequence is discarded. See 1453 `Consequence` for the structure of these. Note that this does 1454 NOT make a copy of the consequence, do that first to avoid 1455 effect-entanglement if you're copying a consequence. 1456 1457 A `MissingDecisionError` or a `MissingTransitionError` is raised 1458 if the specified decision/transition combination doesn't exist. 1459 """ 1460 dID = self.resolveDecision(decision) 1461 1462 dest = self.destination(dID, transition) 1463 1464 info = cast( 1465 TransitionProperties, 1466 self.edges[dID, dest, transition] # type:ignore 1467 ) 1468 1469 info['consequence'] = consequence 1470 1471 def addEquivalence( 1472 self, 1473 requirement: base.Requirement, 1474 capabilityOrMechanismState: Union[ 1475 base.Capability, 1476 Tuple[base.MechanismID, base.MechanismState] 1477 ] 1478 ) -> None: 1479 """ 1480 Adds the given requirement as an equivalence for the given 1481 capability or the given mechanism state. Note that having a 1482 capability via an equivalence does not count as actually having 1483 that capability; it only counts for the purpose of satisfying 1484 `Requirement`s. 1485 1486 Note also that because a mechanism-based requirement looks up 1487 the specific mechanism locally based on a name, an equivalence 1488 defined in one location may affect mechanism requirements in 1489 other locations unless the mechanism name in the requirement is 1490 zone-qualified to be specific. But in such situations the base 1491 mechanism would have caused issues in any case. 1492 """ 1493 self.equivalences.setdefault( 1494 capabilityOrMechanismState, 1495 set() 1496 ).add(requirement) 1497 1498 def removeEquivalence( 1499 self, 1500 requirement: base.Requirement, 1501 capabilityOrMechanismState: Union[ 1502 base.Capability, 1503 Tuple[base.MechanismID, base.MechanismState] 1504 ] 1505 ) -> None: 1506 """ 1507 Removes an equivalence. Raises a `KeyError` if no such 1508 equivalence existed. 1509 """ 1510 self.equivalences[capabilityOrMechanismState].remove(requirement) 1511 1512 def hasAnyEquivalents( 1513 self, 1514 capabilityOrMechanismState: Union[ 1515 base.Capability, 1516 Tuple[base.MechanismID, base.MechanismState] 1517 ] 1518 ) -> bool: 1519 """ 1520 Returns `True` if the given capability or mechanism state has at 1521 least one equivalence. 1522 """ 1523 return capabilityOrMechanismState in self.equivalences 1524 1525 def allEquivalents( 1526 self, 1527 capabilityOrMechanismState: Union[ 1528 base.Capability, 1529 Tuple[base.MechanismID, base.MechanismState] 1530 ] 1531 ) -> Set[base.Requirement]: 1532 """ 1533 Returns the set of equivalences for the given capability. This is 1534 a live set which may be modified (it's probably better to use 1535 `addEquivalence` and `removeEquivalence` instead...). 1536 """ 1537 return self.equivalences.setdefault( 1538 capabilityOrMechanismState, 1539 set() 1540 ) 1541 1542 def reversionType(self, name: str, equivalentTo: Set[str]) -> None: 1543 """ 1544 Specifies a new reversion type, so that when used in a reversion 1545 aspects set with a colon before the name, all items in the 1546 `equivalentTo` value will be added to that set. These may 1547 include other custom reversion type names (with the colon) but 1548 take care not to create an equivalence loop which would result 1549 in a crash. 1550 1551 If you re-use the same name, it will override the old equivalence 1552 for that name. 1553 """ 1554 self.reversionTypes[name] = equivalentTo 1555 1556 def addAction( 1557 self, 1558 decision: base.AnyDecisionSpecifier, 1559 action: base.Transition, 1560 requires: Optional[base.Requirement] = None, 1561 consequence: Optional[base.Consequence] = None, 1562 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 1563 annotations: Optional[List[base.Annotation]] = None, 1564 ) -> None: 1565 """ 1566 Adds the given action as a possibility at the given decision. An 1567 action is just a self-edge, which can have requirements like any 1568 edge, and which can have consequences like any edge. 1569 The optional arguments are given to `setTransitionRequirement` 1570 and `setConsequence`; see those functions for descriptions 1571 of what they mean. 1572 1573 Raises a `KeyError` if a transition with the given name already 1574 exists at the given decision. 1575 """ 1576 if tags is None: 1577 tags = {} 1578 if annotations is None: 1579 annotations = [] 1580 1581 dID = self.resolveDecision(decision) 1582 1583 self.add_edge( 1584 dID, 1585 dID, 1586 key=action, 1587 tags=tags, 1588 annotations=annotations 1589 ) 1590 self.setTransitionRequirement(dID, action, requires) 1591 if consequence is not None: 1592 self.setConsequence(dID, action, consequence) 1593 1594 def tagDecision( 1595 self, 1596 decision: base.AnyDecisionSpecifier, 1597 tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]], 1598 tagValue: Union[ 1599 base.TagValue, 1600 type[base.NoTagValue] 1601 ] = base.NoTagValue 1602 ) -> None: 1603 """ 1604 Adds a tag (or many tags from a dictionary of tags) to a 1605 decision, using `1` as the value if no value is provided. It's 1606 a `ValueError` to provide a value when a dictionary of tags is 1607 provided to set multiple tags at once. 1608 1609 Note that certain tags have special meanings: 1610 1611 - 'unconfirmed' is used for decisions that represent unconfirmed 1612 parts of the graph (this is separate from the 'unknown' 1613 and/or 'hypothesized' exploration statuses, which are only 1614 tracked in a `DiscreteExploration`, not in a `DecisionGraph`). 1615 Various methods require this tag and many also add or remove 1616 it. 1617 """ 1618 if isinstance(tagOrTags, base.Tag): 1619 if tagValue is base.NoTagValue: 1620 tagValue = 1 1621 1622 # Not sure why this cast is necessary given the `if` above... 1623 tagValue = cast(base.TagValue, tagValue) 1624 1625 tagOrTags = {tagOrTags: tagValue} 1626 1627 elif tagValue is not base.NoTagValue: 1628 raise ValueError( 1629 "Provided a dictionary to update multiple tags, but" 1630 " also a tag value." 1631 ) 1632 1633 dID = self.resolveDecision(decision) 1634 1635 tagsAlready = self.nodes[dID].setdefault('tags', {}) 1636 tagsAlready.update(tagOrTags) 1637 1638 def untagDecision( 1639 self, 1640 decision: base.AnyDecisionSpecifier, 1641 tag: base.Tag 1642 ) -> Union[base.TagValue, type[base.NoTagValue]]: 1643 """ 1644 Removes a tag from a decision. Returns the tag's old value if 1645 the tag was present and got removed, or `NoTagValue` if the tag 1646 wasn't present. 1647 """ 1648 dID = self.resolveDecision(decision) 1649 1650 target = self.nodes[dID]['tags'] 1651 try: 1652 return target.pop(tag) 1653 except KeyError: 1654 return base.NoTagValue 1655 1656 def decisionTags( 1657 self, 1658 decision: base.AnyDecisionSpecifier 1659 ) -> Dict[base.Tag, base.TagValue]: 1660 """ 1661 Returns the dictionary of tags for a decision. Edits to the 1662 returned value will be applied to the graph. 1663 """ 1664 dID = self.resolveDecision(decision) 1665 1666 return self.nodes[dID]['tags'] 1667 1668 def annotateDecision( 1669 self, 1670 decision: base.AnyDecisionSpecifier, 1671 annotationOrAnnotations: Union[ 1672 base.Annotation, 1673 Sequence[base.Annotation] 1674 ] 1675 ) -> None: 1676 """ 1677 Adds an annotation to a decision's annotations list. 1678 """ 1679 dID = self.resolveDecision(decision) 1680 1681 if isinstance(annotationOrAnnotations, base.Annotation): 1682 annotationOrAnnotations = [annotationOrAnnotations] 1683 self.nodes[dID]['annotations'].extend(annotationOrAnnotations) 1684 1685 def decisionAnnotations( 1686 self, 1687 decision: base.AnyDecisionSpecifier 1688 ) -> List[base.Annotation]: 1689 """ 1690 Returns the list of annotations for the specified decision. 1691 Modifying the list affects the graph. 1692 """ 1693 dID = self.resolveDecision(decision) 1694 1695 return self.nodes[dID]['annotations'] 1696 1697 def tagTransition( 1698 self, 1699 decision: base.AnyDecisionSpecifier, 1700 transition: base.Transition, 1701 tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]], 1702 tagValue: Union[ 1703 base.TagValue, 1704 type[base.NoTagValue] 1705 ] = base.NoTagValue 1706 ) -> None: 1707 """ 1708 Adds a tag (or each tag from a dictionary) to a transition 1709 coming out of a specific decision. `1` will be used as the 1710 default value if a single tag is supplied; supplying a tag value 1711 when providing a dictionary of multiple tags to update is a 1712 `ValueError`. 1713 1714 Note that certain transition tags have special meanings: 1715 - 'trigger' causes any actions (but not normal transitions) that 1716 it applies to to be automatically triggered when 1717 `advanceSituation` is called and the decision they're 1718 attached to is active in the new situation (as long as the 1719 action's requirements are met). This happens once per 1720 situation; use 'wait' steps to re-apply triggers. 1721 """ 1722 dID = self.resolveDecision(decision) 1723 1724 dest = self.destination(dID, transition) 1725 if isinstance(tagOrTags, base.Tag): 1726 if tagValue is base.NoTagValue: 1727 tagValue = 1 1728 1729 # Not sure why this is necessary given the `if` above... 1730 tagValue = cast(base.TagValue, tagValue) 1731 1732 tagOrTags = {tagOrTags: tagValue} 1733 elif tagValue is not base.NoTagValue: 1734 raise ValueError( 1735 "Provided a dictionary to update multiple tags, but" 1736 " also a tag value." 1737 ) 1738 1739 info = cast( 1740 TransitionProperties, 1741 self.edges[dID, dest, transition] # type:ignore 1742 ) 1743 1744 info.setdefault('tags', {}).update(tagOrTags) 1745 1746 def untagTransition( 1747 self, 1748 decision: base.AnyDecisionSpecifier, 1749 transition: base.Transition, 1750 tagOrTags: Union[base.Tag, Set[base.Tag]] 1751 ) -> None: 1752 """ 1753 Removes a tag (or each tag in a set) from a transition coming out 1754 of a specific decision. Raises a `KeyError` if (one of) the 1755 specified tag(s) is not currently applied to the specified 1756 transition. 1757 """ 1758 dID = self.resolveDecision(decision) 1759 1760 dest = self.destination(dID, transition) 1761 if isinstance(tagOrTags, base.Tag): 1762 tagOrTags = {tagOrTags} 1763 1764 info = cast( 1765 TransitionProperties, 1766 self.edges[dID, dest, transition] # type:ignore 1767 ) 1768 tagsAlready = info.setdefault('tags', {}) 1769 1770 for tag in tagOrTags: 1771 tagsAlready.pop(tag) 1772 1773 def transitionTags( 1774 self, 1775 decision: base.AnyDecisionSpecifier, 1776 transition: base.Transition 1777 ) -> Dict[base.Tag, base.TagValue]: 1778 """ 1779 Returns the dictionary of tags for a transition. Edits to the 1780 returned dictionary will be applied to the graph. 1781 """ 1782 dID = self.resolveDecision(decision) 1783 1784 dest = self.destination(dID, transition) 1785 info = cast( 1786 TransitionProperties, 1787 self.edges[dID, dest, transition] # type:ignore 1788 ) 1789 return info.setdefault('tags', {}) 1790 1791 def annotateTransition( 1792 self, 1793 decision: base.AnyDecisionSpecifier, 1794 transition: base.Transition, 1795 annotations: Union[base.Annotation, Sequence[base.Annotation]] 1796 ) -> None: 1797 """ 1798 Adds an annotation (or a sequence of annotations) to a 1799 transition's annotations list. 1800 """ 1801 dID = self.resolveDecision(decision) 1802 1803 dest = self.destination(dID, transition) 1804 if isinstance(annotations, base.Annotation): 1805 annotations = [annotations] 1806 info = cast( 1807 TransitionProperties, 1808 self.edges[dID, dest, transition] # type:ignore 1809 ) 1810 info['annotations'].extend(annotations) 1811 1812 def transitionAnnotations( 1813 self, 1814 decision: base.AnyDecisionSpecifier, 1815 transition: base.Transition 1816 ) -> List[base.Annotation]: 1817 """ 1818 Returns the annotation list for a specific transition at a 1819 specific decision. Editing the list affects the graph. 1820 """ 1821 dID = self.resolveDecision(decision) 1822 1823 dest = self.destination(dID, transition) 1824 info = cast( 1825 TransitionProperties, 1826 self.edges[dID, dest, transition] # type:ignore 1827 ) 1828 return info['annotations'] 1829 1830 def annotateZone( 1831 self, 1832 zone: base.Zone, 1833 annotations: Union[base.Annotation, Sequence[base.Annotation]] 1834 ) -> None: 1835 """ 1836 Adds an annotation (or many annotations from a sequence) to a 1837 zone. 1838 1839 Raises a `MissingZoneError` if the specified zone does not exist. 1840 """ 1841 if zone not in self.zones: 1842 raise MissingZoneError( 1843 f"Can't add annotation(s) to zone {zone!r} because that" 1844 f" zone doesn't exist yet." 1845 ) 1846 1847 if isinstance(annotations, base.Annotation): 1848 annotations = [ annotations ] 1849 1850 self.zones[zone].annotations.extend(annotations) 1851 1852 def zoneAnnotations(self, zone: base.Zone) -> List[base.Annotation]: 1853 """ 1854 Returns the list of annotations for the specified zone (empty if 1855 none have been added yet). 1856 """ 1857 return self.zones[zone].annotations 1858 1859 def tagZone( 1860 self, 1861 zone: base.Zone, 1862 tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]], 1863 tagValue: Union[ 1864 base.TagValue, 1865 type[base.NoTagValue] 1866 ] = base.NoTagValue 1867 ) -> None: 1868 """ 1869 Adds a tag (or many tags from a dictionary of tags) to a 1870 zone, using `1` as the value if no value is provided. It's 1871 a `ValueError` to provide a value when a dictionary of tags is 1872 provided to set multiple tags at once. 1873 1874 Raises a `MissingZoneError` if the specified zone does not exist. 1875 """ 1876 if zone not in self.zones: 1877 raise MissingZoneError( 1878 f"Can't add tag(s) to zone {zone!r} because that zone" 1879 f" doesn't exist yet." 1880 ) 1881 1882 if isinstance(tagOrTags, base.Tag): 1883 if tagValue is base.NoTagValue: 1884 tagValue = 1 1885 1886 # Not sure why this cast is necessary given the `if` above... 1887 tagValue = cast(base.TagValue, tagValue) 1888 1889 tagOrTags = {tagOrTags: tagValue} 1890 1891 elif tagValue is not base.NoTagValue: 1892 raise ValueError( 1893 "Provided a dictionary to update multiple tags, but" 1894 " also a tag value." 1895 ) 1896 1897 tagsAlready = self.zones[zone].tags 1898 tagsAlready.update(tagOrTags) 1899 1900 def untagZone( 1901 self, 1902 zone: base.Zone, 1903 tag: base.Tag 1904 ) -> Union[base.TagValue, type[base.NoTagValue]]: 1905 """ 1906 Removes a tag from a zone. Returns the tag's old value if the 1907 tag was present and got removed, or `NoTagValue` if the tag 1908 wasn't present. 1909 1910 Raises a `MissingZoneError` if the specified zone does not exist. 1911 """ 1912 if zone not in self.zones: 1913 raise MissingZoneError( 1914 f"Can't remove tag {tag!r} from zone {zone!r} because" 1915 f" that zone doesn't exist yet." 1916 ) 1917 target = self.zones[zone].tags 1918 try: 1919 return target.pop(tag) 1920 except KeyError: 1921 return base.NoTagValue 1922 1923 def zoneTags( 1924 self, 1925 zone: base.Zone 1926 ) -> Dict[base.Tag, base.TagValue]: 1927 """ 1928 Returns the dictionary of tags for a zone. Edits to the returned 1929 value will be applied to the graph. Returns an empty tags 1930 dictionary if called on a zone that didn't have any tags 1931 previously, but raises a `MissingZoneError` if attempting to get 1932 tags for a zone which does not exist. 1933 1934 For example: 1935 1936 >>> g = DecisionGraph() 1937 >>> g.addDecision('A') 1938 0 1939 >>> g.addDecision('B') 1940 1 1941 >>> g.createZone('Zone') 1942 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1943 annotations=[]) 1944 >>> g.tagZone('Zone', 'color', 'blue') 1945 >>> g.tagZone( 1946 ... 'Zone', 1947 ... {'shape': 'square', 'color': 'red', 'sound': 'loud'} 1948 ... ) 1949 >>> g.untagZone('Zone', 'sound') 1950 'loud' 1951 >>> g.zoneTags('Zone') 1952 {'color': 'red', 'shape': 'square'} 1953 """ 1954 if zone in self.zones: 1955 return self.zones[zone].tags 1956 else: 1957 raise MissingZoneError( 1958 f"Tags for zone {zone!r} don't exist because that" 1959 f" zone has not been created yet." 1960 ) 1961 1962 def createZone(self, zone: base.Zone, level: int = 0) -> base.ZoneInfo: 1963 """ 1964 Creates an empty zone with the given name at the given level 1965 (default 0). Raises a `ZoneCollisionError` if that zone name is 1966 already in use (at any level), including if it's in use by a 1967 decision. 1968 1969 Raises an `InvalidLevelError` if the level value is less than 0. 1970 1971 Returns the `ZoneInfo` for the new blank zone. 1972 1973 For example: 1974 1975 >>> d = DecisionGraph() 1976 >>> d.createZone('Z', 0) 1977 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1978 annotations=[]) 1979 >>> d.getZoneInfo('Z') 1980 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1981 annotations=[]) 1982 >>> d.createZone('Z2', 0) 1983 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1984 annotations=[]) 1985 >>> d.createZone('Z3', -1) # level -1 is not valid (must be >= 0) 1986 Traceback (most recent call last): 1987 ... 1988 exploration.core.InvalidLevelError... 1989 >>> d.createZone('Z2') # Name Z2 is already in use 1990 Traceback (most recent call last): 1991 ... 1992 exploration.core.ZoneCollisionError... 1993 """ 1994 if level < 0: 1995 raise InvalidLevelError( 1996 "Cannot create a zone with a negative level." 1997 ) 1998 if zone in self.zones: 1999 raise ZoneCollisionError(f"Zone {zone!r} already exists.") 2000 if zone in self: 2001 raise ZoneCollisionError( 2002 f"A decision named {zone!r} already exists, so a zone" 2003 f" with that name cannot be created." 2004 ) 2005 info: base.ZoneInfo = base.ZoneInfo( 2006 level=level, 2007 parents=set(), 2008 contents=set(), 2009 tags={}, 2010 annotations=[] 2011 ) 2012 self.zones[zone] = info 2013 return info 2014 2015 def getZoneInfo(self, zone: base.Zone) -> Optional[base.ZoneInfo]: 2016 """ 2017 Returns the `ZoneInfo` (level, parents, and contents) for the 2018 specified zone, or `None` if that zone does not exist. 2019 2020 For example: 2021 2022 >>> d = DecisionGraph() 2023 >>> d.createZone('Z', 0) 2024 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2025 annotations=[]) 2026 >>> d.getZoneInfo('Z') 2027 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2028 annotations=[]) 2029 >>> d.createZone('Z2', 0) 2030 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2031 annotations=[]) 2032 >>> d.getZoneInfo('Z2') 2033 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2034 annotations=[]) 2035 """ 2036 return self.zones.get(zone) 2037 2038 def deleteZone(self, zone: base.Zone) -> base.ZoneInfo: 2039 """ 2040 Deletes the specified zone, returning a `ZoneInfo` object with 2041 the information on the level, parents, and contents of that zone. 2042 2043 Raises a `MissingZoneError` if the zone in question does not 2044 exist. 2045 2046 For example: 2047 2048 >>> d = DecisionGraph() 2049 >>> d.createZone('Z', 0) 2050 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2051 annotations=[]) 2052 >>> d.getZoneInfo('Z') 2053 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2054 annotations=[]) 2055 >>> d.deleteZone('Z') 2056 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2057 annotations=[]) 2058 >>> d.getZoneInfo('Z') is None # no info any more 2059 True 2060 >>> d.deleteZone('Z') # can't re-delete 2061 Traceback (most recent call last): 2062 ... 2063 exploration.core.MissingZoneError... 2064 """ 2065 info = self.getZoneInfo(zone) 2066 if info is None: 2067 raise MissingZoneError( 2068 f"Cannot delete zone {zone!r}: it does not exist." 2069 ) 2070 for sub in info.contents: 2071 if 'zones' in self.nodes[sub]: 2072 try: 2073 self.nodes[sub]['zones'].remove(zone) 2074 except KeyError: 2075 pass 2076 del self.zones[zone] 2077 return info 2078 2079 def addDecisionToZone( 2080 self, 2081 decision: base.AnyDecisionSpecifier, 2082 zone: base.Zone 2083 ) -> None: 2084 """ 2085 Adds a decision directly to a zone. Should normally only be used 2086 with level-0 zones. Raises a `MissingZoneError` if the specified 2087 zone did not already exist. 2088 2089 For example: 2090 2091 >>> d = DecisionGraph() 2092 >>> d.addDecision('A') 2093 0 2094 >>> d.addDecision('B') 2095 1 2096 >>> d.addDecision('C') 2097 2 2098 >>> d.createZone('Z', 0) 2099 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2100 annotations=[]) 2101 >>> d.addDecisionToZone('A', 'Z') 2102 >>> d.getZoneInfo('Z') 2103 ZoneInfo(level=0, parents=set(), contents={0}, tags={},\ 2104 annotations=[]) 2105 >>> d.addDecisionToZone('B', 'Z') 2106 >>> d.getZoneInfo('Z') 2107 ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\ 2108 annotations=[]) 2109 """ 2110 dID = self.resolveDecision(decision) 2111 2112 if zone not in self.zones: 2113 raise MissingZoneError(f"Zone {zone!r} does not exist.") 2114 2115 self.zones[zone].contents.add(dID) 2116 self.nodes[dID].setdefault('zones', set()).add(zone) 2117 2118 def removeDecisionFromZone( 2119 self, 2120 decision: base.AnyDecisionSpecifier, 2121 zone: base.Zone 2122 ) -> bool: 2123 """ 2124 Removes a decision from a zone if it had been in it, returning 2125 True if that decision had been in that zone, and False if it was 2126 not in that zone, including if that zone didn't exist. 2127 2128 Note that this only removes a decision from direct zone 2129 membership. If the decision is a member of one or more zones 2130 which are (directly or indirectly) sub-zones of the target zone, 2131 the decision will remain in those zones, and will still be 2132 indirectly part of the target zone afterwards. 2133 2134 Examples: 2135 2136 >>> g = DecisionGraph() 2137 >>> g.addDecision('A') 2138 0 2139 >>> g.addDecision('B') 2140 1 2141 >>> g.createZone('level0', 0) 2142 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2143 annotations=[]) 2144 >>> g.createZone('level1', 1) 2145 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2146 annotations=[]) 2147 >>> g.createZone('level2', 2) 2148 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2149 annotations=[]) 2150 >>> g.createZone('level3', 3) 2151 ZoneInfo(level=3, parents=set(), contents=set(), tags={},\ 2152 annotations=[]) 2153 >>> g.addDecisionToZone('A', 'level0') 2154 >>> g.addDecisionToZone('B', 'level0') 2155 >>> g.addZoneToZone('level0', 'level1') 2156 >>> g.addZoneToZone('level1', 'level2') 2157 >>> g.addZoneToZone('level2', 'level3') 2158 >>> g.addDecisionToZone('B', 'level2') # Direct w/ skips 2159 >>> g.removeDecisionFromZone('A', 'level1') 2160 False 2161 >>> g.zoneParents(0) 2162 {'level0'} 2163 >>> g.removeDecisionFromZone('A', 'level0') 2164 True 2165 >>> g.zoneParents(0) 2166 set() 2167 >>> g.removeDecisionFromZone('A', 'level0') 2168 False 2169 >>> g.removeDecisionFromZone('B', 'level0') 2170 True 2171 >>> g.zoneParents(1) 2172 {'level2'} 2173 >>> g.removeDecisionFromZone('B', 'level0') 2174 False 2175 >>> g.removeDecisionFromZone('B', 'level2') 2176 True 2177 >>> g.zoneParents(1) 2178 set() 2179 """ 2180 dID = self.resolveDecision(decision) 2181 2182 if zone not in self.zones: 2183 return False 2184 2185 info = self.zones[zone] 2186 if dID not in info.contents: 2187 return False 2188 else: 2189 info.contents.remove(dID) 2190 try: 2191 self.nodes[dID]['zones'].remove(zone) 2192 except KeyError: 2193 pass 2194 return True 2195 2196 def addZoneToZone( 2197 self, 2198 addIt: base.Zone, 2199 addTo: base.Zone 2200 ) -> None: 2201 """ 2202 Adds a zone to another zone. The `addIt` one must be at a 2203 strictly lower level than the `addTo` zone, or an 2204 `InvalidLevelError` will be raised. 2205 2206 If the zone to be added didn't already exist, it is created at 2207 one level below the target zone. Similarly, if the zone being 2208 added to didn't already exist, it is created at one level above 2209 the target zone. If neither existed, a `MissingZoneError` will 2210 be raised. 2211 2212 For example: 2213 2214 >>> d = DecisionGraph() 2215 >>> d.addDecision('A') 2216 0 2217 >>> d.addDecision('B') 2218 1 2219 >>> d.addDecision('C') 2220 2 2221 >>> d.createZone('Z', 0) 2222 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2223 annotations=[]) 2224 >>> d.addDecisionToZone('A', 'Z') 2225 >>> d.addDecisionToZone('B', 'Z') 2226 >>> d.getZoneInfo('Z') 2227 ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\ 2228 annotations=[]) 2229 >>> d.createZone('Z2', 0) 2230 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2231 annotations=[]) 2232 >>> d.addDecisionToZone('B', 'Z2') 2233 >>> d.addDecisionToZone('C', 'Z2') 2234 >>> d.getZoneInfo('Z2') 2235 ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\ 2236 annotations=[]) 2237 >>> d.createZone('l1Z', 1) 2238 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2239 annotations=[]) 2240 >>> d.createZone('l2Z', 2) 2241 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2242 annotations=[]) 2243 >>> d.addZoneToZone('Z', 'l1Z') 2244 >>> d.getZoneInfo('Z') 2245 ZoneInfo(level=0, parents={'l1Z'}, contents={0, 1}, tags={},\ 2246 annotations=[]) 2247 >>> d.getZoneInfo('l1Z') 2248 ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\ 2249 annotations=[]) 2250 >>> d.addZoneToZone('l1Z', 'l2Z') 2251 >>> d.getZoneInfo('l1Z') 2252 ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={},\ 2253 annotations=[]) 2254 >>> d.getZoneInfo('l2Z') 2255 ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={},\ 2256 annotations=[]) 2257 >>> d.addZoneToZone('Z2', 'l2Z') 2258 >>> d.getZoneInfo('Z2') 2259 ZoneInfo(level=0, parents={'l2Z'}, contents={1, 2}, tags={},\ 2260 annotations=[]) 2261 >>> l2i = d.getZoneInfo('l2Z') 2262 >>> l2i.level 2263 2 2264 >>> l2i.parents 2265 set() 2266 >>> sorted(l2i.contents) 2267 ['Z2', 'l1Z'] 2268 >>> d.addZoneToZone('NZ', 'NZ2') 2269 Traceback (most recent call last): 2270 ... 2271 exploration.core.MissingZoneError... 2272 >>> d.addZoneToZone('Z', 'l1Z2') 2273 >>> zi = d.getZoneInfo('Z') 2274 >>> zi.level 2275 0 2276 >>> sorted(zi.parents) 2277 ['l1Z', 'l1Z2'] 2278 >>> sorted(zi.contents) 2279 [0, 1] 2280 >>> d.getZoneInfo('l1Z2') 2281 ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\ 2282 annotations=[]) 2283 >>> d.addZoneToZone('NZ', 'l1Z') 2284 >>> d.getZoneInfo('NZ') 2285 ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={},\ 2286 annotations=[]) 2287 >>> zi = d.getZoneInfo('l1Z') 2288 >>> zi.level 2289 1 2290 >>> zi.parents 2291 {'l2Z'} 2292 >>> sorted(zi.contents) 2293 ['NZ', 'Z'] 2294 """ 2295 # Create one or the other (but not both) if they're missing 2296 addInfo = self.getZoneInfo(addIt) 2297 toInfo = self.getZoneInfo(addTo) 2298 if addInfo is None and toInfo is None: 2299 raise MissingZoneError( 2300 f"Cannot add zone {addIt!r} to zone {addTo!r}: neither" 2301 f" exists already." 2302 ) 2303 2304 # Create missing addIt 2305 elif addInfo is None: 2306 toInfo = cast(base.ZoneInfo, toInfo) 2307 newLevel = toInfo.level - 1 2308 if newLevel < 0: 2309 raise InvalidLevelError( 2310 f"Zone {addTo!r} is at level {toInfo.level} and so" 2311 f" a new zone cannot be added underneath it." 2312 ) 2313 addInfo = self.createZone(addIt, newLevel) 2314 2315 # Create missing addTo 2316 elif toInfo is None: 2317 addInfo = cast(base.ZoneInfo, addInfo) 2318 newLevel = addInfo.level + 1 2319 if newLevel < 0: 2320 raise InvalidLevelError( 2321 f"Zone {addIt!r} is at level {addInfo.level} (!!!)" 2322 f" and so a new zone cannot be added above it." 2323 ) 2324 toInfo = self.createZone(addTo, newLevel) 2325 2326 # Now both addInfo and toInfo are defined 2327 if addInfo.level >= toInfo.level: 2328 raise InvalidLevelError( 2329 f"Cannot add zone {addIt!r} at level {addInfo.level}" 2330 f" to zone {addTo!r} at level {toInfo.level}: zones can" 2331 f" only contain zones of lower levels." 2332 ) 2333 2334 # Now both addInfo and toInfo are defined 2335 toInfo.contents.add(addIt) 2336 addInfo.parents.add(addTo) 2337 2338 def removeZoneFromZone( 2339 self, 2340 removeIt: base.Zone, 2341 removeFrom: base.Zone 2342 ) -> bool: 2343 """ 2344 Removes a zone from a zone if it had been in it, returning True 2345 if that zone had been in that zone, and False if it was not in 2346 that zone, including if either zone did not exist. 2347 2348 For example: 2349 2350 >>> d = DecisionGraph() 2351 >>> d.createZone('Z', 0) 2352 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2353 annotations=[]) 2354 >>> d.createZone('Z2', 0) 2355 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2356 annotations=[]) 2357 >>> d.createZone('l1Z', 1) 2358 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2359 annotations=[]) 2360 >>> d.createZone('l2Z', 2) 2361 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2362 annotations=[]) 2363 >>> d.addZoneToZone('Z', 'l1Z') 2364 >>> d.addZoneToZone('l1Z', 'l2Z') 2365 >>> d.getZoneInfo('Z') 2366 ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={},\ 2367 annotations=[]) 2368 >>> d.getZoneInfo('l1Z') 2369 ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={},\ 2370 annotations=[]) 2371 >>> d.getZoneInfo('l2Z') 2372 ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={},\ 2373 annotations=[]) 2374 >>> d.removeZoneFromZone('l1Z', 'l2Z') 2375 True 2376 >>> d.getZoneInfo('l1Z') 2377 ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\ 2378 annotations=[]) 2379 >>> d.getZoneInfo('l2Z') 2380 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2381 annotations=[]) 2382 >>> d.removeZoneFromZone('Z', 'l1Z') 2383 True 2384 >>> d.getZoneInfo('Z') 2385 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2386 annotations=[]) 2387 >>> d.getZoneInfo('l1Z') 2388 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2389 annotations=[]) 2390 >>> d.removeZoneFromZone('Z', 'l1Z') 2391 False 2392 >>> d.removeZoneFromZone('Z', 'madeup') 2393 False 2394 >>> d.removeZoneFromZone('nope', 'madeup') 2395 False 2396 >>> d.removeZoneFromZone('nope', 'l1Z') 2397 False 2398 """ 2399 remInfo = self.getZoneInfo(removeIt) 2400 fromInfo = self.getZoneInfo(removeFrom) 2401 2402 if remInfo is None or fromInfo is None: 2403 return False 2404 2405 if removeIt not in fromInfo.contents: 2406 return False 2407 2408 remInfo.parents.remove(removeFrom) 2409 fromInfo.contents.remove(removeIt) 2410 return True 2411 2412 def decisionsInZone(self, zone: base.Zone) -> Set[base.DecisionID]: 2413 """ 2414 Returns a set of all decisions included directly in the given 2415 zone, not counting decisions included via intermediate 2416 sub-zones (see `allDecisionsInZone` to include those). 2417 2418 Raises a `MissingZoneError` if the specified zone does not 2419 exist. 2420 2421 The returned set is a copy, not a live editable set. 2422 2423 For example: 2424 2425 >>> d = DecisionGraph() 2426 >>> d.addDecision('A') 2427 0 2428 >>> d.addDecision('B') 2429 1 2430 >>> d.addDecision('C') 2431 2 2432 >>> d.createZone('Z', 0) 2433 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2434 annotations=[]) 2435 >>> d.addDecisionToZone('A', 'Z') 2436 >>> d.addDecisionToZone('B', 'Z') 2437 >>> d.getZoneInfo('Z') 2438 ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\ 2439 annotations=[]) 2440 >>> d.decisionsInZone('Z') 2441 {0, 1} 2442 >>> d.createZone('Z2', 0) 2443 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2444 annotations=[]) 2445 >>> d.addDecisionToZone('B', 'Z2') 2446 >>> d.addDecisionToZone('C', 'Z2') 2447 >>> d.getZoneInfo('Z2') 2448 ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\ 2449 annotations=[]) 2450 >>> d.decisionsInZone('Z') 2451 {0, 1} 2452 >>> d.decisionsInZone('Z2') 2453 {1, 2} 2454 >>> d.createZone('l1Z', 1) 2455 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2456 annotations=[]) 2457 >>> d.addZoneToZone('Z', 'l1Z') 2458 >>> d.decisionsInZone('Z') 2459 {0, 1} 2460 >>> d.decisionsInZone('l1Z') 2461 set() 2462 >>> d.decisionsInZone('madeup') 2463 Traceback (most recent call last): 2464 ... 2465 exploration.core.MissingZoneError... 2466 >>> zDec = d.decisionsInZone('Z') 2467 >>> zDec.add(2) # won't affect the zone 2468 >>> zDec 2469 {0, 1, 2} 2470 >>> d.decisionsInZone('Z') 2471 {0, 1} 2472 """ 2473 info = self.getZoneInfo(zone) 2474 if info is None: 2475 raise MissingZoneError(f"Zone {zone!r} does not exist.") 2476 2477 # Everything that's not a zone must be a decision 2478 return { 2479 item 2480 for item in info.contents 2481 if isinstance(item, base.DecisionID) 2482 } 2483 2484 def subZones(self, zone: base.Zone) -> Set[base.Zone]: 2485 """ 2486 Returns the set of all immediate sub-zones of the given zone. 2487 Will be an empty set if there are no sub-zones; raises a 2488 `MissingZoneError` if the specified zone does not exit. 2489 2490 The returned set is a copy, not a live editable set. 2491 2492 For example: 2493 2494 >>> d = DecisionGraph() 2495 >>> d.createZone('Z', 0) 2496 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2497 annotations=[]) 2498 >>> d.subZones('Z') 2499 set() 2500 >>> d.createZone('l1Z', 1) 2501 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2502 annotations=[]) 2503 >>> d.addZoneToZone('Z', 'l1Z') 2504 >>> d.subZones('Z') 2505 set() 2506 >>> d.subZones('l1Z') 2507 {'Z'} 2508 >>> s = d.subZones('l1Z') 2509 >>> s.add('Q') # doesn't affect the zone 2510 >>> sorted(s) 2511 ['Q', 'Z'] 2512 >>> d.subZones('l1Z') 2513 {'Z'} 2514 >>> d.subZones('madeup') 2515 Traceback (most recent call last): 2516 ... 2517 exploration.core.MissingZoneError... 2518 """ 2519 info = self.getZoneInfo(zone) 2520 if info is None: 2521 raise MissingZoneError(f"Zone {zone!r} does not exist.") 2522 2523 # Sub-zones will appear in self.zones 2524 return { 2525 item 2526 for item in info.contents 2527 if isinstance(item, base.Zone) 2528 } 2529 2530 def allDecisionsInZone(self, zone: base.Zone) -> Set[base.DecisionID]: 2531 """ 2532 Returns a set containing all decisions in the given zone, 2533 including those included via sub-zones. 2534 2535 Raises a `MissingZoneError` if the specified zone does not 2536 exist.` 2537 2538 For example: 2539 2540 >>> d = DecisionGraph() 2541 >>> d.addDecision('A') 2542 0 2543 >>> d.addDecision('B') 2544 1 2545 >>> d.addDecision('C') 2546 2 2547 >>> d.createZone('Z', 0) 2548 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2549 annotations=[]) 2550 >>> d.addDecisionToZone('A', 'Z') 2551 >>> d.addDecisionToZone('B', 'Z') 2552 >>> d.getZoneInfo('Z') 2553 ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\ 2554 annotations=[]) 2555 >>> d.decisionsInZone('Z') 2556 {0, 1} 2557 >>> d.allDecisionsInZone('Z') 2558 {0, 1} 2559 >>> d.createZone('Z2', 0) 2560 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2561 annotations=[]) 2562 >>> d.addDecisionToZone('B', 'Z2') 2563 >>> d.addDecisionToZone('C', 'Z2') 2564 >>> d.getZoneInfo('Z2') 2565 ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\ 2566 annotations=[]) 2567 >>> d.decisionsInZone('Z') 2568 {0, 1} 2569 >>> d.decisionsInZone('Z2') 2570 {1, 2} 2571 >>> d.allDecisionsInZone('Z2') 2572 {1, 2} 2573 >>> d.createZone('l1Z', 1) 2574 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2575 annotations=[]) 2576 >>> d.createZone('l2Z', 2) 2577 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2578 annotations=[]) 2579 >>> d.addZoneToZone('Z', 'l1Z') 2580 >>> d.addZoneToZone('l1Z', 'l2Z') 2581 >>> d.addZoneToZone('Z2', 'l2Z') 2582 >>> d.decisionsInZone('Z') 2583 {0, 1} 2584 >>> d.decisionsInZone('Z2') 2585 {1, 2} 2586 >>> d.decisionsInZone('l1Z') 2587 set() 2588 >>> d.allDecisionsInZone('l1Z') 2589 {0, 1} 2590 >>> d.allDecisionsInZone('l2Z') 2591 {0, 1, 2} 2592 """ 2593 result: Set[base.DecisionID] = set() 2594 info = self.getZoneInfo(zone) 2595 if info is None: 2596 raise MissingZoneError(f"Zone {zone!r} does not exist.") 2597 2598 for item in info.contents: 2599 if isinstance(item, base.Zone): 2600 # This can't be an error because of the condition above 2601 result |= self.allDecisionsInZone(item) 2602 else: # it's a decision 2603 result.add(item) 2604 2605 return result 2606 2607 def zoneHierarchyLevel(self, zone: base.Zone) -> int: 2608 """ 2609 Returns the hierarchy level of the given zone, as stored in its 2610 zone info. 2611 2612 Raises a `MissingZoneError` if the specified zone does not 2613 exist. 2614 2615 For example: 2616 2617 >>> d = DecisionGraph() 2618 >>> d.createZone('Z', 0) 2619 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2620 annotations=[]) 2621 >>> d.createZone('l1Z', 1) 2622 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2623 annotations=[]) 2624 >>> d.createZone('l5Z', 5) 2625 ZoneInfo(level=5, parents=set(), contents=set(), tags={},\ 2626 annotations=[]) 2627 >>> d.zoneHierarchyLevel('Z') 2628 0 2629 >>> d.zoneHierarchyLevel('l1Z') 2630 1 2631 >>> d.zoneHierarchyLevel('l5Z') 2632 5 2633 >>> d.zoneHierarchyLevel('madeup') 2634 Traceback (most recent call last): 2635 ... 2636 exploration.core.MissingZoneError... 2637 """ 2638 info = self.getZoneInfo(zone) 2639 if info is None: 2640 raise MissingZoneError(f"Zone {zone!r} dose not exist.") 2641 2642 return info.level 2643 2644 def zoneParents( 2645 self, 2646 zoneOrDecision: Union[base.Zone, base.DecisionID] 2647 ) -> Set[base.Zone]: 2648 """ 2649 Returns the set of all zones which directly contain the target 2650 zone or decision. 2651 2652 Raises a `MissingDecisionError` if the target is neither a valid 2653 zone nor a valid decision. 2654 2655 Returns a copy, not a live editable set. 2656 2657 Example: 2658 2659 >>> g = DecisionGraph() 2660 >>> g.addDecision('A') 2661 0 2662 >>> g.addDecision('B') 2663 1 2664 >>> g.createZone('level0', 0) 2665 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2666 annotations=[]) 2667 >>> g.createZone('level1', 1) 2668 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2669 annotations=[]) 2670 >>> g.createZone('level2', 2) 2671 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2672 annotations=[]) 2673 >>> g.createZone('level3', 3) 2674 ZoneInfo(level=3, parents=set(), contents=set(), tags={},\ 2675 annotations=[]) 2676 >>> g.addDecisionToZone('A', 'level0') 2677 >>> g.addDecisionToZone('B', 'level0') 2678 >>> g.addZoneToZone('level0', 'level1') 2679 >>> g.addZoneToZone('level1', 'level2') 2680 >>> g.addZoneToZone('level2', 'level3') 2681 >>> g.addDecisionToZone('B', 'level2') # Direct w/ skips 2682 >>> sorted(g.zoneParents(0)) 2683 ['level0'] 2684 >>> sorted(g.zoneParents(1)) 2685 ['level0', 'level2'] 2686 """ 2687 if zoneOrDecision in self.zones: 2688 zoneOrDecision = cast(base.Zone, zoneOrDecision) 2689 info = cast(base.ZoneInfo, self.getZoneInfo(zoneOrDecision)) 2690 return copy.copy(info.parents) 2691 elif zoneOrDecision in self: 2692 return self.nodes[zoneOrDecision].get('zones', set()) 2693 else: 2694 raise MissingDecisionError( 2695 f"Name {zoneOrDecision!r} is neither a valid zone nor a" 2696 f" valid decision." 2697 ) 2698 2699 def zoneAncestors( 2700 self, 2701 zoneOrDecision: Union[base.Zone, base.DecisionID], 2702 exclude: Set[base.Zone] = set() 2703 ) -> Set[base.Zone]: 2704 """ 2705 Returns the set of zones which contain the target zone or 2706 decision, either directly or indirectly. The target is not 2707 included in the set. 2708 2709 Any ones listed in the `exclude` set are also excluded, as are 2710 any of their ancestors which are not also ancestors of the 2711 target zone via another path of inclusion. 2712 2713 Raises a `MissingDecisionError` if the target is nether a valid 2714 zone nor a valid decision. 2715 2716 Example: 2717 2718 >>> g = DecisionGraph() 2719 >>> g.addDecision('A') 2720 0 2721 >>> g.addDecision('B') 2722 1 2723 >>> g.createZone('level0', 0) 2724 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2725 annotations=[]) 2726 >>> g.createZone('level1', 1) 2727 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2728 annotations=[]) 2729 >>> g.createZone('level2', 2) 2730 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2731 annotations=[]) 2732 >>> g.createZone('level3', 3) 2733 ZoneInfo(level=3, parents=set(), contents=set(), tags={},\ 2734 annotations=[]) 2735 >>> g.addDecisionToZone('A', 'level0') 2736 >>> g.addDecisionToZone('B', 'level0') 2737 >>> g.addZoneToZone('level0', 'level1') 2738 >>> g.addZoneToZone('level1', 'level2') 2739 >>> g.addZoneToZone('level2', 'level3') 2740 >>> g.addDecisionToZone('B', 'level2') # Direct w/ skips 2741 >>> sorted(g.zoneAncestors(0)) 2742 ['level0', 'level1', 'level2', 'level3'] 2743 >>> sorted(g.zoneAncestors(1)) 2744 ['level0', 'level1', 'level2', 'level3'] 2745 >>> sorted(g.zoneParents(0)) 2746 ['level0'] 2747 >>> sorted(g.zoneParents(1)) 2748 ['level0', 'level2'] 2749 """ 2750 # Copy is important here! 2751 result = set(self.zoneParents(zoneOrDecision)) 2752 result -= exclude 2753 for parent in copy.copy(result): 2754 # Recursively dig up ancestors, but exclude 2755 # results-so-far to avoid re-enumerating when there are 2756 # multiple braided inclusion paths. 2757 result |= self.zoneAncestors(parent, result | exclude) 2758 2759 return result 2760 2761 def zoneEdges(self, zone: base.Zone) -> Optional[ 2762 Tuple[ 2763 Set[Tuple[base.DecisionID, base.Transition]], 2764 Set[Tuple[base.DecisionID, base.Transition]] 2765 ] 2766 ]: 2767 """ 2768 Given a zone to look at, finds all of the transitions which go 2769 out of and into that zone, ignoring internal transitions between 2770 decisions in the zone. This includes all decisions in sub-zones. 2771 The return value is a pair of sets for outgoing and then 2772 incoming transitions, where each transition is specified as a 2773 (sourceID, transitionName) pair. 2774 2775 Returns `None` if the target zone isn't yet fully defined. 2776 2777 Note that this takes time proportional to *all* edges plus *all* 2778 nodes in the graph no matter how large or small the zone in 2779 question is. 2780 2781 >>> g = DecisionGraph() 2782 >>> g.addDecision('A') 2783 0 2784 >>> g.addDecision('B') 2785 1 2786 >>> g.addDecision('C') 2787 2 2788 >>> g.addDecision('D') 2789 3 2790 >>> g.addTransition('A', 'up', 'B', 'down') 2791 >>> g.addTransition('B', 'right', 'C', 'left') 2792 >>> g.addTransition('C', 'down', 'D', 'up') 2793 >>> g.addTransition('D', 'left', 'A', 'right') 2794 >>> g.addTransition('A', 'tunnel', 'C', 'tunnel') 2795 >>> g.createZone('Z', 0) 2796 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2797 annotations=[]) 2798 >>> g.createZone('ZZ', 1) 2799 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2800 annotations=[]) 2801 >>> g.addZoneToZone('Z', 'ZZ') 2802 >>> g.addDecisionToZone('A', 'Z') 2803 >>> g.addDecisionToZone('B', 'Z') 2804 >>> g.addDecisionToZone('D', 'ZZ') 2805 >>> outgoing, incoming = g.zoneEdges('Z') # TODO: Sort for testing 2806 >>> sorted(outgoing) 2807 [(0, 'right'), (0, 'tunnel'), (1, 'right')] 2808 >>> sorted(incoming) 2809 [(2, 'left'), (2, 'tunnel'), (3, 'left')] 2810 >>> outgoing, incoming = g.zoneEdges('ZZ') 2811 >>> sorted(outgoing) 2812 [(0, 'tunnel'), (1, 'right'), (3, 'up')] 2813 >>> sorted(incoming) 2814 [(2, 'down'), (2, 'left'), (2, 'tunnel')] 2815 >>> g.zoneEdges('madeup') is None 2816 True 2817 """ 2818 # Find the interior nodes 2819 try: 2820 interior = self.allDecisionsInZone(zone) 2821 except MissingZoneError: 2822 return None 2823 2824 # Set up our result 2825 results: Tuple[ 2826 Set[Tuple[base.DecisionID, base.Transition]], 2827 Set[Tuple[base.DecisionID, base.Transition]] 2828 ] = (set(), set()) 2829 2830 # Because finding incoming edges requires searching the entire 2831 # graph anyways, it's more efficient to just consider each edge 2832 # once. 2833 for fromDecision in self: 2834 fromThere = self[fromDecision] 2835 for toDecision in fromThere: 2836 for transition in fromThere[toDecision]: 2837 sourceIn = fromDecision in interior 2838 destIn = toDecision in interior 2839 if sourceIn and not destIn: 2840 results[0].add((fromDecision, transition)) 2841 elif destIn and not sourceIn: 2842 results[1].add((fromDecision, transition)) 2843 2844 return results 2845 2846 def replaceZonesInHierarchy( 2847 self, 2848 target: base.AnyDecisionSpecifier, 2849 zone: base.Zone, 2850 level: int 2851 ) -> None: 2852 """ 2853 This method replaces one or more zones which contain the 2854 specified `target` decision with a specific zone, at a specific 2855 level in the zone hierarchy (see `zoneHierarchyLevel`). If the 2856 named zone doesn't yet exist, it will be created. 2857 2858 To do this, it looks at all zones which contain the target 2859 decision directly or indirectly (see `zoneAncestors`) and which 2860 are at the specified level. 2861 2862 - Any direct children of those zones which are ancestors of the 2863 target decision are removed from those zones and placed into 2864 the new zone instead, regardless of their levels. Indirect 2865 children are not affected (except perhaps indirectly via 2866 their parents' ancestors changing). 2867 - The new zone is placed into every direct parent of those 2868 zones, regardless of their levels (those parents are by 2869 definition all ancestors of the target decision). 2870 - If there were no zones at the target level, every zone at the 2871 next level down which is an ancestor of the target decision 2872 (or just that decision if the level is 0) is placed into the 2873 new zone as a direct child (and is removed from any previous 2874 parents it had). In this case, the new zone will also be 2875 added as a sub-zone to every ancestor of the target decision 2876 at the level above the specified level, if there are any. 2877 * In this case, if there are no zones at the level below the 2878 specified level, the highest level of zones smaller than 2879 that is treated as the level below, down to targeting 2880 the decision itself. 2881 * Similarly, if there are no zones at the level above the 2882 specified level but there are zones at a higher level, 2883 the new zone will be added to each of the zones in the 2884 lowest level above the target level that has zones in it. 2885 2886 A `MissingDecisionError` will be raised if the specified 2887 decision is not valid, or if the decision is left as default but 2888 there is no current decision in the exploration. 2889 2890 An `InvalidLevelError` will be raised if the level is less than 2891 zero. 2892 2893 Example: 2894 2895 >>> g = DecisionGraph() 2896 >>> g.addDecision('decision') 2897 0 2898 >>> g.addDecision('alternate') 2899 1 2900 >>> g.createZone('zone0', 0) 2901 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2902 annotations=[]) 2903 >>> g.createZone('zone1', 1) 2904 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2905 annotations=[]) 2906 >>> g.createZone('zone2.1', 2) 2907 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2908 annotations=[]) 2909 >>> g.createZone('zone2.2', 2) 2910 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2911 annotations=[]) 2912 >>> g.createZone('zone3', 3) 2913 ZoneInfo(level=3, parents=set(), contents=set(), tags={},\ 2914 annotations=[]) 2915 >>> g.addDecisionToZone('decision', 'zone0') 2916 >>> g.addDecisionToZone('alternate', 'zone0') 2917 >>> g.addZoneToZone('zone0', 'zone1') 2918 >>> g.addZoneToZone('zone1', 'zone2.1') 2919 >>> g.addZoneToZone('zone1', 'zone2.2') 2920 >>> g.addZoneToZone('zone2.1', 'zone3') 2921 >>> g.addZoneToZone('zone2.2', 'zone3') 2922 >>> g.zoneHierarchyLevel('zone0') 2923 0 2924 >>> g.zoneHierarchyLevel('zone1') 2925 1 2926 >>> g.zoneHierarchyLevel('zone2.1') 2927 2 2928 >>> g.zoneHierarchyLevel('zone2.2') 2929 2 2930 >>> g.zoneHierarchyLevel('zone3') 2931 3 2932 >>> sorted(g.decisionsInZone('zone0')) 2933 [0, 1] 2934 >>> sorted(g.zoneAncestors('zone0')) 2935 ['zone1', 'zone2.1', 'zone2.2', 'zone3'] 2936 >>> g.subZones('zone1') 2937 {'zone0'} 2938 >>> g.zoneParents('zone0') 2939 {'zone1'} 2940 >>> g.replaceZonesInHierarchy('decision', 'new0', 0) 2941 >>> g.zoneParents('zone0') 2942 {'zone1'} 2943 >>> g.zoneParents('new0') 2944 {'zone1'} 2945 >>> sorted(g.zoneAncestors('zone0')) 2946 ['zone1', 'zone2.1', 'zone2.2', 'zone3'] 2947 >>> sorted(g.zoneAncestors('new0')) 2948 ['zone1', 'zone2.1', 'zone2.2', 'zone3'] 2949 >>> g.decisionsInZone('zone0') 2950 {1} 2951 >>> g.decisionsInZone('new0') 2952 {0} 2953 >>> sorted(g.subZones('zone1')) 2954 ['new0', 'zone0'] 2955 >>> g.zoneParents('new0') 2956 {'zone1'} 2957 >>> g.replaceZonesInHierarchy('decision', 'new1', 1) 2958 >>> sorted(g.zoneAncestors(0)) 2959 ['new0', 'new1', 'zone2.1', 'zone2.2', 'zone3'] 2960 >>> g.subZones('zone1') 2961 {'zone0'} 2962 >>> g.subZones('new1') 2963 {'new0'} 2964 >>> g.zoneParents('new0') 2965 {'new1'} 2966 >>> sorted(g.zoneParents('zone1')) 2967 ['zone2.1', 'zone2.2'] 2968 >>> sorted(g.zoneParents('new1')) 2969 ['zone2.1', 'zone2.2'] 2970 >>> g.zoneParents('zone2.1') 2971 {'zone3'} 2972 >>> g.zoneParents('zone2.2') 2973 {'zone3'} 2974 >>> sorted(g.subZones('zone2.1')) 2975 ['new1', 'zone1'] 2976 >>> sorted(g.subZones('zone2.2')) 2977 ['new1', 'zone1'] 2978 >>> sorted(g.allDecisionsInZone('zone2.1')) 2979 [0, 1] 2980 >>> sorted(g.allDecisionsInZone('zone2.2')) 2981 [0, 1] 2982 >>> g.replaceZonesInHierarchy('decision', 'new2', 2) 2983 >>> g.zoneParents('zone2.1') 2984 {'zone3'} 2985 >>> g.zoneParents('zone2.2') 2986 {'zone3'} 2987 >>> g.subZones('zone2.1') 2988 {'zone1'} 2989 >>> g.subZones('zone2.2') 2990 {'zone1'} 2991 >>> g.subZones('new2') 2992 {'new1'} 2993 >>> g.zoneParents('new2') 2994 {'zone3'} 2995 >>> g.allDecisionsInZone('zone2.1') 2996 {1} 2997 >>> g.allDecisionsInZone('zone2.2') 2998 {1} 2999 >>> g.allDecisionsInZone('new2') 3000 {0} 3001 >>> sorted(g.subZones('zone3')) 3002 ['new2', 'zone2.1', 'zone2.2'] 3003 >>> g.zoneParents('zone3') 3004 set() 3005 >>> sorted(g.allDecisionsInZone('zone3')) 3006 [0, 1] 3007 >>> g.replaceZonesInHierarchy('decision', 'new3', 3) 3008 >>> sorted(g.subZones('zone3')) 3009 ['zone2.1', 'zone2.2'] 3010 >>> g.subZones('new3') 3011 {'new2'} 3012 >>> g.zoneParents('zone3') 3013 set() 3014 >>> g.zoneParents('new3') 3015 set() 3016 >>> g.allDecisionsInZone('zone3') 3017 {1} 3018 >>> g.allDecisionsInZone('new3') 3019 {0} 3020 >>> g.replaceZonesInHierarchy('decision', 'new4', 5) 3021 >>> g.subZones('new4') 3022 {'new3'} 3023 >>> g.zoneHierarchyLevel('new4') 3024 5 3025 3026 Another example of level collapse when trying to replace a zone 3027 at a level above : 3028 3029 >>> g = DecisionGraph() 3030 >>> g.addDecision('A') 3031 0 3032 >>> g.addDecision('B') 3033 1 3034 >>> g.createZone('level0', 0) 3035 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 3036 annotations=[]) 3037 >>> g.createZone('level1', 1) 3038 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 3039 annotations=[]) 3040 >>> g.createZone('level2', 2) 3041 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 3042 annotations=[]) 3043 >>> g.createZone('level3', 3) 3044 ZoneInfo(level=3, parents=set(), contents=set(), tags={},\ 3045 annotations=[]) 3046 >>> g.addDecisionToZone('B', 'level0') 3047 >>> g.addZoneToZone('level0', 'level1') 3048 >>> g.addZoneToZone('level1', 'level2') 3049 >>> g.addZoneToZone('level2', 'level3') 3050 >>> g.addDecisionToZone('A', 'level3') # missing some zone levels 3051 >>> g.zoneHierarchyLevel('level3') 3052 3 3053 >>> g.replaceZonesInHierarchy('A', 'newFirst', 1) 3054 >>> g.zoneHierarchyLevel('newFirst') 3055 1 3056 >>> g.decisionsInZone('newFirst') 3057 {0} 3058 >>> g.decisionsInZone('level3') 3059 set() 3060 >>> sorted(g.allDecisionsInZone('level3')) 3061 [0, 1] 3062 >>> g.subZones('newFirst') 3063 set() 3064 >>> sorted(g.subZones('level3')) 3065 ['level2', 'newFirst'] 3066 >>> g.zoneParents('newFirst') 3067 {'level3'} 3068 >>> g.replaceZonesInHierarchy('A', 'newSecond', 2) 3069 >>> g.zoneHierarchyLevel('newSecond') 3070 2 3071 >>> g.decisionsInZone('newSecond') 3072 set() 3073 >>> g.allDecisionsInZone('newSecond') 3074 {0} 3075 >>> g.subZones('newSecond') 3076 {'newFirst'} 3077 >>> g.zoneParents('newSecond') 3078 {'level3'} 3079 >>> g.zoneParents('newFirst') 3080 {'newSecond'} 3081 >>> sorted(g.subZones('level3')) 3082 ['level2', 'newSecond'] 3083 """ 3084 tID = self.resolveDecision(target) 3085 3086 if level < 0: 3087 raise InvalidLevelError( 3088 f"Target level must be positive (got {level})." 3089 ) 3090 3091 info = self.getZoneInfo(zone) 3092 if info is None: 3093 info = self.createZone(zone, level) 3094 elif level != info.level: 3095 raise InvalidLevelError( 3096 f"Target level ({level}) does not match the level of" 3097 f" the target zone ({zone!r} at level {info.level})." 3098 ) 3099 3100 # Collect both parents & ancestors 3101 parents = self.zoneParents(tID) 3102 ancestors = set(self.zoneAncestors(tID)) 3103 3104 # Map from levels to sets of zones from the ancestors pool 3105 levelMap: Dict[int, Set[base.Zone]] = {} 3106 highest = -1 3107 for ancestor in ancestors: 3108 ancestorLevel = self.zoneHierarchyLevel(ancestor) 3109 levelMap.setdefault(ancestorLevel, set()).add(ancestor) 3110 if ancestorLevel > highest: 3111 highest = ancestorLevel 3112 3113 # Figure out if we have target zones to replace or not 3114 reparentDecision = False 3115 if level in levelMap: 3116 # If there are zones at the target level, 3117 targetZones = levelMap[level] 3118 3119 above = set() 3120 below = set() 3121 3122 for replaced in targetZones: 3123 above |= self.zoneParents(replaced) 3124 below |= self.subZones(replaced) 3125 if replaced in parents: 3126 reparentDecision = True 3127 3128 # Only ancestors should be reparented 3129 below &= ancestors 3130 3131 else: 3132 # Find levels w/ zones in them above + below 3133 levelBelow = level - 1 3134 levelAbove = level + 1 3135 below = levelMap.get(levelBelow, set()) 3136 above = levelMap.get(levelAbove, set()) 3137 3138 while len(below) == 0 and levelBelow > 0: 3139 levelBelow -= 1 3140 below = levelMap.get(levelBelow, set()) 3141 3142 if len(below) == 0: 3143 reparentDecision = True 3144 3145 while len(above) == 0 and levelAbove < highest: 3146 levelAbove += 1 3147 above = levelMap.get(levelAbove, set()) 3148 3149 # Handle re-parenting zones below 3150 for under in below: 3151 for parent in self.zoneParents(under): 3152 if parent in ancestors: 3153 self.removeZoneFromZone(under, parent) 3154 self.addZoneToZone(under, zone) 3155 3156 # Add this zone to each parent 3157 for parent in above: 3158 self.addZoneToZone(zone, parent) 3159 3160 # Re-parent the decision itself if necessary 3161 if reparentDecision: 3162 # (using set() here to avoid size-change-during-iteration) 3163 for parent in set(parents): 3164 self.removeDecisionFromZone(tID, parent) 3165 self.addDecisionToZone(tID, zone) 3166 3167 def getReciprocal( 3168 self, 3169 decision: base.AnyDecisionSpecifier, 3170 transition: base.Transition 3171 ) -> Optional[base.Transition]: 3172 """ 3173 Returns the reciprocal edge for the specified transition from the 3174 specified decision (see `setReciprocal`). Returns 3175 `None` if no reciprocal has been established for that 3176 transition, or if that decision or transition does not exist. 3177 """ 3178 dID = self.resolveDecision(decision) 3179 3180 dest = self.getDestination(dID, transition) 3181 if dest is not None: 3182 info = cast( 3183 TransitionProperties, 3184 self.edges[dID, dest, transition] # type:ignore 3185 ) 3186 recip = info.get("reciprocal") 3187 if recip is not None and not isinstance(recip, base.Transition): 3188 raise ValueError(f"Invalid reciprocal value: {repr(recip)}") 3189 return recip 3190 else: 3191 return None 3192 3193 def setReciprocal( 3194 self, 3195 decision: base.AnyDecisionSpecifier, 3196 transition: base.Transition, 3197 reciprocal: Optional[base.Transition], 3198 setBoth: bool = True, 3199 cleanup: bool = True 3200 ) -> None: 3201 """ 3202 Sets the 'reciprocal' transition for a particular transition from 3203 a particular decision, and removes the reciprocal property from 3204 any old reciprocal transition. 3205 3206 Raises a `MissingDecisionError` or a `MissingTransitionError` if 3207 the specified decision or transition does not exist. 3208 3209 Raises an `InvalidDestinationError` if the reciprocal transition 3210 does not exist, or if it does exist but does not lead back to 3211 the decision the transition came from. 3212 3213 If `setBoth` is True (the default) then the transition which is 3214 being identified as a reciprocal will also have its reciprocal 3215 property set, pointing back to the primary transition being 3216 modified, and any old reciprocal of that transition will have its 3217 reciprocal set to None. If you want to create a situation with 3218 non-exclusive reciprocals, use `setBoth=False`. 3219 3220 If `cleanup` is True (the default) then abandoned reciprocal 3221 transitions (for both edges if `setBoth` was true) have their 3222 reciprocal properties removed. Set `cleanup` to false if you want 3223 to retain them, although this will result in non-exclusive 3224 reciprocal relationships. 3225 3226 If the `reciprocal` value is None, this deletes the reciprocal 3227 value entirely, and if `setBoth` is true, it does this for the 3228 previous reciprocal edge as well. No error is raised in this case 3229 when there was not already a reciprocal to delete. 3230 3231 Note that one should remove a reciprocal relationship before 3232 redirecting either edge of the pair in a way that gives it a new 3233 reciprocal, since otherwise, a later attempt to remove the 3234 reciprocal with `setBoth` set to True (the default) will end up 3235 deleting the reciprocal information from the other edge that was 3236 already modified. There is no way to reliably detect and avoid 3237 this, because two different decisions could (and often do in 3238 practice) have transitions with identical names, meaning that the 3239 reciprocal value will still be the same, but it will indicate a 3240 different edge in virtue of the destination of the edge changing. 3241 3242 ## Example 3243 3244 >>> g = DecisionGraph() 3245 >>> g.addDecision('G') 3246 0 3247 >>> g.addDecision('H') 3248 1 3249 >>> g.addDecision('I') 3250 2 3251 >>> g.addTransition('G', 'up', 'H', 'down') 3252 >>> g.addTransition('G', 'next', 'H', 'prev') 3253 >>> g.addTransition('H', 'next', 'I', 'prev') 3254 >>> g.addTransition('H', 'return', 'G') 3255 >>> g.setReciprocal('G', 'up', 'next') # Error w/ destinations 3256 Traceback (most recent call last): 3257 ... 3258 exploration.core.InvalidDestinationError... 3259 >>> g.setReciprocal('G', 'up', 'none') # Doesn't exist 3260 Traceback (most recent call last): 3261 ... 3262 exploration.core.MissingTransitionError... 3263 >>> g.getReciprocal('G', 'up') 3264 'down' 3265 >>> g.getReciprocal('H', 'down') 3266 'up' 3267 >>> g.getReciprocal('H', 'return') is None 3268 True 3269 >>> g.setReciprocal('G', 'up', 'return') 3270 >>> g.getReciprocal('G', 'up') 3271 'return' 3272 >>> g.getReciprocal('H', 'down') is None 3273 True 3274 >>> g.getReciprocal('H', 'return') 3275 'up' 3276 >>> g.setReciprocal('H', 'return', None) # remove the reciprocal 3277 >>> g.getReciprocal('G', 'up') is None 3278 True 3279 >>> g.getReciprocal('H', 'down') is None 3280 True 3281 >>> g.getReciprocal('H', 'return') is None 3282 True 3283 >>> g.setReciprocal('G', 'up', 'down', setBoth=False) # one-way 3284 >>> g.getReciprocal('G', 'up') 3285 'down' 3286 >>> g.getReciprocal('H', 'down') is None 3287 True 3288 >>> g.getReciprocal('H', 'return') is None 3289 True 3290 >>> g.setReciprocal('H', 'return', 'up', setBoth=False) # non-sym 3291 >>> g.getReciprocal('G', 'up') 3292 'down' 3293 >>> g.getReciprocal('H', 'down') is None 3294 True 3295 >>> g.getReciprocal('H', 'return') 3296 'up' 3297 >>> g.setReciprocal('H', 'down', 'up') # setBoth not needed 3298 >>> g.getReciprocal('G', 'up') 3299 'down' 3300 >>> g.getReciprocal('H', 'down') 3301 'up' 3302 >>> g.getReciprocal('H', 'return') # unchanged 3303 'up' 3304 >>> g.setReciprocal('G', 'up', 'return', cleanup=False) # no cleanup 3305 >>> g.getReciprocal('G', 'up') 3306 'return' 3307 >>> g.getReciprocal('H', 'down') 3308 'up' 3309 >>> g.getReciprocal('H', 'return') # unchanged 3310 'up' 3311 >>> # Cleanup only applies to reciprocal if setBoth is true 3312 >>> g.setReciprocal('H', 'down', 'up', setBoth=False) 3313 >>> g.getReciprocal('G', 'up') 3314 'return' 3315 >>> g.getReciprocal('H', 'down') 3316 'up' 3317 >>> g.getReciprocal('H', 'return') # not cleaned up w/out setBoth 3318 'up' 3319 >>> g.setReciprocal('H', 'down', 'up') # with cleanup and setBoth 3320 >>> g.getReciprocal('G', 'up') 3321 'down' 3322 >>> g.getReciprocal('H', 'down') 3323 'up' 3324 >>> g.getReciprocal('H', 'return') is None # cleaned up 3325 True 3326 """ 3327 dID = self.resolveDecision(decision) 3328 3329 dest = self.destination(dID, transition) # possible KeyError 3330 if reciprocal is None: 3331 rDest = None 3332 else: 3333 rDest = self.getDestination(dest, reciprocal) 3334 3335 # Set or delete reciprocal property 3336 if reciprocal is None: 3337 # Delete the property 3338 info = self.edges[dID, dest, transition] # type:ignore 3339 3340 old = info.pop('reciprocal') 3341 if setBoth: 3342 rDest = self.getDestination(dest, old) 3343 if rDest != dID: 3344 raise RuntimeError( 3345 f"Invalid reciprocal {old!r} for transition" 3346 f" {transition!r} from {self.identityOf(dID)}:" 3347 f" destination is {rDest}." 3348 ) 3349 rInfo = self.edges[dest, dID, old] # type:ignore 3350 if 'reciprocal' in rInfo: 3351 del rInfo['reciprocal'] 3352 else: 3353 # Set the property, checking for errors first 3354 if rDest is None: 3355 raise MissingTransitionError( 3356 f"Reciprocal transition {reciprocal!r} for" 3357 f" transition {transition!r} from decision" 3358 f" {self.identityOf(dID)} does not exist at" 3359 f" decision {self.identityOf(dest)}" 3360 ) 3361 3362 if rDest != dID: 3363 raise InvalidDestinationError( 3364 f"Reciprocal transition {reciprocal!r} from" 3365 f" decision {self.identityOf(dest)} does not lead" 3366 f" back to decision {self.identityOf(dID)}." 3367 ) 3368 3369 eProps = self.edges[dID, dest, transition] # type:ignore [index] 3370 abandoned = eProps.get('reciprocal') 3371 eProps['reciprocal'] = reciprocal 3372 if cleanup and abandoned not in (None, reciprocal): 3373 aProps = self.edges[dest, dID, abandoned] # type:ignore 3374 if 'reciprocal' in aProps: 3375 del aProps['reciprocal'] 3376 3377 if setBoth: 3378 rProps = self.edges[dest, dID, reciprocal] # type:ignore 3379 revAbandoned = rProps.get('reciprocal') 3380 rProps['reciprocal'] = transition 3381 # Sever old reciprocal relationship 3382 if cleanup and revAbandoned not in (None, transition): 3383 raProps = self.edges[ 3384 dID, # type:ignore 3385 dest, 3386 revAbandoned 3387 ] 3388 del raProps['reciprocal'] 3389 3390 def getReciprocalPair( 3391 self, 3392 decision: base.AnyDecisionSpecifier, 3393 transition: base.Transition 3394 ) -> Optional[Tuple[base.DecisionID, base.Transition]]: 3395 """ 3396 Returns a tuple containing both the destination decision ID and 3397 the transition at that decision which is the reciprocal of the 3398 specified destination & transition. Returns `None` if no 3399 reciprocal has been established for that transition, or if that 3400 decision or transition does not exist. 3401 3402 >>> g = DecisionGraph() 3403 >>> g.addDecision('A') 3404 0 3405 >>> g.addDecision('B') 3406 1 3407 >>> g.addDecision('C') 3408 2 3409 >>> g.addTransition('A', 'up', 'B', 'down') 3410 >>> g.addTransition('B', 'right', 'C', 'left') 3411 >>> g.addTransition('A', 'oneway', 'C') 3412 >>> g.getReciprocalPair('A', 'up') 3413 (1, 'down') 3414 >>> g.getReciprocalPair('B', 'down') 3415 (0, 'up') 3416 >>> g.getReciprocalPair('B', 'right') 3417 (2, 'left') 3418 >>> g.getReciprocalPair('C', 'left') 3419 (1, 'right') 3420 >>> g.getReciprocalPair('C', 'up') is None 3421 True 3422 >>> g.getReciprocalPair('Q', 'up') is None 3423 True 3424 >>> g.getReciprocalPair('A', 'tunnel') is None 3425 True 3426 """ 3427 try: 3428 dID = self.resolveDecision(decision) 3429 except MissingDecisionError: 3430 return None 3431 3432 reciprocal = self.getReciprocal(dID, transition) 3433 if reciprocal is None: 3434 return None 3435 else: 3436 destination = self.getDestination(dID, transition) 3437 if destination is None: 3438 return None 3439 else: 3440 return (destination, reciprocal) 3441 3442 def addDecision( 3443 self, 3444 name: base.DecisionName, 3445 domain: Optional[base.Domain] = None, 3446 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 3447 annotations: Optional[List[base.Annotation]] = None 3448 ) -> base.DecisionID: 3449 """ 3450 Adds a decision to the graph, without any transitions yet. Each 3451 decision will be assigned an ID so name collisions are allowed, 3452 but it's usually best to keep names unique at least within each 3453 zone. If no domain is provided, the `DEFAULT_DOMAIN` will be 3454 used for the decision's domain. A dictionary of tags and/or a 3455 list of annotations (strings in both cases) may be provided. 3456 3457 Returns the newly-assigned `DecisionID` for the decision it 3458 created. 3459 3460 Emits a `DecisionCollisionWarning` if a decision with the 3461 provided name already exists and the `WARN_OF_NAME_COLLISIONS` 3462 global variable is set to `True`. 3463 """ 3464 # Defaults 3465 if domain is None: 3466 domain = base.DEFAULT_DOMAIN 3467 if tags is None: 3468 tags = {} 3469 if annotations is None: 3470 annotations = [] 3471 3472 # Error checking 3473 if name in self.nameLookup and WARN_OF_NAME_COLLISIONS: 3474 warnings.warn( 3475 ( 3476 f"Adding decision {name!r}: Another decision with" 3477 f" that name already exists." 3478 ), 3479 DecisionCollisionWarning 3480 ) 3481 3482 dID = self._assignID() 3483 3484 # Add the decision 3485 self.add_node( 3486 dID, 3487 name=name, 3488 domain=domain, 3489 tags=tags, 3490 annotations=annotations 3491 ) 3492 #TODO: Elide tags/annotations if they're empty? 3493 3494 # Track it in our `nameLookup` dictionary 3495 self.nameLookup.setdefault(name, []).append(dID) 3496 3497 return dID 3498 3499 def addIdentifiedDecision( 3500 self, 3501 dID: base.DecisionID, 3502 name: base.DecisionName, 3503 domain: Optional[base.Domain] = None, 3504 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 3505 annotations: Optional[List[base.Annotation]] = None 3506 ) -> None: 3507 """ 3508 Adds a new decision to the graph using a specific decision ID, 3509 rather than automatically assigning a new decision ID like 3510 `addDecision` does. Otherwise works like `addDecision`. 3511 3512 Raises a `MechanismCollisionError` if the specified decision ID 3513 is already in use. 3514 """ 3515 # Defaults 3516 if domain is None: 3517 domain = base.DEFAULT_DOMAIN 3518 if tags is None: 3519 tags = {} 3520 if annotations is None: 3521 annotations = [] 3522 3523 # Error checking 3524 if dID in self.nodes: 3525 raise MechanismCollisionError( 3526 f"Cannot add a node with id {dID} and name {name!r}:" 3527 f" that ID is already used by node {self.identityOf(dID)}" 3528 ) 3529 3530 if name in self.nameLookup and WARN_OF_NAME_COLLISIONS: 3531 warnings.warn( 3532 ( 3533 f"Adding decision {name!r}: Another decision with" 3534 f" that name already exists." 3535 ), 3536 DecisionCollisionWarning 3537 ) 3538 3539 # Add the decision 3540 self.add_node( 3541 dID, 3542 name=name, 3543 domain=domain, 3544 tags=tags, 3545 annotations=annotations 3546 ) 3547 #TODO: Elide tags/annotations if they're empty? 3548 3549 # Track it in our `nameLookup` dictionary 3550 self.nameLookup.setdefault(name, []).append(dID) 3551 3552 def addTransition( 3553 self, 3554 fromDecision: base.AnyDecisionSpecifier, 3555 name: base.Transition, 3556 toDecision: base.AnyDecisionSpecifier, 3557 reciprocal: Optional[base.Transition] = None, 3558 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 3559 annotations: Optional[List[base.Annotation]] = None, 3560 revTags: Optional[Dict[base.Tag, base.TagValue]] = None, 3561 revAnnotations: Optional[List[base.Annotation]] = None, 3562 requires: Optional[base.Requirement] = None, 3563 consequence: Optional[base.Consequence] = None, 3564 revRequires: Optional[base.Requirement] = None, 3565 revConsequece: Optional[base.Consequence] = None 3566 ) -> None: 3567 """ 3568 Adds a transition connecting two decisions. A specifier for each 3569 decision is required, as is a name for the transition. If a 3570 `reciprocal` is provided, a reciprocal edge will be added in the 3571 opposite direction using that name; by default only the specified 3572 edge is added. A `TransitionCollisionError` will be raised if the 3573 `reciprocal` matches the name of an existing edge at the 3574 destination decision. 3575 3576 Both decisions must already exist, or a `MissingDecisionError` 3577 will be raised. 3578 3579 A dictionary of tags and/or a list of annotations may be 3580 provided. Tags and/or annotations for the reverse edge may also 3581 be specified if one is being added. 3582 3583 The `requires`, `consequence`, `revRequires`, and `revConsequece` 3584 arguments specify requirements and/or consequences of the new 3585 outgoing and reciprocal edges. 3586 """ 3587 # Defaults 3588 if tags is None: 3589 tags = {} 3590 if annotations is None: 3591 annotations = [] 3592 if revTags is None: 3593 revTags = {} 3594 if revAnnotations is None: 3595 revAnnotations = [] 3596 3597 # Error checking 3598 fromID = self.resolveDecision(fromDecision) 3599 toID = self.resolveDecision(toDecision) 3600 3601 # Note: have to check this first so we don't add the forward edge 3602 # and then error out after a side effect! 3603 if ( 3604 reciprocal is not None 3605 and self.getDestination(toDecision, reciprocal) is not None 3606 ): 3607 raise TransitionCollisionError( 3608 f"Cannot add a transition from" 3609 f" {self.identityOf(fromDecision)} to" 3610 f" {self.identityOf(toDecision)} with reciprocal edge" 3611 f" {reciprocal!r}: {reciprocal!r} is already used as an" 3612 f" edge name at {self.identityOf(toDecision)}." 3613 ) 3614 3615 # Add the edge 3616 self.add_edge( 3617 fromID, 3618 toID, 3619 key=name, 3620 tags=tags, 3621 annotations=annotations 3622 ) 3623 self.setTransitionRequirement(fromDecision, name, requires) 3624 if consequence is not None: 3625 self.setConsequence(fromDecision, name, consequence) 3626 if reciprocal is not None: 3627 # Add the reciprocal edge 3628 self.add_edge( 3629 toID, 3630 fromID, 3631 key=reciprocal, 3632 tags=revTags, 3633 annotations=revAnnotations 3634 ) 3635 self.setReciprocal(fromID, name, reciprocal) 3636 self.setTransitionRequirement( 3637 toDecision, 3638 reciprocal, 3639 revRequires 3640 ) 3641 if revConsequece is not None: 3642 self.setConsequence(toDecision, reciprocal, revConsequece) 3643 3644 def removeTransition( 3645 self, 3646 fromDecision: base.AnyDecisionSpecifier, 3647 transition: base.Transition, 3648 removeReciprocal=False 3649 ) -> Union[ 3650 TransitionProperties, 3651 Tuple[TransitionProperties, TransitionProperties] 3652 ]: 3653 """ 3654 Removes a transition. If `removeReciprocal` is true (False is the 3655 default) any reciprocal transition will also be removed (but no 3656 error will occur if there wasn't a reciprocal). 3657 3658 For each removed transition, *every* transition that targeted 3659 that transition as its reciprocal will have its reciprocal set to 3660 `None`, to avoid leaving any invalid reciprocal values. 3661 3662 Raises a `KeyError` if either the target decision or the target 3663 transition does not exist. 3664 3665 Returns a transition properties dictionary with the properties 3666 of the removed transition, or if `removeReciprocal` is true, 3667 returns a pair of such dictionaries for the target transition 3668 and its reciprocal. 3669 3670 ## Example 3671 3672 >>> g = DecisionGraph() 3673 >>> g.addDecision('A') 3674 0 3675 >>> g.addDecision('B') 3676 1 3677 >>> g.addTransition('A', 'up', 'B', 'down', tags={'wide'}) 3678 >>> g.addTransition('A', 'in', 'B', 'out') # we won't touch this 3679 >>> g.addTransition('A', 'next', 'B') 3680 >>> g.setReciprocal('A', 'next', 'down', setBoth=False) 3681 >>> p = g.removeTransition('A', 'up') 3682 >>> p['tags'] 3683 {'wide'} 3684 >>> g.destinationsFrom('A') 3685 {'in': 1, 'next': 1} 3686 >>> g.destinationsFrom('B') 3687 {'down': 0, 'out': 0} 3688 >>> g.getReciprocal('B', 'down') is None 3689 True 3690 >>> g.getReciprocal('A', 'next') # Asymmetrical left over 3691 'down' 3692 >>> g.getReciprocal('A', 'in') # not affected 3693 'out' 3694 >>> g.getReciprocal('B', 'out') # not affected 3695 'in' 3696 >>> # Now with removeReciprocal set to True 3697 >>> g.addTransition('A', 'up', 'B') # add this back in 3698 >>> g.setReciprocal('A', 'up', 'down') # sets both 3699 >>> p = g.removeTransition('A', 'up', removeReciprocal=True) 3700 >>> g.destinationsFrom('A') 3701 {'in': 1, 'next': 1} 3702 >>> g.destinationsFrom('B') 3703 {'out': 0} 3704 >>> g.getReciprocal('A', 'next') is None 3705 True 3706 >>> g.getReciprocal('A', 'in') # not affected 3707 'out' 3708 >>> g.getReciprocal('B', 'out') # not affected 3709 'in' 3710 >>> g.removeTransition('A', 'none') 3711 Traceback (most recent call last): 3712 ... 3713 exploration.core.MissingTransitionError... 3714 >>> g.removeTransition('Z', 'nope') 3715 Traceback (most recent call last): 3716 ... 3717 exploration.core.MissingDecisionError... 3718 """ 3719 # Resolve target ID 3720 fromID = self.resolveDecision(fromDecision) 3721 3722 # raises if either is missing: 3723 destination = self.destination(fromID, transition) 3724 reciprocal = self.getReciprocal(fromID, transition) 3725 3726 # Get dictionaries of parallel & antiparallel edges to be 3727 # checked for invalid reciprocals after removing edges 3728 # Note: these will update live as we remove edges 3729 allAntiparallel = self[destination][fromID] 3730 allParallel = self[fromID][destination] 3731 3732 # Remove the target edge 3733 fProps = self.getTransitionProperties(fromID, transition) 3734 self.remove_edge(fromID, destination, transition) 3735 3736 # Clean up any dangling reciprocal values 3737 for tProps in allAntiparallel.values(): 3738 if tProps.get('reciprocal') == transition: 3739 del tProps['reciprocal'] 3740 3741 # Remove the reciprocal if requested 3742 if removeReciprocal and reciprocal is not None: 3743 rProps = self.getTransitionProperties(destination, reciprocal) 3744 self.remove_edge(destination, fromID, reciprocal) 3745 3746 # Clean up any dangling reciprocal values 3747 for tProps in allParallel.values(): 3748 if tProps.get('reciprocal') == reciprocal: 3749 del tProps['reciprocal'] 3750 3751 return (fProps, rProps) 3752 else: 3753 return fProps 3754 3755 def addMechanism( 3756 self, 3757 name: base.MechanismName, 3758 where: Optional[base.AnyDecisionSpecifier] = None 3759 ) -> base.MechanismID: 3760 """ 3761 Creates a new mechanism with the given name at the specified 3762 decision, returning its assigned ID. If `where` is `None`, it 3763 creates a global mechanism. Raises a `MechanismCollisionError` 3764 if a mechanism with the same name already exists at a specified 3765 decision (or already exists as a global mechanism). 3766 3767 Note that if the decision is deleted, the mechanism will be as 3768 well. 3769 3770 Since `MechanismState`s are not tracked by `DecisionGraph`s but 3771 instead are part of a `State`, the mechanism won't be in any 3772 particular state, which means it will be treated as being in the 3773 `base.DEFAULT_MECHANISM_STATE`. 3774 """ 3775 if where is None: 3776 mechs = self.globalMechanisms 3777 dID = None 3778 else: 3779 dID = self.resolveDecision(where) 3780 mechs = self.nodes[dID].setdefault('mechanisms', {}) 3781 3782 if name in mechs: 3783 if dID is None: 3784 raise MechanismCollisionError( 3785 f"A global mechanism named {name!r} already exists." 3786 ) 3787 else: 3788 raise MechanismCollisionError( 3789 f"A mechanism named {name!r} already exists at" 3790 f" decision {self.identityOf(dID)}." 3791 ) 3792 3793 mID = self._assignMechanismID() 3794 mechs[name] = mID 3795 self.mechanisms[mID] = (dID, name) 3796 return mID 3797 3798 def mechanismsAt( 3799 self, 3800 decision: base.AnyDecisionSpecifier 3801 ) -> Dict[base.MechanismName, base.MechanismID]: 3802 """ 3803 Returns a dictionary mapping mechanism names to their IDs for 3804 all mechanisms at the specified decision. 3805 """ 3806 dID = self.resolveDecision(decision) 3807 3808 return self.nodes[dID]['mechanisms'] 3809 3810 def mechanismDetails( 3811 self, 3812 mID: base.MechanismID 3813 ) -> Optional[Tuple[Optional[base.DecisionID], base.MechanismName]]: 3814 """ 3815 Returns a tuple containing the decision ID and mechanism name 3816 for the specified mechanism. Returns `None` if there is no 3817 mechanism with that ID. For global mechanisms, `None` is used in 3818 place of a decision ID. 3819 """ 3820 return self.mechanisms.get(mID) 3821 3822 def deleteMechanism(self, mID: base.MechanismID) -> None: 3823 """ 3824 Deletes the specified mechanism. 3825 """ 3826 name, dID = self.mechanisms.pop(mID) 3827 3828 del self.nodes[dID]['mechanisms'][name] 3829 3830 def localLookup( 3831 self, 3832 startFrom: Union[ 3833 base.AnyDecisionSpecifier, 3834 Collection[base.AnyDecisionSpecifier] 3835 ], 3836 findAmong: Callable[ 3837 ['DecisionGraph', Union[Set[base.DecisionID], str]], 3838 Optional[LookupResult] 3839 ], 3840 fallbackLayerName: Optional[str] = "fallback", 3841 fallbackToAllDecisions: bool = True 3842 ) -> Optional[LookupResult]: 3843 """ 3844 Looks up some kind of result in the graph by starting from a 3845 base set of decisions and widening the search iteratively based 3846 on zones. This first searches for result(s) in the set of 3847 decisions given, then in the set of all decisions which are in 3848 level-0 zones containing those decisions, then in level-1 zones, 3849 etc. When it runs out of relevant zones, it will check all 3850 decisions which are in any domain that a decision from the 3851 initial search set is in, and then if `fallbackLayerName` is a 3852 string, it will provide that string instead of a set of decision 3853 IDs to the `findAmong` function as the next layer to search. 3854 After the `fallbackLayerName` is used, if 3855 `fallbackToAllDecisions` is `True` (the default) a final search 3856 will be run on all decisions in the graph. The provided 3857 `findAmong` function is called on each successive decision ID 3858 set, until it generates a non-`None` result. We stop and return 3859 that non-`None` result as soon as one is generated. But if none 3860 of the decision sets consulted generate non-`None` results, then 3861 the entire result will be `None`. 3862 """ 3863 # Normalize starting decisions to a set 3864 if isinstance(startFrom, (int, str, base.DecisionSpecifier)): 3865 startFrom = set([startFrom]) 3866 3867 # Resolve decision IDs; convert to list 3868 searchArea: Union[Set[base.DecisionID], str] = set( 3869 self.resolveDecision(spec) for spec in startFrom 3870 ) 3871 3872 # Find all ancestor zones & all relevant domains 3873 allAncestors = set() 3874 relevantDomains = set() 3875 for startingDecision in searchArea: 3876 allAncestors |= self.zoneAncestors(startingDecision) 3877 relevantDomains.add(self.domainFor(startingDecision)) 3878 3879 # Build layers dictionary 3880 ancestorLayers: Dict[int, Set[base.Zone]] = {} 3881 for zone in allAncestors: 3882 info = self.getZoneInfo(zone) 3883 assert info is not None 3884 level = info.level 3885 ancestorLayers.setdefault(level, set()).add(zone) 3886 3887 searchLayers: LookupLayersList = ( 3888 cast(LookupLayersList, [None]) 3889 + cast(LookupLayersList, sorted(ancestorLayers.keys())) 3890 + cast(LookupLayersList, ["domains"]) 3891 ) 3892 if fallbackLayerName is not None: 3893 searchLayers.append("fallback") 3894 3895 if fallbackToAllDecisions: 3896 searchLayers.append("all") 3897 3898 # Continue our search through zone layers 3899 for layer in searchLayers: 3900 # Update search area on subsequent iterations 3901 if layer == "domains": 3902 searchArea = set() 3903 for relevant in relevantDomains: 3904 searchArea |= self.allDecisionsInDomain(relevant) 3905 elif layer == "fallback": 3906 assert fallbackLayerName is not None 3907 searchArea = fallbackLayerName 3908 elif layer == "all": 3909 searchArea = set(self.nodes) 3910 elif layer is not None: 3911 layer = cast(int, layer) # must be an integer 3912 searchZones = ancestorLayers[layer] 3913 searchArea = set() 3914 for zone in searchZones: 3915 searchArea |= self.allDecisionsInZone(zone) 3916 # else it's the first iteration and we use the starting 3917 # searchArea 3918 3919 searchResult: Optional[LookupResult] = findAmong( 3920 self, 3921 searchArea 3922 ) 3923 3924 if searchResult is not None: 3925 return searchResult 3926 3927 # Didn't find any non-None results. 3928 return None 3929 3930 @staticmethod 3931 def uniqueMechanismFinder(name: base.MechanismName) -> Callable[ 3932 ['DecisionGraph', Union[Set[base.DecisionID], str]], 3933 Optional[base.MechanismID] 3934 ]: 3935 """ 3936 Returns a search function that looks for the given mechanism ID, 3937 suitable for use with `localLookup`. The finder will raise a 3938 `MechanismCollisionError` if it finds more than one mechanism 3939 with the specified name at the same level of the search. 3940 """ 3941 def namedMechanismFinder( 3942 graph: 'DecisionGraph', 3943 searchIn: Union[Set[base.DecisionID], str] 3944 ) -> Optional[base.MechanismID]: 3945 """ 3946 Generated finder function for `localLookup` to find a unique 3947 mechanism by name. 3948 """ 3949 candidates: List[base.DecisionID] = [] 3950 3951 if searchIn == "fallback": 3952 if name in graph.globalMechanisms: 3953 candidates = [graph.globalMechanisms[name]] 3954 3955 else: 3956 assert isinstance(searchIn, set) 3957 for dID in searchIn: 3958 mechs = graph.nodes[dID].get('mechanisms', {}) 3959 if name in mechs: 3960 candidates.append(mechs[name]) 3961 3962 if len(candidates) > 1: 3963 raise MechanismCollisionError( 3964 f"There are {len(candidates)} mechanisms named {name!r}" 3965 f" in the search area ({len(searchIn)} decisions(s))." 3966 ) 3967 elif len(candidates) == 1: 3968 return candidates[0] 3969 else: 3970 return None 3971 3972 return namedMechanismFinder 3973 3974 def lookupMechanism( 3975 self, 3976 startFrom: Union[ 3977 base.AnyDecisionSpecifier, 3978 Collection[base.AnyDecisionSpecifier] 3979 ], 3980 name: base.MechanismName 3981 ) -> base.MechanismID: 3982 """ 3983 Looks up the mechanism with the given name 'closest' to the 3984 given decision or set of decisions. First it looks for a 3985 mechanism with that name that's at one of those decisions. Then 3986 it starts looking in level-0 zones which contain any of them, 3987 then in level-1 zones, and so on. If it finds two mechanisms 3988 with the target name during the same search pass, it raises a 3989 `MechanismCollisionError`, but if it finds one it returns it. 3990 Raises a `MissingMechanismError` if there is no mechanisms with 3991 that name among global mechanisms (searched after the last 3992 applicable level of zones) or anywhere in the graph (which is the 3993 final level of search after checking global mechanisms). 3994 3995 For example: 3996 3997 >>> d = DecisionGraph() 3998 >>> d.addDecision('A') 3999 0 4000 >>> d.addDecision('B') 4001 1 4002 >>> d.addDecision('C') 4003 2 4004 >>> d.addDecision('D') 4005 3 4006 >>> d.addDecision('E') 4007 4 4008 >>> d.addMechanism('switch', 'A') 4009 0 4010 >>> d.addMechanism('switch', 'B') 4011 1 4012 >>> d.addMechanism('switch', 'C') 4013 2 4014 >>> d.addMechanism('lever', 'D') 4015 3 4016 >>> d.addMechanism('lever', None) # global 4017 4 4018 >>> d.createZone('Z1', 0) 4019 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 4020 annotations=[]) 4021 >>> d.createZone('Z2', 0) 4022 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 4023 annotations=[]) 4024 >>> d.createZone('Zup', 1) 4025 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 4026 annotations=[]) 4027 >>> d.addDecisionToZone('A', 'Z1') 4028 >>> d.addDecisionToZone('B', 'Z1') 4029 >>> d.addDecisionToZone('C', 'Z2') 4030 >>> d.addDecisionToZone('D', 'Z2') 4031 >>> d.addDecisionToZone('E', 'Z1') 4032 >>> d.addZoneToZone('Z1', 'Zup') 4033 >>> d.addZoneToZone('Z2', 'Zup') 4034 >>> d.lookupMechanism(set(), 'switch') # 3x among all decisions 4035 Traceback (most recent call last): 4036 ... 4037 exploration.core.MechanismCollisionError... 4038 >>> d.lookupMechanism(set(), 'lever') # 1x global > 1x all 4039 4 4040 >>> d.lookupMechanism({'D'}, 'lever') # local 4041 3 4042 >>> d.lookupMechanism({'A'}, 'lever') # found at D via Zup 4043 3 4044 >>> d.lookupMechanism({'A', 'D'}, 'lever') # local again 4045 3 4046 >>> d.lookupMechanism({'A'}, 'switch') # local 4047 0 4048 >>> d.lookupMechanism({'B'}, 'switch') # local 4049 1 4050 >>> d.lookupMechanism({'C'}, 'switch') # local 4051 2 4052 >>> d.lookupMechanism({'A', 'B'}, 'switch') # ambiguous 4053 Traceback (most recent call last): 4054 ... 4055 exploration.core.MechanismCollisionError... 4056 >>> d.lookupMechanism({'A', 'B', 'C'}, 'switch') # ambiguous 4057 Traceback (most recent call last): 4058 ... 4059 exploration.core.MechanismCollisionError... 4060 >>> d.lookupMechanism({'B', 'D'}, 'switch') # not ambiguous 4061 1 4062 >>> d.lookupMechanism({'E', 'D'}, 'switch') # ambiguous at L0 zone 4063 Traceback (most recent call last): 4064 ... 4065 exploration.core.MechanismCollisionError... 4066 >>> d.lookupMechanism({'E'}, 'switch') # ambiguous at L0 zone 4067 Traceback (most recent call last): 4068 ... 4069 exploration.core.MechanismCollisionError... 4070 >>> d.lookupMechanism({'D'}, 'switch') # found at L0 zone 4071 2 4072 """ 4073 result = self.localLookup( 4074 startFrom, 4075 DecisionGraph.uniqueMechanismFinder(name) 4076 ) 4077 if result is None: 4078 raise MissingMechanismError( 4079 f"No mechanism named {name!r}" 4080 ) 4081 else: 4082 return result 4083 4084 def resolveMechanism( 4085 self, 4086 specifier: base.AnyMechanismSpecifier, 4087 startFrom: Union[ 4088 None, 4089 base.AnyDecisionSpecifier, 4090 Collection[base.AnyDecisionSpecifier] 4091 ] = None 4092 ) -> base.MechanismID: 4093 """ 4094 Works like `lookupMechanism`, except it accepts a 4095 `base.AnyMechanismSpecifier` which may have position information 4096 baked in, and so the `startFrom` information is optional. If 4097 position information isn't specified in the mechanism specifier 4098 and startFrom is not provided, the mechanism is searched for at 4099 the global scope and then in the entire graph. On the other 4100 hand, if the specifier includes any position information, the 4101 startFrom value provided here will be ignored. 4102 """ 4103 if isinstance(specifier, base.MechanismID): 4104 return specifier 4105 4106 elif isinstance(specifier, base.MechanismName): 4107 if startFrom is None: 4108 startFrom = set() 4109 return self.lookupMechanism(startFrom, specifier) 4110 4111 elif isinstance(specifier, tuple) and len(specifier) == 4: 4112 domain, zone, decision, mechanism = specifier 4113 if domain is None and zone is None and decision is None: 4114 if startFrom is None: 4115 startFrom = set() 4116 return self.lookupMechanism(startFrom, mechanism) 4117 4118 elif decision is not None: 4119 startFrom = { 4120 self.resolveDecision( 4121 base.DecisionSpecifier(domain, zone, decision) 4122 ) 4123 } 4124 return self.lookupMechanism(startFrom, mechanism) 4125 4126 else: # decision is None but domain and/or zone aren't 4127 startFrom = set() 4128 if zone is not None: 4129 baseStart = self.allDecisionsInZone(zone) 4130 else: 4131 baseStart = set(self) 4132 4133 if domain is None: 4134 startFrom = baseStart 4135 else: 4136 for dID in baseStart: 4137 if self.domainFor(dID) == domain: 4138 startFrom.add(dID) 4139 return self.lookupMechanism(startFrom, mechanism) 4140 4141 else: 4142 raise TypeError( 4143 f"Invalid mechanism specifier: {repr(specifier)}" 4144 f"\n(Must be a mechanism ID, mechanism name, or" 4145 f" mechanism specifier tuple)" 4146 ) 4147 4148 def walkConsequenceMechanisms( 4149 self, 4150 consequence: base.Consequence, 4151 searchFrom: Set[base.DecisionID] 4152 ) -> Generator[base.MechanismID, None, None]: 4153 """ 4154 Yields each requirement in the given `base.Consequence`, 4155 including those in `base.Condition`s, `base.ConditionalSkill`s 4156 within `base.Challenge`s, and those set or toggled by 4157 `base.Effect`s. The `searchFrom` argument specifies where to 4158 start searching for mechanisms, since requirements include them 4159 by name, not by ID. 4160 """ 4161 for part in base.walkParts(consequence): 4162 if isinstance(part, dict): 4163 if 'skills' in part: # a Challenge 4164 for cSkill in part['skills'].walk(): 4165 if isinstance(cSkill, base.ConditionalSkill): 4166 yield from self.walkRequirementMechanisms( 4167 cSkill.requirement, 4168 searchFrom 4169 ) 4170 elif 'condition' in part: # a Condition 4171 yield from self.walkRequirementMechanisms( 4172 part['condition'], 4173 searchFrom 4174 ) 4175 elif 'value' in part: # an Effect 4176 val = part['value'] 4177 if part['type'] == 'set': 4178 if ( 4179 isinstance(val, tuple) 4180 and len(val) == 2 4181 and isinstance(val[1], base.State) 4182 ): 4183 yield from self.walkRequirementMechanisms( 4184 base.ReqMechanism(val[0], val[1]), 4185 searchFrom 4186 ) 4187 elif part['type'] == 'toggle': 4188 if isinstance(val, tuple): 4189 assert len(val) == 2 4190 yield from self.walkRequirementMechanisms( 4191 base.ReqMechanism(val[0], '_'), 4192 # state part is ignored here 4193 searchFrom 4194 ) 4195 4196 def walkRequirementMechanisms( 4197 self, 4198 req: base.Requirement, 4199 searchFrom: Set[base.DecisionID] 4200 ) -> Generator[base.MechanismID, None, None]: 4201 """ 4202 Given a requirement, yields any mechanisms mentioned in that 4203 requirement, in depth-first traversal order. 4204 """ 4205 for part in req.walk(): 4206 if isinstance(part, base.ReqMechanism): 4207 mech = part.mechanism 4208 yield self.resolveMechanism( 4209 mech, 4210 startFrom=searchFrom 4211 ) 4212 4213 def addUnexploredEdge( 4214 self, 4215 fromDecision: base.AnyDecisionSpecifier, 4216 name: base.Transition, 4217 destinationName: Optional[base.DecisionName] = None, 4218 reciprocal: Optional[base.Transition] = 'return', 4219 toDomain: Optional[base.Domain] = None, 4220 placeInZone: Union[base.Zone, type[base.DefaultZone], None] = None, 4221 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 4222 annotations: Optional[List[base.Annotation]] = None, 4223 revTags: Optional[Dict[base.Tag, base.TagValue]] = None, 4224 revAnnotations: Optional[List[base.Annotation]] = None, 4225 requires: Optional[base.Requirement] = None, 4226 consequence: Optional[base.Consequence] = None, 4227 revRequires: Optional[base.Requirement] = None, 4228 revConsequece: Optional[base.Consequence] = None 4229 ) -> base.DecisionID: 4230 """ 4231 Adds a transition connecting to a new decision named `'_u.-n-'` 4232 where '-n-' is the number of unknown decisions (named or not) 4233 that have ever been created in this graph (or using the 4234 specified destination name if one is provided). This represents 4235 a transition to an unknown destination. The destination node 4236 gets tagged 'unconfirmed'. 4237 4238 This also adds a reciprocal transition in the reverse direction, 4239 unless `reciprocal` is set to `None`. The reciprocal will use 4240 the provided name (default is 'return'). The new decision will 4241 be in the same domain as the decision it's connected to, unless 4242 `toDecision` is specified, in which case it will be in that 4243 domain. 4244 4245 The new decision will not be placed into any zones, unless 4246 `placeInZone` is specified, in which case it will be placed into 4247 that zone. If that zone needs to be created, it will be created 4248 at level 0; in that case that zone will be added to any 4249 grandparent zones of the decision we're branching off of. If 4250 `placeInZone` is set to `base.DefaultZone`, then the new 4251 decision will be placed into each parent zone of the decision 4252 we're branching off of, as long as the new decision is in the 4253 same domain as the decision we're branching from (otherwise only 4254 an explicit `placeInZone` would apply). 4255 4256 The ID of the decision that was created is returned. 4257 4258 A `MissingDecisionError` will be raised if the starting decision 4259 does not exist, a `TransitionCollisionError` will be raised if 4260 it exists but already has a transition with the given name, and a 4261 `DecisionCollisionWarning` will be issued if a decision with the 4262 specified destination name already exists (won't happen when 4263 using an automatic name). 4264 4265 Lists of tags and/or annotations (strings in both cases) may be 4266 provided. These may also be provided for the reciprocal edge. 4267 4268 Similarly, requirements and/or consequences for either edge may 4269 be provided. 4270 4271 ## Example 4272 4273 >>> g = DecisionGraph() 4274 >>> g.addDecision('A') 4275 0 4276 >>> g.addUnexploredEdge('A', 'up') 4277 1 4278 >>> g.nameFor(1) 4279 '_u.0' 4280 >>> g.decisionTags(1) 4281 {'unconfirmed': 1} 4282 >>> g.addUnexploredEdge('A', 'right', 'B') 4283 2 4284 >>> g.nameFor(2) 4285 'B' 4286 >>> g.decisionTags(2) 4287 {'unconfirmed': 1} 4288 >>> g.addUnexploredEdge('A', 'down', None, 'up') 4289 3 4290 >>> g.nameFor(3) 4291 '_u.2' 4292 >>> g.addUnexploredEdge( 4293 ... '_u.0', 4294 ... 'beyond', 4295 ... toDomain='otherDomain', 4296 ... tags={'fast':1}, 4297 ... revTags={'slow':1}, 4298 ... annotations=['comment'], 4299 ... revAnnotations=['one', 'two'], 4300 ... requires=base.ReqCapability('dash'), 4301 ... revRequires=base.ReqCapability('super dash'), 4302 ... consequence=[base.effect(gain='super dash')], 4303 ... revConsequece=[base.effect(lose='super dash')] 4304 ... ) 4305 4 4306 >>> g.nameFor(4) 4307 '_u.3' 4308 >>> g.domainFor(4) 4309 'otherDomain' 4310 >>> g.transitionTags('_u.0', 'beyond') 4311 {'fast': 1} 4312 >>> g.transitionAnnotations('_u.0', 'beyond') 4313 ['comment'] 4314 >>> g.getTransitionRequirement('_u.0', 'beyond') 4315 ReqCapability('dash') 4316 >>> e = g.getConsequence('_u.0', 'beyond') 4317 >>> e == [base.effect(gain='super dash')] 4318 True 4319 >>> g.transitionTags('_u.3', 'return') 4320 {'slow': 1} 4321 >>> g.transitionAnnotations('_u.3', 'return') 4322 ['one', 'two'] 4323 >>> g.getTransitionRequirement('_u.3', 'return') 4324 ReqCapability('super dash') 4325 >>> e = g.getConsequence('_u.3', 'return') 4326 >>> e == [base.effect(lose='super dash')] 4327 True 4328 """ 4329 # Defaults 4330 if tags is None: 4331 tags = {} 4332 if annotations is None: 4333 annotations = [] 4334 if revTags is None: 4335 revTags = {} 4336 if revAnnotations is None: 4337 revAnnotations = [] 4338 4339 # Resolve ID 4340 fromID = self.resolveDecision(fromDecision) 4341 if toDomain is None: 4342 toDomain = self.domainFor(fromID) 4343 4344 if name in self.destinationsFrom(fromID): 4345 raise TransitionCollisionError( 4346 f"Cannot add a new edge {name!r}:" 4347 f" {self.identityOf(fromDecision)} already has an" 4348 f" outgoing edge with that name." 4349 ) 4350 4351 if destinationName in self.nameLookup and WARN_OF_NAME_COLLISIONS: 4352 warnings.warn( 4353 ( 4354 f"Cannot add a new unexplored node" 4355 f" {destinationName!r}: A decision with that name" 4356 f" already exists.\n(Leave destinationName as None" 4357 f" to use an automatic name.)" 4358 ), 4359 DecisionCollisionWarning 4360 ) 4361 4362 # Create the new unexplored decision and add the edge 4363 if destinationName is None: 4364 toName = '_u.' + str(self.unknownCount) 4365 else: 4366 toName = destinationName 4367 self.unknownCount += 1 4368 newID = self.addDecision(toName, domain=toDomain) 4369 self.addTransition( 4370 fromID, 4371 name, 4372 newID, 4373 tags=tags, 4374 annotations=annotations 4375 ) 4376 self.setTransitionRequirement(fromID, name, requires) 4377 if consequence is not None: 4378 self.setConsequence(fromID, name, consequence) 4379 4380 # Add it to a zone if requested 4381 if ( 4382 placeInZone is base.DefaultZone 4383 and toDomain == self.domainFor(fromID) 4384 ): 4385 # Add to each parent of the from decision 4386 for parent in self.zoneParents(fromID): 4387 self.addDecisionToZone(newID, parent) 4388 elif placeInZone is not None: 4389 # Otherwise add it to one specific zone, creating that zone 4390 # at level 0 if necessary 4391 assert isinstance(placeInZone, base.Zone) 4392 if self.getZoneInfo(placeInZone) is None: 4393 self.createZone(placeInZone, 0) 4394 # Add new zone to each grandparent of the from decision 4395 for parent in self.zoneParents(fromID): 4396 for grandparent in self.zoneParents(parent): 4397 self.addZoneToZone(placeInZone, grandparent) 4398 self.addDecisionToZone(newID, placeInZone) 4399 4400 # Create the reciprocal edge 4401 if reciprocal is not None: 4402 self.addTransition( 4403 newID, 4404 reciprocal, 4405 fromID, 4406 tags=revTags, 4407 annotations=revAnnotations 4408 ) 4409 self.setTransitionRequirement(newID, reciprocal, revRequires) 4410 if revConsequece is not None: 4411 self.setConsequence(newID, reciprocal, revConsequece) 4412 # Set as a reciprocal 4413 self.setReciprocal(fromID, name, reciprocal) 4414 4415 # Tag the destination as 'unconfirmed' 4416 self.tagDecision(newID, 'unconfirmed') 4417 4418 # Return ID of new destination 4419 return newID 4420 4421 def retargetTransition( 4422 self, 4423 fromDecision: base.AnyDecisionSpecifier, 4424 transition: base.Transition, 4425 newDestination: base.AnyDecisionSpecifier, 4426 swapReciprocal=True, 4427 errorOnNameColision=True 4428 ) -> Optional[base.Transition]: 4429 """ 4430 Given a particular decision and a transition at that decision, 4431 changes that transition so that it goes to the specified new 4432 destination instead of wherever it was connected to before. If 4433 the new destination is the same as the old one, no changes are 4434 made. 4435 4436 If `swapReciprocal` is set to True (the default) then any 4437 reciprocal edge at the old destination will be deleted, and a 4438 new reciprocal edge from the new destination with equivalent 4439 properties to the original reciprocal will be created, pointing 4440 to the origin of the specified transition. If `swapReciprocal` 4441 is set to False, then the reciprocal relationship with any old 4442 reciprocal edge will be removed, but the old reciprocal edge 4443 will not be changed. 4444 4445 Note that if `errorOnNameColision` is True (the default), then 4446 if the reciprocal transition has the same name as a transition 4447 which already exists at the new destination node, a 4448 `TransitionCollisionError` will be thrown. However, if it is set 4449 to False, the reciprocal transition will be renamed with a suffix 4450 to avoid any possible name collisions. Either way, the name of 4451 the reciprocal transition (possibly just changed) will be 4452 returned, or None if there was no reciprocal transition. 4453 4454 ## Example 4455 4456 >>> g = DecisionGraph() 4457 >>> for fr, to, nm in [ 4458 ... ('A', 'B', 'up'), 4459 ... ('A', 'B', 'up2'), 4460 ... ('B', 'A', 'down'), 4461 ... ('B', 'B', 'self'), 4462 ... ('B', 'C', 'next'), 4463 ... ('C', 'B', 'prev') 4464 ... ]: 4465 ... if g.getDecision(fr) is None: 4466 ... g.addDecision(fr) 4467 ... if g.getDecision(to) is None: 4468 ... g.addDecision(to) 4469 ... g.addTransition(fr, nm, to) 4470 0 4471 1 4472 2 4473 >>> g.setReciprocal('A', 'up', 'down') 4474 >>> g.setReciprocal('B', 'next', 'prev') 4475 >>> g.destination('A', 'up') 4476 1 4477 >>> g.destination('B', 'down') 4478 0 4479 >>> g.retargetTransition('A', 'up', 'C') 4480 'down' 4481 >>> g.destination('A', 'up') 4482 2 4483 >>> g.getDestination('B', 'down') is None 4484 True 4485 >>> g.destination('C', 'down') 4486 0 4487 >>> g.addTransition('A', 'next', 'B') 4488 >>> g.addTransition('B', 'prev', 'A') 4489 >>> g.setReciprocal('A', 'next', 'prev') 4490 >>> # Can't swap a reciprocal in a way that would collide names 4491 >>> g.getReciprocal('C', 'prev') 4492 'next' 4493 >>> g.retargetTransition('C', 'prev', 'A') 4494 Traceback (most recent call last): 4495 ... 4496 exploration.core.TransitionCollisionError... 4497 >>> g.retargetTransition('C', 'prev', 'A', swapReciprocal=False) 4498 'next' 4499 >>> g.destination('C', 'prev') 4500 0 4501 >>> g.destination('A', 'next') # not changed 4502 1 4503 >>> # Reciprocal relationship is severed: 4504 >>> g.getReciprocal('C', 'prev') is None 4505 True 4506 >>> g.getReciprocal('B', 'next') is None 4507 True 4508 >>> # Swap back so we can do another demo 4509 >>> g.retargetTransition('C', 'prev', 'B', swapReciprocal=False) 4510 >>> # Note return value was None here because there was no reciprocal 4511 >>> g.setReciprocal('C', 'prev', 'next') 4512 >>> # Swap reciprocal by renaming it 4513 >>> g.retargetTransition('C', 'prev', 'A', errorOnNameColision=False) 4514 'next.1' 4515 >>> g.getReciprocal('C', 'prev') 4516 'next.1' 4517 >>> g.destination('C', 'prev') 4518 0 4519 >>> g.destination('A', 'next.1') 4520 2 4521 >>> g.destination('A', 'next') 4522 1 4523 >>> # Note names are the same but these are from different nodes 4524 >>> g.getReciprocal('A', 'next') 4525 'prev' 4526 >>> g.getReciprocal('A', 'next.1') 4527 'prev' 4528 """ 4529 fromID = self.resolveDecision(fromDecision) 4530 newDestID = self.resolveDecision(newDestination) 4531 4532 # Figure out the old destination of the transition we're swapping 4533 oldDestID = self.destination(fromID, transition) 4534 reciprocal = self.getReciprocal(fromID, transition) 4535 4536 # If thew new destination is the same, we don't do anything! 4537 if oldDestID == newDestID: 4538 return reciprocal 4539 4540 # First figure out reciprocal business so we can error out 4541 # without making changes if we need to 4542 if swapReciprocal and reciprocal is not None: 4543 reciprocal = self.rebaseTransition( 4544 oldDestID, 4545 reciprocal, 4546 newDestID, 4547 swapReciprocal=False, 4548 errorOnNameColision=errorOnNameColision 4549 ) 4550 4551 # Handle the forward transition... 4552 # Find the transition properties 4553 tProps = self.getTransitionProperties(fromID, transition) 4554 4555 # Delete the edge 4556 self.removeEdgeByKey(fromID, transition) 4557 4558 # Add the new edge 4559 self.addTransition(fromID, transition, newDestID) 4560 4561 # Reapply the transition properties 4562 self.setTransitionProperties(fromID, transition, **tProps) 4563 4564 # Handle the reciprocal transition if there is one... 4565 if reciprocal is not None: 4566 if not swapReciprocal: 4567 # Then sever the relationship, but only if that edge 4568 # still exists (we might be in the middle of a rebase) 4569 check = self.getDestination(oldDestID, reciprocal) 4570 if check is not None: 4571 self.setReciprocal( 4572 oldDestID, 4573 reciprocal, 4574 None, 4575 setBoth=False # Other transition was deleted already 4576 ) 4577 else: 4578 # Establish new reciprocal relationship 4579 self.setReciprocal( 4580 fromID, 4581 transition, 4582 reciprocal 4583 ) 4584 4585 return reciprocal 4586 4587 def rebaseTransition( 4588 self, 4589 fromDecision: base.AnyDecisionSpecifier, 4590 transition: base.Transition, 4591 newBase: base.AnyDecisionSpecifier, 4592 swapReciprocal=True, 4593 errorOnNameColision=True 4594 ) -> base.Transition: 4595 """ 4596 Given a particular destination and a transition at that 4597 destination, changes that transition's origin to a new base 4598 decision. If the new source is the same as the old one, no 4599 changes are made. 4600 4601 If `swapReciprocal` is set to True (the default) then any 4602 reciprocal edge at the destination will be retargeted to point 4603 to the new source so that it can remain a reciprocal. If 4604 `swapReciprocal` is set to False, then the reciprocal 4605 relationship with any old reciprocal edge will be removed, but 4606 the old reciprocal edge will not be otherwise changed. 4607 4608 Note that if `errorOnNameColision` is True (the default), then 4609 if the transition has the same name as a transition which 4610 already exists at the new source node, a 4611 `TransitionCollisionError` will be raised. However, if it is set 4612 to False, the transition will be renamed with a suffix to avoid 4613 any possible name collisions. Either way, the (possibly new) name 4614 of the transition that was rebased will be returned. 4615 4616 ## Example 4617 4618 >>> g = DecisionGraph() 4619 >>> for fr, to, nm in [ 4620 ... ('A', 'B', 'up'), 4621 ... ('A', 'B', 'up2'), 4622 ... ('B', 'A', 'down'), 4623 ... ('B', 'B', 'self'), 4624 ... ('B', 'C', 'next'), 4625 ... ('C', 'B', 'prev') 4626 ... ]: 4627 ... if g.getDecision(fr) is None: 4628 ... g.addDecision(fr) 4629 ... if g.getDecision(to) is None: 4630 ... g.addDecision(to) 4631 ... g.addTransition(fr, nm, to) 4632 0 4633 1 4634 2 4635 >>> g.setReciprocal('A', 'up', 'down') 4636 >>> g.setReciprocal('B', 'next', 'prev') 4637 >>> g.destination('A', 'up') 4638 1 4639 >>> g.destination('B', 'down') 4640 0 4641 >>> g.rebaseTransition('B', 'down', 'C') 4642 'down' 4643 >>> g.destination('A', 'up') 4644 2 4645 >>> g.getDestination('B', 'down') is None 4646 True 4647 >>> g.destination('C', 'down') 4648 0 4649 >>> g.addTransition('A', 'next', 'B') 4650 >>> g.addTransition('B', 'prev', 'A') 4651 >>> g.setReciprocal('A', 'next', 'prev') 4652 >>> # Can't rebase in a way that would collide names 4653 >>> g.rebaseTransition('B', 'next', 'A') 4654 Traceback (most recent call last): 4655 ... 4656 exploration.core.TransitionCollisionError... 4657 >>> g.rebaseTransition('B', 'next', 'A', errorOnNameColision=False) 4658 'next.1' 4659 >>> g.destination('C', 'prev') 4660 0 4661 >>> g.destination('A', 'next') # not changed 4662 1 4663 >>> # Collision is avoided by renaming 4664 >>> g.destination('A', 'next.1') 4665 2 4666 >>> # Swap without reciprocal 4667 >>> g.getReciprocal('A', 'next.1') 4668 'prev' 4669 >>> g.getReciprocal('C', 'prev') 4670 'next.1' 4671 >>> g.rebaseTransition('A', 'next.1', 'B', swapReciprocal=False) 4672 'next.1' 4673 >>> g.getReciprocal('C', 'prev') is None 4674 True 4675 >>> g.destination('C', 'prev') 4676 0 4677 >>> g.getDestination('A', 'next.1') is None 4678 True 4679 >>> g.destination('A', 'next') 4680 1 4681 >>> g.destination('B', 'next.1') 4682 2 4683 >>> g.getReciprocal('B', 'next.1') is None 4684 True 4685 >>> # Rebase in a way that creates a self-edge 4686 >>> g.rebaseTransition('A', 'next', 'B') 4687 'next' 4688 >>> g.getDestination('A', 'next') is None 4689 True 4690 >>> g.destination('B', 'next') 4691 1 4692 >>> g.destination('B', 'prev') # swapped as a reciprocal 4693 1 4694 >>> g.getReciprocal('B', 'next') # still reciprocals 4695 'prev' 4696 >>> g.getReciprocal('B', 'prev') 4697 'next' 4698 >>> # And rebasing of a self-edge also works 4699 >>> g.rebaseTransition('B', 'prev', 'A') 4700 'prev' 4701 >>> g.destination('A', 'prev') 4702 1 4703 >>> g.destination('B', 'next') 4704 0 4705 >>> g.getReciprocal('B', 'next') # still reciprocals 4706 'prev' 4707 >>> g.getReciprocal('A', 'prev') 4708 'next' 4709 >>> # We've effectively reversed this edge/reciprocal pair 4710 >>> # by rebasing twice 4711 """ 4712 fromID = self.resolveDecision(fromDecision) 4713 newBaseID = self.resolveDecision(newBase) 4714 4715 # If thew new base is the same, we don't do anything! 4716 if newBaseID == fromID: 4717 return transition 4718 4719 # First figure out reciprocal business so we can swap it later 4720 # without making changes if we need to 4721 destination = self.destination(fromID, transition) 4722 reciprocal = self.getReciprocal(fromID, transition) 4723 # Check for an already-deleted reciprocal 4724 if ( 4725 reciprocal is not None 4726 and self.getDestination(destination, reciprocal) is None 4727 ): 4728 reciprocal = None 4729 4730 # Handle the base swap... 4731 # Find the transition properties 4732 tProps = self.getTransitionProperties(fromID, transition) 4733 4734 # Check for a collision 4735 targetDestinations = self.destinationsFrom(newBaseID) 4736 if transition in targetDestinations: 4737 if errorOnNameColision: 4738 raise TransitionCollisionError( 4739 f"Cannot rebase transition {transition!r} from" 4740 f" {self.identityOf(fromDecision)}: it would be a" 4741 f" duplicate transition name at the new base" 4742 f" decision {self.identityOf(newBase)}." 4743 ) 4744 else: 4745 # Figure out a good fresh name 4746 newName = utils.uniqueName( 4747 transition, 4748 targetDestinations 4749 ) 4750 else: 4751 newName = transition 4752 4753 # Delete the edge 4754 self.removeEdgeByKey(fromID, transition) 4755 4756 # Add the new edge 4757 self.addTransition(newBaseID, newName, destination) 4758 4759 # Reapply the transition properties 4760 self.setTransitionProperties(newBaseID, newName, **tProps) 4761 4762 # Handle the reciprocal transition if there is one... 4763 if reciprocal is not None: 4764 if not swapReciprocal: 4765 # Then sever the relationship 4766 self.setReciprocal( 4767 destination, 4768 reciprocal, 4769 None, 4770 setBoth=False # Other transition was deleted already 4771 ) 4772 else: 4773 # Otherwise swap the reciprocal edge 4774 self.retargetTransition( 4775 destination, 4776 reciprocal, 4777 newBaseID, 4778 swapReciprocal=False 4779 ) 4780 4781 # And establish a new reciprocal relationship 4782 self.setReciprocal( 4783 newBaseID, 4784 newName, 4785 reciprocal 4786 ) 4787 4788 # Return the new name in case it was changed 4789 return newName 4790 4791 # TODO: zone merging! 4792 4793 # TODO: Double-check that exploration vars get updated when this is 4794 # called! 4795 def mergeDecisions( 4796 self, 4797 merge: base.AnyDecisionSpecifier, 4798 mergeInto: base.AnyDecisionSpecifier, 4799 errorOnNameColision=True 4800 ) -> Dict[base.Transition, base.Transition]: 4801 """ 4802 Merges two decisions, deleting the first after transferring all 4803 of its incoming and outgoing edges to target the second one, 4804 whose name is retained. The second decision will be added to any 4805 zones that the first decision was a member of. If either decision 4806 does not exist, a `MissingDecisionError` will be raised. If 4807 `merge` and `mergeInto` are the same, then nothing will be 4808 changed. 4809 4810 Unless `errorOnNameColision` is set to False, a 4811 `TransitionCollisionError` will be raised if the two decisions 4812 have outgoing transitions with the same name. If 4813 `errorOnNameColision` is set to False, then such edges will be 4814 renamed using a suffix to avoid name collisions, with edges 4815 connected to the second decision retaining their original names 4816 and edges that were connected to the first decision getting 4817 renamed. 4818 4819 Any mechanisms located at the first decision will be moved to the 4820 merged decision. 4821 4822 The tags and annotations of the merged decision are added to the 4823 tags and annotations of the merge target. If there are shared 4824 tags, the values from the merge target will override those of 4825 the merged decision. If this is undesired behavior, clear/edit 4826 the tags/annotations of the merged decision before the merge. 4827 4828 The 'unconfirmed' tag is treated specially: if both decisions have 4829 it it will be retained, but otherwise it will be dropped even if 4830 one of the situations had it before. 4831 4832 The domain of the second decision is retained. 4833 4834 Returns a dictionary mapping each original transition name to 4835 its new name in cases where transitions get renamed; this will 4836 be empty when no re-naming occurs, including when 4837 `errorOnNameColision` is True. If there were any transitions 4838 connecting the nodes that were merged, these become self-edges 4839 of the merged node (and may be renamed if necessary). 4840 Note that all renamed transitions were originally based on the 4841 first (merged) node, since transitions of the second (merge 4842 target) node are not renamed. 4843 4844 ## Example 4845 4846 >>> g = DecisionGraph() 4847 >>> for fr, to, nm in [ 4848 ... ('A', 'B', 'up'), 4849 ... ('A', 'B', 'up2'), 4850 ... ('B', 'A', 'down'), 4851 ... ('B', 'B', 'self'), 4852 ... ('B', 'C', 'next'), 4853 ... ('C', 'B', 'prev'), 4854 ... ('A', 'C', 'right') 4855 ... ]: 4856 ... if g.getDecision(fr) is None: 4857 ... g.addDecision(fr) 4858 ... if g.getDecision(to) is None: 4859 ... g.addDecision(to) 4860 ... g.addTransition(fr, nm, to) 4861 0 4862 1 4863 2 4864 >>> g.getDestination('A', 'up') 4865 1 4866 >>> g.getDestination('B', 'down') 4867 0 4868 >>> sorted(g) 4869 [0, 1, 2] 4870 >>> g.setReciprocal('A', 'up', 'down') 4871 >>> g.setReciprocal('B', 'next', 'prev') 4872 >>> g.mergeDecisions('C', 'B') 4873 {} 4874 >>> g.destinationsFrom('A') 4875 {'up': 1, 'up2': 1, 'right': 1} 4876 >>> g.destinationsFrom('B') 4877 {'down': 0, 'self': 1, 'prev': 1, 'next': 1} 4878 >>> 'C' in g 4879 False 4880 >>> g.mergeDecisions('A', 'A') # does nothing 4881 {} 4882 >>> # Can't merge non-existent decision 4883 >>> g.mergeDecisions('A', 'Z') 4884 Traceback (most recent call last): 4885 ... 4886 exploration.core.MissingDecisionError... 4887 >>> g.mergeDecisions('Z', 'A') 4888 Traceback (most recent call last): 4889 ... 4890 exploration.core.MissingDecisionError... 4891 >>> # Can't merge decisions w/ shared edge names 4892 >>> g.addDecision('D') 4893 3 4894 >>> g.addTransition('D', 'next', 'A') 4895 >>> g.addTransition('A', 'prev', 'D') 4896 >>> g.setReciprocal('D', 'next', 'prev') 4897 >>> g.mergeDecisions('D', 'B') # both have a 'next' transition 4898 Traceback (most recent call last): 4899 ... 4900 exploration.core.TransitionCollisionError... 4901 >>> # Auto-rename colliding edges 4902 >>> g.mergeDecisions('D', 'B', errorOnNameColision=False) 4903 {'next': 'next.1'} 4904 >>> g.destination('B', 'next') # merge target unchanged 4905 1 4906 >>> g.destination('B', 'next.1') # merged decision name changed 4907 0 4908 >>> g.destination('B', 'prev') # name unchanged (no collision) 4909 1 4910 >>> g.getReciprocal('B', 'next') # unchanged (from B) 4911 'prev' 4912 >>> g.getReciprocal('B', 'next.1') # from A 4913 'prev' 4914 >>> g.getReciprocal('A', 'prev') # from B 4915 'next.1' 4916 4917 ## Folding four nodes into a 2-node loop 4918 4919 >>> g = DecisionGraph() 4920 >>> g.addDecision('X') 4921 0 4922 >>> g.addDecision('Y') 4923 1 4924 >>> g.addTransition('X', 'next', 'Y', 'prev') 4925 >>> g.addDecision('preX') 4926 2 4927 >>> g.addDecision('postY') 4928 3 4929 >>> g.addTransition('preX', 'next', 'X', 'prev') 4930 >>> g.addTransition('Y', 'next', 'postY', 'prev') 4931 >>> g.mergeDecisions('preX', 'Y', errorOnNameColision=False) 4932 {'next': 'next.1'} 4933 >>> g.destinationsFrom('X') 4934 {'next': 1, 'prev': 1} 4935 >>> g.destinationsFrom('Y') 4936 {'prev': 0, 'next': 3, 'next.1': 0} 4937 >>> 2 in g 4938 False 4939 >>> g.destinationsFrom('postY') 4940 {'prev': 1} 4941 >>> g.mergeDecisions('postY', 'X', errorOnNameColision=False) 4942 {'prev': 'prev.1'} 4943 >>> g.destinationsFrom('X') 4944 {'next': 1, 'prev': 1, 'prev.1': 1} 4945 >>> g.destinationsFrom('Y') # order 'cause of 'next' re-target 4946 {'prev': 0, 'next.1': 0, 'next': 0} 4947 >>> 2 in g 4948 False 4949 >>> 3 in g 4950 False 4951 >>> # Reciprocals are tangled... 4952 >>> g.getReciprocal(0, 'prev') 4953 'next.1' 4954 >>> g.getReciprocal(0, 'prev.1') 4955 'next' 4956 >>> g.getReciprocal(1, 'next') 4957 'prev.1' 4958 >>> g.getReciprocal(1, 'next.1') 4959 'prev' 4960 >>> # Note: one merge cannot handle both extra transitions 4961 >>> # because their reciprocals are crossed (e.g., prev.1 <-> next) 4962 >>> # (It would merge both edges but the result would retain 4963 >>> # 'next.1' instead of retaining 'next'.) 4964 >>> g.mergeTransitions('X', 'prev.1', 'prev', mergeReciprocal=False) 4965 >>> g.mergeTransitions('Y', 'next.1', 'next', mergeReciprocal=True) 4966 >>> g.destinationsFrom('X') 4967 {'next': 1, 'prev': 1} 4968 >>> g.destinationsFrom('Y') 4969 {'prev': 0, 'next': 0} 4970 >>> # Reciprocals were salvaged in second merger 4971 >>> g.getReciprocal('X', 'prev') 4972 'next' 4973 >>> g.getReciprocal('Y', 'next') 4974 'prev' 4975 4976 ## Merging with tags/requirements/annotations/consequences 4977 4978 >>> g = DecisionGraph() 4979 >>> g.addDecision('X') 4980 0 4981 >>> g.addDecision('Y') 4982 1 4983 >>> g.addDecision('Z') 4984 2 4985 >>> g.addTransition('X', 'next', 'Y', 'prev') 4986 >>> g.addTransition('X', 'down', 'Z', 'up') 4987 >>> g.tagDecision('X', 'tag0', 1) 4988 >>> g.tagDecision('Y', 'tag1', 10) 4989 >>> g.tagDecision('Y', 'unconfirmed') 4990 >>> g.tagDecision('Z', 'tag1', 20) 4991 >>> g.tagDecision('Z', 'tag2', 30) 4992 >>> g.tagTransition('X', 'next', 'ttag1', 11) 4993 >>> g.tagTransition('Y', 'prev', 'ttag2', 22) 4994 >>> g.tagTransition('X', 'down', 'ttag3', 33) 4995 >>> g.tagTransition('Z', 'up', 'ttag4', 44) 4996 >>> g.annotateDecision('Y', 'annotation 1') 4997 >>> g.annotateDecision('Z', 'annotation 2') 4998 >>> g.annotateDecision('Z', 'annotation 3') 4999 >>> g.annotateTransition('Y', 'prev', 'trans annotation 1') 5000 >>> g.annotateTransition('Y', 'prev', 'trans annotation 2') 5001 >>> g.annotateTransition('Z', 'up', 'trans annotation 3') 5002 >>> g.setTransitionRequirement( 5003 ... 'X', 5004 ... 'next', 5005 ... base.ReqCapability('power') 5006 ... ) 5007 >>> g.setTransitionRequirement( 5008 ... 'Y', 5009 ... 'prev', 5010 ... base.ReqTokens('token', 1) 5011 ... ) 5012 >>> g.setTransitionRequirement( 5013 ... 'X', 5014 ... 'down', 5015 ... base.ReqCapability('power2') 5016 ... ) 5017 >>> g.setTransitionRequirement( 5018 ... 'Z', 5019 ... 'up', 5020 ... base.ReqTokens('token2', 2) 5021 ... ) 5022 >>> g.setConsequence( 5023 ... 'Y', 5024 ... 'prev', 5025 ... [base.effect(gain="power2")] 5026 ... ) 5027 >>> g.mergeDecisions('Y', 'Z') 5028 {} 5029 >>> g.destination('X', 'next') 5030 2 5031 >>> g.destination('X', 'down') 5032 2 5033 >>> g.destination('Z', 'prev') 5034 0 5035 >>> g.destination('Z', 'up') 5036 0 5037 >>> g.decisionTags('X') 5038 {'tag0': 1} 5039 >>> g.decisionTags('Z') # note that 'unconfirmed' is removed 5040 {'tag1': 20, 'tag2': 30} 5041 >>> g.transitionTags('X', 'next') 5042 {'ttag1': 11} 5043 >>> g.transitionTags('X', 'down') 5044 {'ttag3': 33} 5045 >>> g.transitionTags('Z', 'prev') 5046 {'ttag2': 22} 5047 >>> g.transitionTags('Z', 'up') 5048 {'ttag4': 44} 5049 >>> g.decisionAnnotations('Z') 5050 ['annotation 2', 'annotation 3', 'annotation 1'] 5051 >>> g.transitionAnnotations('Z', 'prev') 5052 ['trans annotation 1', 'trans annotation 2'] 5053 >>> g.transitionAnnotations('Z', 'up') 5054 ['trans annotation 3'] 5055 >>> g.getTransitionRequirement('X', 'next') 5056 ReqCapability('power') 5057 >>> g.getTransitionRequirement('Z', 'prev') 5058 ReqTokens('token', 1) 5059 >>> g.getTransitionRequirement('X', 'down') 5060 ReqCapability('power2') 5061 >>> g.getTransitionRequirement('Z', 'up') 5062 ReqTokens('token2', 2) 5063 >>> g.getConsequence('Z', 'prev') == [ 5064 ... { 5065 ... 'type': 'gain', 5066 ... 'applyTo': 'active', 5067 ... 'value': 'power2', 5068 ... 'charges': None, 5069 ... 'delay': None, 5070 ... 'hidden': False 5071 ... } 5072 ... ] 5073 True 5074 5075 ## Merging into node without tags 5076 5077 >>> g = DecisionGraph() 5078 >>> g.addDecision('X') 5079 0 5080 >>> g.addDecision('Y') 5081 1 5082 >>> g.tagDecision('Y', 'unconfirmed') # special handling 5083 >>> g.tagDecision('Y', 'tag', 'value') 5084 >>> g.mergeDecisions('Y', 'X') 5085 {} 5086 >>> g.decisionTags('X') 5087 {'tag': 'value'} 5088 >>> 0 in g # Second argument remains 5089 True 5090 >>> 1 in g # First argument is deleted 5091 False 5092 """ 5093 # Resolve IDs 5094 mergeID = self.resolveDecision(merge) 5095 mergeIntoID = self.resolveDecision(mergeInto) 5096 5097 # Create our result as an empty dictionary 5098 result: Dict[base.Transition, base.Transition] = {} 5099 5100 # Short-circuit if the two decisions are the same 5101 if mergeID == mergeIntoID: 5102 return result 5103 5104 # MissingDecisionErrors from here if either doesn't exist 5105 allNewOutgoing = set(self.destinationsFrom(mergeID)) 5106 allOldOutgoing = set(self.destinationsFrom(mergeIntoID)) 5107 # Find colliding transition names 5108 collisions = allNewOutgoing & allOldOutgoing 5109 if len(collisions) > 0 and errorOnNameColision: 5110 raise TransitionCollisionError( 5111 f"Cannot merge decision {self.identityOf(merge)} into" 5112 f" decision {self.identityOf(mergeInto)}: the decisions" 5113 f" share {len(collisions)} transition names:" 5114 f" {collisions}\n(Note that errorOnNameColision was set" 5115 f" to True, set it to False to allow the operation by" 5116 f" renaming half of those transitions.)" 5117 ) 5118 5119 # Record zones that will have to change after the merge 5120 zoneParents = self.zoneParents(mergeID) 5121 5122 # First, swap all incoming edges, along with their reciprocals 5123 # This will include self-edges, which will be retargeted and 5124 # whose reciprocals will be rebased in the process, leading to 5125 # the possibility of a missing edge during the loop 5126 for source, incoming in self.allEdgesTo(mergeID): 5127 # Skip this edge if it was already swapped away because it's 5128 # a self-loop with a reciprocal whose reciprocal was 5129 # processed earlier in the loop 5130 if incoming not in self.destinationsFrom(source): 5131 continue 5132 5133 # Find corresponding outgoing edge 5134 outgoing = self.getReciprocal(source, incoming) 5135 5136 # Swap both edges to new destination 5137 newOutgoing = self.retargetTransition( 5138 source, 5139 incoming, 5140 mergeIntoID, 5141 swapReciprocal=True, 5142 errorOnNameColision=False # collisions were detected above 5143 ) 5144 # Add to our result if the name of the reciprocal was 5145 # changed 5146 if ( 5147 outgoing is not None 5148 and newOutgoing is not None 5149 and outgoing != newOutgoing 5150 ): 5151 result[outgoing] = newOutgoing 5152 5153 # Next, swap any remaining outgoing edges (which didn't have 5154 # reciprocals, or they'd already be swapped, unless they were 5155 # self-edges previously). Note that in this loop, there can't be 5156 # any self-edges remaining, although there might be connections 5157 # between the merging nodes that need to become self-edges 5158 # because they used to be a self-edge that was half-retargeted 5159 # by the previous loop. 5160 # Note: a copy is used here to avoid iterating over a changing 5161 # dictionary 5162 for stillOutgoing in copy.copy(self.destinationsFrom(mergeID)): 5163 newOutgoing = self.rebaseTransition( 5164 mergeID, 5165 stillOutgoing, 5166 mergeIntoID, 5167 swapReciprocal=True, 5168 errorOnNameColision=False # collisions were detected above 5169 ) 5170 if stillOutgoing != newOutgoing: 5171 result[stillOutgoing] = newOutgoing 5172 5173 # At this point, there shouldn't be any remaining incoming or 5174 # outgoing edges! 5175 assert self.degree(mergeID) == 0 5176 5177 # Merge tags & annotations 5178 # Note that these operations affect the underlying graph 5179 destTags = self.decisionTags(mergeIntoID) 5180 destUnvisited = 'unconfirmed' in destTags 5181 sourceTags = self.decisionTags(mergeID) 5182 sourceUnvisited = 'unconfirmed' in sourceTags 5183 # Copy over only new tags, leaving existing tags alone 5184 for key in sourceTags: 5185 if key not in destTags: 5186 destTags[key] = sourceTags[key] 5187 5188 if int(destUnvisited) + int(sourceUnvisited) == 1: 5189 del destTags['unconfirmed'] 5190 5191 self.decisionAnnotations(mergeIntoID).extend( 5192 self.decisionAnnotations(mergeID) 5193 ) 5194 5195 # Transfer zones 5196 for zone in zoneParents: 5197 self.addDecisionToZone(mergeIntoID, zone) 5198 5199 # Delete the old node 5200 self.removeDecision(mergeID) 5201 5202 return result 5203 5204 def removeDecision(self, decision: base.AnyDecisionSpecifier) -> None: 5205 """ 5206 Deletes the specified decision from the graph, updating 5207 attendant structures like zones. Note that the ID of the deleted 5208 node will NOT be reused, unless it's specifically provided to 5209 `addIdentifiedDecision`. 5210 5211 For example: 5212 5213 >>> dg = DecisionGraph() 5214 >>> dg.addDecision('A') 5215 0 5216 >>> dg.addDecision('B') 5217 1 5218 >>> list(dg) 5219 [0, 1] 5220 >>> 1 in dg 5221 True 5222 >>> 'B' in dg.nameLookup 5223 True 5224 >>> dg.removeDecision('B') 5225 >>> 1 in dg 5226 False 5227 >>> list(dg) 5228 [0] 5229 >>> 'B' in dg.nameLookup 5230 False 5231 >>> dg.addDecision('C') # doesn't re-use ID 5232 2 5233 """ 5234 dID = self.resolveDecision(decision) 5235 5236 # Remove the target from all zones: 5237 for zone in self.zones: 5238 self.removeDecisionFromZone(dID, zone) 5239 5240 # Remove the node but record the current name 5241 name = self.nodes[dID]['name'] 5242 self.remove_node(dID) 5243 5244 # Clean up the nameLookup entry 5245 luInfo = self.nameLookup[name] 5246 luInfo.remove(dID) 5247 if len(luInfo) == 0: 5248 self.nameLookup.pop(name) 5249 5250 # TODO: Clean up edges? 5251 5252 def renameDecision( 5253 self, 5254 decision: base.AnyDecisionSpecifier, 5255 newName: base.DecisionName 5256 ): 5257 """ 5258 Renames a decision. The decision retains its old ID. 5259 5260 Generates a `DecisionCollisionWarning` if a decision using the new 5261 name already exists and `WARN_OF_NAME_COLLISIONS` is enabled. 5262 5263 Example: 5264 5265 >>> g = DecisionGraph() 5266 >>> g.addDecision('one') 5267 0 5268 >>> g.addDecision('three') 5269 1 5270 >>> g.addTransition('one', '>', 'three') 5271 >>> g.addTransition('three', '<', 'one') 5272 >>> g.tagDecision('three', 'hi') 5273 >>> g.annotateDecision('three', 'note') 5274 >>> g.destination('one', '>') 5275 1 5276 >>> g.destination('three', '<') 5277 0 5278 >>> g.renameDecision('three', 'two') 5279 >>> g.resolveDecision('one') 5280 0 5281 >>> g.resolveDecision('two') 5282 1 5283 >>> g.resolveDecision('three') 5284 Traceback (most recent call last): 5285 ... 5286 exploration.core.MissingDecisionError... 5287 >>> g.destination('one', '>') 5288 1 5289 >>> g.nameFor(1) 5290 'two' 5291 >>> g.getDecision('three') is None 5292 True 5293 >>> g.destination('two', '<') 5294 0 5295 >>> g.decisionTags('two') 5296 {'hi': 1} 5297 >>> g.decisionAnnotations('two') 5298 ['note'] 5299 """ 5300 dID = self.resolveDecision(decision) 5301 5302 if newName in self.nameLookup and WARN_OF_NAME_COLLISIONS: 5303 warnings.warn( 5304 ( 5305 f"Can't rename {self.identityOf(decision)} as" 5306 f" {newName!r} because a decision with that name" 5307 f" already exists." 5308 ), 5309 DecisionCollisionWarning 5310 ) 5311 5312 # Update name in node 5313 oldName = self.nodes[dID]['name'] 5314 self.nodes[dID]['name'] = newName 5315 5316 # Update nameLookup entries 5317 oldNL = self.nameLookup[oldName] 5318 oldNL.remove(dID) 5319 if len(oldNL) == 0: 5320 self.nameLookup.pop(oldName) 5321 self.nameLookup.setdefault(newName, []).append(dID) 5322 5323 def mergeTransitions( 5324 self, 5325 fromDecision: base.AnyDecisionSpecifier, 5326 merge: base.Transition, 5327 mergeInto: base.Transition, 5328 mergeReciprocal=True 5329 ) -> None: 5330 """ 5331 Given a decision and two transitions that start at that decision, 5332 merges the first transition into the second transition, combining 5333 their transition properties (using `mergeProperties`) and 5334 deleting the first transition. By default any reciprocal of the 5335 first transition is also merged into the reciprocal of the 5336 second, although you can set `mergeReciprocal` to `False` to 5337 disable this in which case the old reciprocal will lose its 5338 reciprocal relationship, even if the transition that was merged 5339 into does not have a reciprocal. 5340 5341 If the two names provided are the same, nothing will happen. 5342 5343 If the two transitions do not share the same destination, they 5344 cannot be merged, and an `InvalidDestinationError` will result. 5345 Use `retargetTransition` beforehand to ensure that they do if you 5346 want to merge transitions with different destinations. 5347 5348 A `MissingDecisionError` or `MissingTransitionError` will result 5349 if the decision or either transition does not exist. 5350 5351 If merging reciprocal properties was requested and the first 5352 transition does not have a reciprocal, then no reciprocal 5353 properties change. However, if the second transition does not 5354 have a reciprocal and the first does, the first transition's 5355 reciprocal will be set to the reciprocal of the second 5356 transition, and that transition will not be deleted as usual. 5357 5358 ## Example 5359 5360 >>> g = DecisionGraph() 5361 >>> g.addDecision('A') 5362 0 5363 >>> g.addDecision('B') 5364 1 5365 >>> g.addTransition('A', 'up', 'B') 5366 >>> g.addTransition('B', 'down', 'A') 5367 >>> g.setReciprocal('A', 'up', 'down') 5368 >>> # Merging a transition with no reciprocal 5369 >>> g.addTransition('A', 'up2', 'B') 5370 >>> g.mergeTransitions('A', 'up2', 'up') 5371 >>> g.getDestination('A', 'up2') is None 5372 True 5373 >>> g.getDestination('A', 'up') 5374 1 5375 >>> # Merging a transition with a reciprocal & tags 5376 >>> g.addTransition('A', 'up2', 'B') 5377 >>> g.addTransition('B', 'down2', 'A') 5378 >>> g.setReciprocal('A', 'up2', 'down2') 5379 >>> g.tagTransition('A', 'up2', 'one') 5380 >>> g.tagTransition('B', 'down2', 'two') 5381 >>> g.mergeTransitions('B', 'down2', 'down') 5382 >>> g.getDestination('A', 'up2') is None 5383 True 5384 >>> g.getDestination('A', 'up') 5385 1 5386 >>> g.getDestination('B', 'down2') is None 5387 True 5388 >>> g.getDestination('B', 'down') 5389 0 5390 >>> # Merging requirements uses ReqAll (i.e., 'and' logic) 5391 >>> g.addTransition('A', 'up2', 'B') 5392 >>> g.setTransitionProperties( 5393 ... 'A', 5394 ... 'up2', 5395 ... requirement=base.ReqCapability('dash') 5396 ... ) 5397 >>> g.setTransitionProperties('A', 'up', 5398 ... requirement=base.ReqCapability('slide')) 5399 >>> g.mergeTransitions('A', 'up2', 'up') 5400 >>> g.getDestination('A', 'up2') is None 5401 True 5402 >>> repr(g.getTransitionRequirement('A', 'up')) 5403 "ReqAll([ReqCapability('dash'), ReqCapability('slide')])" 5404 >>> # Errors if destinations differ, or if something is missing 5405 >>> g.mergeTransitions('A', 'down', 'up') 5406 Traceback (most recent call last): 5407 ... 5408 exploration.core.MissingTransitionError... 5409 >>> g.mergeTransitions('Z', 'one', 'two') 5410 Traceback (most recent call last): 5411 ... 5412 exploration.core.MissingDecisionError... 5413 >>> g.addDecision('C') 5414 2 5415 >>> g.addTransition('A', 'down', 'C') 5416 >>> g.mergeTransitions('A', 'down', 'up') 5417 Traceback (most recent call last): 5418 ... 5419 exploration.core.InvalidDestinationError... 5420 >>> # Merging a reciprocal onto an edge that doesn't have one 5421 >>> g.addTransition('A', 'down2', 'C') 5422 >>> g.addTransition('C', 'up2', 'A') 5423 >>> g.setReciprocal('A', 'down2', 'up2') 5424 >>> g.tagTransition('C', 'up2', 'narrow') 5425 >>> g.getReciprocal('A', 'down') is None 5426 True 5427 >>> g.mergeTransitions('A', 'down2', 'down') 5428 >>> g.getDestination('A', 'down2') is None 5429 True 5430 >>> g.getDestination('A', 'down') 5431 2 5432 >>> g.getDestination('C', 'up2') 5433 0 5434 >>> g.getReciprocal('A', 'down') 5435 'up2' 5436 >>> g.getReciprocal('C', 'up2') 5437 'down' 5438 >>> g.transitionTags('C', 'up2') 5439 {'narrow': 1} 5440 >>> # Merging without a reciprocal 5441 >>> g.addTransition('C', 'up', 'A') 5442 >>> g.mergeTransitions('C', 'up2', 'up', mergeReciprocal=False) 5443 >>> g.getDestination('C', 'up2') is None 5444 True 5445 >>> g.getDestination('C', 'up') 5446 0 5447 >>> g.transitionTags('C', 'up') # tag gets merged 5448 {'narrow': 1} 5449 >>> g.getDestination('A', 'down') 5450 2 5451 >>> g.getReciprocal('A', 'down') is None 5452 True 5453 >>> g.getReciprocal('C', 'up') is None 5454 True 5455 >>> # Merging w/ normal reciprocals 5456 >>> g.addDecision('D') 5457 3 5458 >>> g.addDecision('E') 5459 4 5460 >>> g.addTransition('D', 'up', 'E', 'return') 5461 >>> g.addTransition('E', 'down', 'D') 5462 >>> g.mergeTransitions('E', 'return', 'down') 5463 >>> g.getDestination('D', 'up') 5464 4 5465 >>> g.getDestination('E', 'down') 5466 3 5467 >>> g.getDestination('E', 'return') is None 5468 True 5469 >>> g.getReciprocal('D', 'up') 5470 'down' 5471 >>> g.getReciprocal('E', 'down') 5472 'up' 5473 >>> # Merging w/ weird reciprocals 5474 >>> g.addTransition('E', 'return', 'D') 5475 >>> g.setReciprocal('E', 'return', 'up', setBoth=False) 5476 >>> g.getReciprocal('D', 'up') 5477 'down' 5478 >>> g.getReciprocal('E', 'down') 5479 'up' 5480 >>> g.getReciprocal('E', 'return') # shared 5481 'up' 5482 >>> g.mergeTransitions('E', 'return', 'down') 5483 >>> g.getDestination('D', 'up') 5484 4 5485 >>> g.getDestination('E', 'down') 5486 3 5487 >>> g.getDestination('E', 'return') is None 5488 True 5489 >>> g.getReciprocal('D', 'up') 5490 'down' 5491 >>> g.getReciprocal('E', 'down') 5492 'up' 5493 """ 5494 fromID = self.resolveDecision(fromDecision) 5495 5496 # Short-circuit in the no-op case 5497 if merge == mergeInto: 5498 return 5499 5500 # These lines will raise a MissingDecisionError or 5501 # MissingTransitionError if needed 5502 dest1 = self.destination(fromID, merge) 5503 dest2 = self.destination(fromID, mergeInto) 5504 5505 if dest1 != dest2: 5506 raise InvalidDestinationError( 5507 f"Cannot merge transition {merge!r} into transition" 5508 f" {mergeInto!r} from decision" 5509 f" {self.identityOf(fromDecision)} because their" 5510 f" destinations are different ({self.identityOf(dest1)}" 5511 f" and {self.identityOf(dest2)}).\nNote: you can use" 5512 f" `retargetTransition` to change the destination of a" 5513 f" transition." 5514 ) 5515 5516 # Find and the transition properties 5517 props1 = self.getTransitionProperties(fromID, merge) 5518 props2 = self.getTransitionProperties(fromID, mergeInto) 5519 merged = mergeProperties(props1, props2) 5520 # Note that this doesn't change the reciprocal: 5521 self.setTransitionProperties(fromID, mergeInto, **merged) 5522 5523 # Merge the reciprocal properties if requested 5524 # Get reciprocal to merge into 5525 reciprocal = self.getReciprocal(fromID, mergeInto) 5526 # Get reciprocal that needs cleaning up 5527 altReciprocal = self.getReciprocal(fromID, merge) 5528 # If the reciprocal to be merged actually already was the 5529 # reciprocal to merge into, there's nothing to do here 5530 if altReciprocal != reciprocal: 5531 if not mergeReciprocal: 5532 # In this case, we sever the reciprocal relationship if 5533 # there is a reciprocal 5534 if altReciprocal is not None: 5535 self.setReciprocal(dest1, altReciprocal, None) 5536 # By default setBoth takes care of the other half 5537 else: 5538 # In this case, we try to merge reciprocals 5539 # If altReciprocal is None, we don't need to do anything 5540 if altReciprocal is not None: 5541 # Was there already a reciprocal or not? 5542 if reciprocal is None: 5543 # altReciprocal becomes the new reciprocal and is 5544 # not deleted 5545 self.setReciprocal( 5546 fromID, 5547 mergeInto, 5548 altReciprocal 5549 ) 5550 else: 5551 # merge reciprocal properties 5552 props1 = self.getTransitionProperties( 5553 dest1, 5554 altReciprocal 5555 ) 5556 props2 = self.getTransitionProperties( 5557 dest2, 5558 reciprocal 5559 ) 5560 merged = mergeProperties(props1, props2) 5561 self.setTransitionProperties( 5562 dest1, 5563 reciprocal, 5564 **merged 5565 ) 5566 5567 # delete the old reciprocal transition 5568 self.remove_edge(dest1, fromID, altReciprocal) 5569 5570 # Delete the old transition (reciprocal deletion/severance is 5571 # handled above if necessary) 5572 self.remove_edge(fromID, dest1, merge) 5573 5574 def isConfirmed(self, decision: base.AnyDecisionSpecifier) -> bool: 5575 """ 5576 Returns `True` or `False` depending on whether or not the 5577 specified decision has been confirmed. Uses the presence or 5578 absence of the 'unconfirmed' tag to determine this. 5579 5580 Note: 'unconfirmed' is used instead of 'confirmed' so that large 5581 graphs with many confirmed nodes will be smaller when saved. 5582 """ 5583 dID = self.resolveDecision(decision) 5584 5585 return 'unconfirmed' not in self.nodes[dID]['tags'] 5586 5587 def replaceUnconfirmed( 5588 self, 5589 fromDecision: base.AnyDecisionSpecifier, 5590 transition: base.Transition, 5591 connectTo: Optional[base.AnyDecisionSpecifier] = None, 5592 reciprocal: Optional[base.Transition] = None, 5593 requirement: Optional[base.Requirement] = None, 5594 applyConsequence: Optional[base.Consequence] = None, 5595 placeInZone: Union[type[base.DefaultZone], base.Zone, None] = None, 5596 forceNew: bool = False, 5597 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 5598 annotations: Optional[List[base.Annotation]] = None, 5599 revRequires: Optional[base.Requirement] = None, 5600 revConsequence: Optional[base.Consequence] = None, 5601 revTags: Optional[Dict[base.Tag, base.TagValue]] = None, 5602 revAnnotations: Optional[List[base.Annotation]] = None, 5603 decisionTags: Optional[Dict[base.Tag, base.TagValue]] = None, 5604 decisionAnnotations: Optional[List[base.Annotation]] = None 5605 ) -> Tuple[ 5606 Dict[base.Transition, base.Transition], 5607 Dict[base.Transition, base.Transition] 5608 ]: 5609 """ 5610 Given a decision and an edge name in that decision, where the 5611 named edge leads to a decision with an unconfirmed exploration 5612 state (see `isConfirmed`), renames the unexplored decision on 5613 the other end of that edge using the given `connectTo` name, or 5614 if a decision using that name already exists, merges the 5615 unexplored decision into that decision. If `connectTo` is a 5616 `DecisionSpecifier` whose target doesn't exist, it will be 5617 treated as just a name, but if it's an ID and it doesn't exist, 5618 you'll get a `MissingDecisionError`. If a `reciprocal` is provided, 5619 a reciprocal edge will be added using that name connecting the 5620 `connectTo` decision back to the original decision. If this 5621 transition already exists, it must also point to a node which is 5622 also unexplored, and which will also be merged into the 5623 `fromDecision` node. 5624 5625 If `connectTo` is not given (or is set to `None` explicitly) 5626 then the name of the unexplored decision will not be changed, 5627 unless that name has the form `'_u.-n-'` where `-n-` is a positive 5628 integer (i.e., the form given to automatically-named unknown 5629 nodes). In that case, the name will be changed to `'_x.-n-'` using 5630 the same number, or a higher number if that name is already taken. 5631 5632 If the destination is being renamed or if the destination's 5633 exploration state counts as unexplored, the exploration state of 5634 the destination will be set to 'exploring'. 5635 5636 If a `placeInZone` is specified, the destination will be placed 5637 directly into that zone (even if it already existed and has zone 5638 information), and it will be removed from any other zones it had 5639 been a direct member of. If `placeInZone` is set to 5640 `DefaultZone`, then the destination will be placed into each zone 5641 which is a direct parent of the origin, but only if the 5642 destination is not an already-explored existing decision AND 5643 it is not already in any zones (in those cases no zone changes 5644 are made). This will also remove it from any previous zones it 5645 had been a part of. If `placeInZone` is left as `None` (the 5646 default) no zone changes are made. 5647 5648 If `placeInZone` is specified and that zone didn't already exist, 5649 it will be created as a new level-0 zone and will be added as a 5650 sub-zone of each zone that's a direct parent of any level-0 zone 5651 that the origin is a member of. 5652 5653 If `forceNew` is specified, then the destination will just be 5654 renamed, even if another decision with the same name already 5655 exists. It's an error to use `forceNew` with a decision ID as 5656 the destination. 5657 5658 Any additional edges pointing to or from the unknown node(s) 5659 being replaced will also be re-targeted at the now-discovered 5660 known destination(s) if necessary. These edges will retain their 5661 reciprocal names, or if this would cause a name clash, they will 5662 be renamed with a suffix (see `retargetTransition`). 5663 5664 The return value is a pair of dictionaries mapping old names to 5665 new ones that just includes the names which were changed. The 5666 first dictionary contains renamed transitions that are outgoing 5667 from the new destination node (which used to be outgoing from 5668 the unexplored node). The second dictionary contains renamed 5669 transitions that are outgoing from the source node (which used 5670 to be outgoing from the unexplored node attached to the 5671 reciprocal transition; if there was no reciprocal transition 5672 specified then this will always be an empty dictionary). 5673 5674 An `ExplorationStatusError` will be raised if the destination 5675 of the specified transition counts as visited (see 5676 `hasBeenVisited`). An `ExplorationStatusError` will also be 5677 raised if the `connectTo`'s `reciprocal` transition does not lead 5678 to an unconfirmed decision (it's okay if this second transition 5679 doesn't exist). A `TransitionCollisionError` will be raised if 5680 the unconfirmed destination decision already has an outgoing 5681 transition with the specified `reciprocal` which does not lead 5682 back to the `fromDecision`. 5683 5684 The transition properties (requirement, consequences, tags, 5685 and/or annotations) of the replaced transition will be copied 5686 over to the new transition. Transition properties from the 5687 reciprocal transition will also be copied for the newly created 5688 reciprocal edge. Properties for any additional edges to/from the 5689 unknown node will also be copied. 5690 5691 Also, any transition properties on existing forward or reciprocal 5692 edges from the destination node with the indicated reverse name 5693 will be merged with those from the target transition. Note that 5694 this merging process may introduce corruption of complex 5695 transition consequences. TODO: Fix that! 5696 5697 Any tags and annotations are added to copied tags/annotations, 5698 but specified requirements, and/or consequences will replace 5699 previous requirements/consequences, rather than being added to 5700 them. 5701 5702 ## Example 5703 5704 >>> g = DecisionGraph() 5705 >>> g.addDecision('A') 5706 0 5707 >>> g.addUnexploredEdge('A', 'up') 5708 1 5709 >>> g.destination('A', 'up') 5710 1 5711 >>> g.destination('_u.0', 'return') 5712 0 5713 >>> g.replaceUnconfirmed('A', 'up', 'B', 'down') 5714 ({}, {}) 5715 >>> g.destination('A', 'up') 5716 1 5717 >>> g.nameFor(1) 5718 'B' 5719 >>> g.destination('B', 'down') 5720 0 5721 >>> g.getDestination('B', 'return') is None 5722 True 5723 >>> '_u.0' in g.nameLookup 5724 False 5725 >>> g.getReciprocal('A', 'up') 5726 'down' 5727 >>> g.getReciprocal('B', 'down') 5728 'up' 5729 >>> # Two unexplored edges to the same node: 5730 >>> g.addDecision('C') 5731 2 5732 >>> g.addTransition('B', 'next', 'C') 5733 >>> g.addTransition('C', 'prev', 'B') 5734 >>> g.setReciprocal('B', 'next', 'prev') 5735 >>> g.addUnexploredEdge('A', 'next', 'D', 'prev') 5736 3 5737 >>> g.addTransition('C', 'down', 'D') 5738 >>> g.addTransition('D', 'up', 'C') 5739 >>> g.setReciprocal('C', 'down', 'up') 5740 >>> g.replaceUnconfirmed('C', 'down') 5741 ({}, {}) 5742 >>> g.destination('C', 'down') 5743 3 5744 >>> g.destination('A', 'next') 5745 3 5746 >>> g.destinationsFrom('D') 5747 {'prev': 0, 'up': 2} 5748 >>> g.decisionTags('D') 5749 {} 5750 >>> # An unexplored transition which turns out to connect to a 5751 >>> # known decision, with name collisions 5752 >>> g.addUnexploredEdge('D', 'next', reciprocal='prev') 5753 4 5754 >>> g.tagDecision('_u.2', 'wet') 5755 >>> g.addUnexploredEdge('B', 'next', reciprocal='prev') # edge taken 5756 Traceback (most recent call last): 5757 ... 5758 exploration.core.TransitionCollisionError... 5759 >>> g.addUnexploredEdge('A', 'prev', reciprocal='next') 5760 5 5761 >>> g.tagDecision('_u.3', 'dry') 5762 >>> # Add transitions that will collide when merged 5763 >>> g.addUnexploredEdge('_u.2', 'up') # collides with A/up 5764 6 5765 >>> g.addUnexploredEdge('_u.3', 'prev') # collides with D/prev 5766 7 5767 >>> g.getReciprocal('A', 'prev') 5768 'next' 5769 >>> g.replaceUnconfirmed('A', 'prev', 'D', 'next') # two gone 5770 ({'prev': 'prev.1'}, {'up': 'up.1'}) 5771 >>> g.destination('A', 'prev') 5772 3 5773 >>> g.destination('D', 'next') 5774 0 5775 >>> g.getReciprocal('A', 'prev') 5776 'next' 5777 >>> g.getReciprocal('D', 'next') 5778 'prev' 5779 >>> # Note that further unexplored structures are NOT merged 5780 >>> # even if they match against existing structures... 5781 >>> g.destination('A', 'up.1') 5782 6 5783 >>> g.destination('D', 'prev.1') 5784 7 5785 >>> '_u.2' in g.nameLookup 5786 False 5787 >>> '_u.3' in g.nameLookup 5788 False 5789 >>> g.decisionTags('D') # tags are merged 5790 {'dry': 1} 5791 >>> g.decisionTags('A') 5792 {'wet': 1} 5793 >>> # Auto-renaming an anonymous unexplored node 5794 >>> g.addUnexploredEdge('B', 'out') 5795 8 5796 >>> g.replaceUnconfirmed('B', 'out') 5797 ({}, {}) 5798 >>> '_u.6' in g 5799 False 5800 >>> g.destination('B', 'out') 5801 8 5802 >>> g.nameFor(8) 5803 '_x.6' 5804 >>> g.destination('_x.6', 'return') 5805 1 5806 >>> # Placing a node into a zone 5807 >>> g.addUnexploredEdge('B', 'through') 5808 9 5809 >>> g.getDecision('E') is None 5810 True 5811 >>> g.replaceUnconfirmed( 5812 ... 'B', 5813 ... 'through', 5814 ... 'E', 5815 ... 'back', 5816 ... placeInZone='Zone' 5817 ... ) 5818 ({}, {}) 5819 >>> g.getDecision('E') 5820 9 5821 >>> g.destination('B', 'through') 5822 9 5823 >>> g.destination('E', 'back') 5824 1 5825 >>> g.zoneParents(9) 5826 {'Zone'} 5827 >>> g.addUnexploredEdge('E', 'farther') 5828 10 5829 >>> g.replaceUnconfirmed( 5830 ... 'E', 5831 ... 'farther', 5832 ... 'F', 5833 ... 'closer', 5834 ... placeInZone=base.DefaultZone 5835 ... ) 5836 ({}, {}) 5837 >>> g.destination('E', 'farther') 5838 10 5839 >>> g.destination('F', 'closer') 5840 9 5841 >>> g.zoneParents(10) 5842 {'Zone'} 5843 >>> g.addUnexploredEdge('F', 'backwards', placeInZone='Enoz') 5844 11 5845 >>> g.replaceUnconfirmed( 5846 ... 'F', 5847 ... 'backwards', 5848 ... 'G', 5849 ... 'forwards', 5850 ... placeInZone=base.DefaultZone 5851 ... ) 5852 ({}, {}) 5853 >>> g.destination('F', 'backwards') 5854 11 5855 >>> g.destination('G', 'forwards') 5856 10 5857 >>> g.zoneParents(11) # not changed since it already had a zone 5858 {'Enoz'} 5859 >>> # TODO: forceNew example 5860 """ 5861 5862 # Defaults 5863 if tags is None: 5864 tags = {} 5865 if annotations is None: 5866 annotations = [] 5867 if revTags is None: 5868 revTags = {} 5869 if revAnnotations is None: 5870 revAnnotations = [] 5871 if decisionTags is None: 5872 decisionTags = {} 5873 if decisionAnnotations is None: 5874 decisionAnnotations = [] 5875 5876 # Resolve source 5877 fromID = self.resolveDecision(fromDecision) 5878 5879 # Figure out destination decision 5880 oldUnexplored = self.destination(fromID, transition) 5881 if self.isConfirmed(oldUnexplored): 5882 raise ExplorationStatusError( 5883 f"Transition {transition!r} from" 5884 f" {self.identityOf(fromDecision)} does not lead to an" 5885 f" unconfirmed decision (it leads to" 5886 f" {self.identityOf(oldUnexplored)} which is not tagged" 5887 f" 'unconfirmed')." 5888 ) 5889 5890 # Resolve destination 5891 newName: Optional[base.DecisionName] = None 5892 connectID: Optional[base.DecisionID] = None 5893 if forceNew: 5894 if isinstance(connectTo, base.DecisionID): 5895 raise TypeError( 5896 f"connectTo cannot be a decision ID when forceNew" 5897 f" is True. Got: {self.identityOf(connectTo)}" 5898 ) 5899 elif isinstance(connectTo, base.DecisionSpecifier): 5900 newName = connectTo.name 5901 elif isinstance(connectTo, base.DecisionName): 5902 newName = connectTo 5903 elif connectTo is None: 5904 oldName = self.nameFor(oldUnexplored) 5905 if ( 5906 oldName.startswith('_u.') 5907 and oldName[3:].isdigit() 5908 ): 5909 newName = utils.uniqueName('_x.' + oldName[3:], self) 5910 else: 5911 newName = oldName 5912 else: 5913 raise TypeError( 5914 f"Invalid connectTo value: {connectTo!r}" 5915 ) 5916 elif connectTo is not None: 5917 try: 5918 connectID = self.resolveDecision(connectTo) 5919 # leave newName as None 5920 except MissingDecisionError: 5921 if isinstance(connectTo, int): 5922 raise 5923 elif isinstance(connectTo, base.DecisionSpecifier): 5924 newName = connectTo.name 5925 # The domain & zone are ignored here 5926 else: # Must just be a string 5927 assert isinstance(connectTo, str) 5928 newName = connectTo 5929 else: 5930 # If connectTo name wasn't specified, use current name of 5931 # unknown node unless it's a default name 5932 oldName = self.nameFor(oldUnexplored) 5933 if ( 5934 oldName.startswith('_u.') 5935 and oldName[3:].isdigit() 5936 ): 5937 newName = utils.uniqueName('_x.' + oldName[3:], self) 5938 else: 5939 newName = oldName 5940 5941 # One or the other should be valid at this point 5942 assert connectID is not None or newName is not None 5943 5944 # Check that the old unknown doesn't have a reciprocal edge that 5945 # would collide with the specified return edge 5946 if reciprocal is not None: 5947 revFromUnknown = self.getDestination(oldUnexplored, reciprocal) 5948 if revFromUnknown not in (None, fromID): 5949 raise TransitionCollisionError( 5950 f"Transition {reciprocal!r} from" 5951 f" {self.identityOf(oldUnexplored)} exists and does" 5952 f" not lead back to {self.identityOf(fromDecision)}" 5953 f" (it leads to {self.identityOf(revFromUnknown)})." 5954 ) 5955 5956 # Remember old reciprocal edge for future merging in case 5957 # it's not reciprocal 5958 oldReciprocal = self.getReciprocal(fromID, transition) 5959 5960 # Apply any new tags or annotations, or create a new node 5961 needsZoneInfo = False 5962 if connectID is not None: 5963 # Before applying tags, check if we need to error out 5964 # because of a reciprocal edge that points to a known 5965 # destination: 5966 if reciprocal is not None: 5967 otherOldUnknown: Optional[ 5968 base.DecisionID 5969 ] = self.getDestination( 5970 connectID, 5971 reciprocal 5972 ) 5973 if ( 5974 otherOldUnknown is not None 5975 and self.isConfirmed(otherOldUnknown) 5976 ): 5977 raise ExplorationStatusError( 5978 f"Reciprocal transition {reciprocal!r} from" 5979 f" {self.identityOf(connectTo)} does not lead" 5980 f" to an unconfirmed decision (it leads to" 5981 f" {self.identityOf(otherOldUnknown)})." 5982 ) 5983 self.tagDecision(connectID, decisionTags) 5984 self.annotateDecision(connectID, decisionAnnotations) 5985 # Still needs zone info if the place we're connecting to was 5986 # unconfirmed up until now, since unconfirmed nodes don't 5987 # normally get zone info when they're created. 5988 if not self.isConfirmed(connectID): 5989 needsZoneInfo = True 5990 5991 # First, merge the old unknown with the connectTo node... 5992 destRenames = self.mergeDecisions( 5993 oldUnexplored, 5994 connectID, 5995 errorOnNameColision=False 5996 ) 5997 else: 5998 needsZoneInfo = True 5999 if len(self.zoneParents(oldUnexplored)) > 0: 6000 needsZoneInfo = False 6001 assert newName is not None 6002 self.renameDecision(oldUnexplored, newName) 6003 connectID = oldUnexplored 6004 # In this case there can't be an other old unknown 6005 otherOldUnknown = None 6006 destRenames = {} # empty 6007 6008 # Check for domain mismatch to stifle zone updates: 6009 fromDomain = self.domainFor(fromID) 6010 if connectID is None: 6011 destDomain = self.domainFor(oldUnexplored) 6012 else: 6013 destDomain = self.domainFor(connectID) 6014 6015 # Stifle zone updates if there's a mismatch 6016 if fromDomain != destDomain: 6017 needsZoneInfo = False 6018 6019 # Records renames that happen at the source (from node) 6020 sourceRenames = {} # empty for now 6021 6022 assert connectID is not None 6023 6024 # Apply the new zone if there is one 6025 if placeInZone is not None: 6026 if placeInZone is base.DefaultZone: 6027 # When using DefaultZone, changes are only made for new 6028 # destinations which don't already have any zones and 6029 # which are in the same domain as the departing node: 6030 # they get placed into each zone parent of the source 6031 # decision. 6032 if needsZoneInfo: 6033 # Remove destination from all current parents 6034 removeFrom = set(self.zoneParents(connectID)) # copy 6035 for parent in removeFrom: 6036 self.removeDecisionFromZone(connectID, parent) 6037 # Add it to parents of origin 6038 for parent in self.zoneParents(fromID): 6039 self.addDecisionToZone(connectID, parent) 6040 else: 6041 placeInZone = cast(base.Zone, placeInZone) 6042 # Create the zone if it doesn't already exist 6043 if self.getZoneInfo(placeInZone) is None: 6044 self.createZone(placeInZone, 0) 6045 # Add it to each grandparent of the from decision 6046 for parent in self.zoneParents(fromID): 6047 for grandparent in self.zoneParents(parent): 6048 self.addZoneToZone(placeInZone, grandparent) 6049 # Remove destination from all current parents 6050 for parent in set(self.zoneParents(connectID)): 6051 self.removeDecisionFromZone(connectID, parent) 6052 # Add it to the specified zone 6053 self.addDecisionToZone(connectID, placeInZone) 6054 6055 # Next, if there is a reciprocal name specified, we do more... 6056 if reciprocal is not None: 6057 # Figure out what kind of merging needs to happen 6058 if otherOldUnknown is None: 6059 if revFromUnknown is None: 6060 # Just create the desired reciprocal transition, which 6061 # we know does not already exist 6062 self.addTransition(connectID, reciprocal, fromID) 6063 otherOldReciprocal = None 6064 else: 6065 # Reciprocal exists, as revFromUnknown 6066 otherOldReciprocal = None 6067 else: 6068 otherOldReciprocal = self.getReciprocal( 6069 connectID, 6070 reciprocal 6071 ) 6072 # we need to merge otherOldUnknown into our fromDecision 6073 sourceRenames = self.mergeDecisions( 6074 otherOldUnknown, 6075 fromID, 6076 errorOnNameColision=False 6077 ) 6078 # Unvisited tag after merge only if both were 6079 6080 # No matter what happened we ensure the reciprocal 6081 # relationship is set up: 6082 self.setReciprocal(fromID, transition, reciprocal) 6083 6084 # Now we might need to merge some transitions: 6085 # - Any reciprocal of the target transition should be merged 6086 # with reciprocal (if it was already reciprocal, that's a 6087 # no-op). 6088 # - Any reciprocal of the reciprocal transition from the target 6089 # node (leading to otherOldUnknown) should be merged with 6090 # the target transition, even if it shared a name and was 6091 # renamed as a result. 6092 # - If reciprocal was renamed during the initial merge, those 6093 # transitions should be merged. 6094 6095 # Merge old reciprocal into reciprocal 6096 if oldReciprocal is not None: 6097 oldRev = destRenames.get(oldReciprocal, oldReciprocal) 6098 if self.getDestination(connectID, oldRev) is not None: 6099 # Note that we don't want to auto-merge the reciprocal, 6100 # which is the target transition 6101 self.mergeTransitions( 6102 connectID, 6103 oldRev, 6104 reciprocal, 6105 mergeReciprocal=False 6106 ) 6107 # Remove it from the renames map 6108 if oldReciprocal in destRenames: 6109 del destRenames[oldReciprocal] 6110 6111 # Merge reciprocal reciprocal from otherOldUnknown 6112 if otherOldReciprocal is not None: 6113 otherOldRev = sourceRenames.get( 6114 otherOldReciprocal, 6115 otherOldReciprocal 6116 ) 6117 # Note that the reciprocal is reciprocal, which we don't 6118 # need to merge 6119 self.mergeTransitions( 6120 fromID, 6121 otherOldRev, 6122 transition, 6123 mergeReciprocal=False 6124 ) 6125 # Remove it from the renames map 6126 if otherOldReciprocal in sourceRenames: 6127 del sourceRenames[otherOldReciprocal] 6128 6129 # Merge any renamed reciprocal onto reciprocal 6130 if reciprocal in destRenames: 6131 extraRev = destRenames[reciprocal] 6132 self.mergeTransitions( 6133 connectID, 6134 extraRev, 6135 reciprocal, 6136 mergeReciprocal=False 6137 ) 6138 # Remove it from the renames map 6139 del destRenames[reciprocal] 6140 6141 # Accumulate new tags & annotations for the transitions 6142 self.tagTransition(fromID, transition, tags) 6143 self.annotateTransition(fromID, transition, annotations) 6144 6145 if reciprocal is not None: 6146 self.tagTransition(connectID, reciprocal, revTags) 6147 self.annotateTransition(connectID, reciprocal, revAnnotations) 6148 6149 # Override copied requirement/consequences for the transitions 6150 if requirement is not None: 6151 self.setTransitionRequirement( 6152 fromID, 6153 transition, 6154 requirement 6155 ) 6156 if applyConsequence is not None: 6157 self.setConsequence( 6158 fromID, 6159 transition, 6160 applyConsequence 6161 ) 6162 6163 if reciprocal is not None: 6164 if revRequires is not None: 6165 self.setTransitionRequirement( 6166 connectID, 6167 reciprocal, 6168 revRequires 6169 ) 6170 if revConsequence is not None: 6171 self.setConsequence( 6172 connectID, 6173 reciprocal, 6174 revConsequence 6175 ) 6176 6177 # Remove 'unconfirmed' tag if it was present 6178 self.untagDecision(connectID, 'unconfirmed') 6179 6180 # Final checks 6181 assert self.getDestination(fromDecision, transition) == connectID 6182 useConnect: base.AnyDecisionSpecifier 6183 useRev: Optional[str] 6184 if connectTo is None: 6185 useConnect = connectID 6186 else: 6187 useConnect = connectTo 6188 if reciprocal is None: 6189 useRev = self.getReciprocal(fromDecision, transition) 6190 else: 6191 useRev = reciprocal 6192 if useRev is not None: 6193 try: 6194 assert self.getDestination(useConnect, useRev) == fromID 6195 except AmbiguousDecisionSpecifierError: 6196 assert self.getDestination(connectID, useRev) == fromID 6197 6198 # Return our final rename dictionaries 6199 return (destRenames, sourceRenames) 6200 6201 def endingID(self, name: base.DecisionName) -> base.DecisionID: 6202 """ 6203 Returns the decision ID for the ending with the specified name. 6204 Endings are disconnected decisions in the `ENDINGS_DOMAIN`; they 6205 don't normally include any zone information. If no ending with 6206 the specified name already existed, then a new ending with that 6207 name will be created and its Decision ID will be returned. 6208 6209 If a new decision is created, it will be tagged as unconfirmed. 6210 6211 Note that endings mostly aren't special: they're normal 6212 decisions in a separate singular-focalized domain. However, some 6213 parts of the exploration and journal machinery treat them 6214 differently (in particular, taking certain actions via 6215 `advanceSituation` while any decision in the `ENDINGS_DOMAIN` is 6216 active is an error. 6217 """ 6218 # Create our new ending decision if we need to 6219 try: 6220 endID = self.resolveDecision( 6221 base.DecisionSpecifier(ENDINGS_DOMAIN, None, name) 6222 ) 6223 except MissingDecisionError: 6224 # Create a new decision for the ending 6225 endID = self.addDecision(name, domain=ENDINGS_DOMAIN) 6226 # Tag it as unconfirmed 6227 self.tagDecision(endID, 'unconfirmed') 6228 6229 return endID 6230 6231 def triggerGroupID(self, name: base.DecisionName) -> base.DecisionID: 6232 """ 6233 Given the name of a trigger group, returns the ID of the special 6234 node representing that trigger group in the `TRIGGERS_DOMAIN`. 6235 If the specified group didn't already exist, it will be created. 6236 6237 Trigger group decisions are not special: they just exist in a 6238 separate spreading-focalized domain and have a few API methods to 6239 access them, but all the normal decision-related API methods 6240 still work. Their intended use is for sets of global triggers, 6241 by attaching actions with the 'trigger' tag to them and then 6242 activating or deactivating them as needed. 6243 """ 6244 result = self.getDecision( 6245 base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name) 6246 ) 6247 if result is None: 6248 return self.addDecision(name, domain=TRIGGERS_DOMAIN) 6249 else: 6250 return result 6251 6252 @staticmethod 6253 def example(which: Literal['simple', 'abc']) -> 'DecisionGraph': 6254 """ 6255 Returns one of a number of example decision graphs, depending on 6256 the string given. It returns a fresh copy each time. The graphs 6257 are: 6258 6259 - 'simple': Three nodes named 'A', 'B', and 'C' with IDs 0, 1, 6260 and 2, each connected to the next in the sequence by a 6261 'next' transition with reciprocal 'prev'. In other words, a 6262 simple little triangle. There are no tags, annotations, 6263 requirements, consequences, mechanisms, or equivalences. 6264 - 'abc': A more complicated 3-node setup that introduces a 6265 little bit of everything. In this graph, we have the same 6266 three nodes, but different transitions: 6267 6268 * From A you can go 'left' to B with reciprocal 'right'. 6269 * From A you can also go 'up_left' to B with reciprocal 6270 'up_right'. These transitions both require the 6271 'grate' mechanism (which is at decision A) to be in 6272 state 'open'. 6273 * From A you can go 'down' to C with reciprocal 'up'. 6274 6275 (In this graph, B and C are not directly connected to each 6276 other.) 6277 6278 The graph has two level-0 zones 'zoneA' and 'zoneB', along 6279 with a level-1 zone 'upZone'. Decisions A and C are in 6280 zoneA while B is in zoneB; zoneA is in upZone, but zoneB is 6281 not. 6282 6283 The decision A has annotation: 6284 6285 'This is a multi-word "annotation."' 6286 6287 The transition 'down' from A has annotation: 6288 6289 "Transition 'annotation.'" 6290 6291 Decision B has tags 'b' with value 1 and 'tag2' with value 6292 '"value"'. 6293 6294 Decision C has tag 'aw"ful' with value "ha'ha'". 6295 6296 Transition 'up' from C has tag 'fast' with value 1. 6297 6298 At decision C there are actions 'grab_helmet' and 6299 'pull_lever'. 6300 6301 The 'grab_helmet' transition requires that you don't have 6302 the 'helmet' capability, and gives you that capability, 6303 deactivating with delay 3. 6304 6305 The 'pull_lever' transition requires that you do have the 6306 'helmet' capability, and takes away that capability, but it 6307 also gives you 1 token, and if you have 2 tokens (before 6308 getting the one extra), it sets the 'grate' mechanism (which 6309 is a decision A) to state 'open' and deactivates. 6310 6311 The graph has an equivalence: having the 'helmet' capability 6312 satisfies requirements for the 'grate' mechanism to be in the 6313 'open' state. 6314 6315 """ 6316 result = DecisionGraph() 6317 if which == 'simple': 6318 result.addDecision('A') # id 0 6319 result.addDecision('B') # id 1 6320 result.addDecision('C') # id 2 6321 result.addTransition('A', 'next', 'B', 'prev') 6322 result.addTransition('B', 'next', 'C', 'prev') 6323 result.addTransition('C', 'next', 'A', 'prev') 6324 elif which == 'abc': 6325 result.addDecision('A') # id 0 6326 result.addDecision('B') # id 1 6327 result.addDecision('C') # id 2 6328 result.createZone('zoneA', 0) 6329 result.createZone('zoneB', 0) 6330 result.createZone('upZone', 1) 6331 result.addZoneToZone('zoneA', 'upZone') 6332 result.addDecisionToZone('A', 'zoneA') 6333 result.addDecisionToZone('B', 'zoneB') 6334 result.addDecisionToZone('C', 'zoneA') 6335 result.addTransition('A', 'left', 'B', 'right') 6336 result.addTransition('A', 'up_left', 'B', 'up_right') 6337 result.addTransition('A', 'down', 'C', 'up') 6338 result.setTransitionRequirement( 6339 'A', 6340 'up_left', 6341 base.ReqMechanism('grate', 'open') 6342 ) 6343 result.setTransitionRequirement( 6344 'B', 6345 'up_right', 6346 base.ReqMechanism('grate', 'open') 6347 ) 6348 result.annotateDecision('A', 'This is a multi-word "annotation."') 6349 result.annotateTransition('A', 'down', "Transition 'annotation.'") 6350 result.tagDecision('B', 'b') 6351 result.tagDecision('B', 'tag2', '"value"') 6352 result.tagDecision('C', 'aw"ful', "ha'ha") 6353 result.tagTransition('C', 'up', 'fast') 6354 result.addMechanism('grate', 'A') 6355 result.addAction( 6356 'C', 6357 'grab_helmet', 6358 base.ReqNot(base.ReqCapability('helmet')), 6359 [ 6360 base.effect(gain='helmet'), 6361 base.effect(deactivate=True, delay=3) 6362 ] 6363 ) 6364 result.addAction( 6365 'C', 6366 'pull_lever', 6367 base.ReqCapability('helmet'), 6368 [ 6369 base.effect(lose='helmet'), 6370 base.effect(gain=('token', 1)), 6371 base.condition( 6372 base.ReqTokens('token', 2), 6373 [ 6374 base.effect(set=('grate', 'open')), 6375 base.effect(deactivate=True) 6376 ] 6377 ) 6378 ] 6379 ) 6380 result.addEquivalence( 6381 base.ReqCapability('helmet'), 6382 (0, 'open') 6383 ) 6384 else: 6385 raise ValueError(f"Invalid example name: {which!r}") 6386 6387 return result 6388 6389 6390#---------------------------# 6391# DiscreteExploration class # 6392#---------------------------# 6393 6394def emptySituation() -> base.Situation: 6395 """ 6396 Creates and returns an empty situation: A situation that has an 6397 empty `DecisionGraph`, an empty `State`, a 'pending' decision type 6398 with `None` as the action taken, no tags, and no annotations. 6399 """ 6400 return base.Situation( 6401 graph=DecisionGraph(), 6402 state=base.emptyState(), 6403 type='pending', 6404 action=None, 6405 saves={}, 6406 tags={}, 6407 annotations=[] 6408 ) 6409 6410 6411class DiscreteExploration: 6412 """ 6413 A list of `Situations` each of which contains a `DecisionGraph` 6414 representing exploration over time, with `States` containing 6415 `FocalContext` information for each step and 'taken' values for the 6416 transition selected (at a particular decision) in that step. Each 6417 decision graph represents a new state of the world (and/or new 6418 knowledge about a persisting state of the world), and the 'taken' 6419 transition in one situation transition indicates which option was 6420 selected, or what event happened to cause update(s). Depending on the 6421 resolution, it could represent a close record of every decision made 6422 or a more coarse set of snapshots from gameplay with more time in 6423 between. 6424 6425 The steps of the exploration can also be tagged and annotated (see 6426 `tagStep` and `annotateStep`). 6427 6428 When a new `DiscreteExploration` is created, it starts out with an 6429 empty `Situation` that contains an empty `DecisionGraph`. Use the 6430 `start` method to name the starting decision point and set things up 6431 for other methods. 6432 6433 Tracking of player goals and destinations is also possible (see the 6434 `quest`, `progress`, `complete`, `destination`, and `arrive` methods). 6435 TODO: That 6436 """ 6437 def __init__(self) -> None: 6438 self.situations: List[base.Situation] = [ 6439 base.Situation( 6440 graph=DecisionGraph(), 6441 state=base.emptyState(), 6442 type='pending', 6443 action=None, 6444 saves={}, 6445 tags={}, 6446 annotations=[] 6447 ) 6448 ] 6449 6450 # Note: not hashable 6451 6452 def __eq__(self, other): 6453 """ 6454 Equality checker. `DiscreteExploration`s can only be equal to 6455 other `DiscreteExploration`s, not to other kinds of things. 6456 """ 6457 if not isinstance(other, DiscreteExploration): 6458 return False 6459 else: 6460 return self.situations == other.situations 6461 6462 @staticmethod 6463 def fromGraph( 6464 graph: DecisionGraph, 6465 state: Optional[base.State] = None 6466 ) -> 'DiscreteExploration': 6467 """ 6468 Creates an exploration which has just a single step whose graph 6469 is the entire specified graph, with the specified decision as 6470 the primary decision (if any). The graph is copied, so that 6471 changes to the exploration will not modify it. A starting state 6472 may also be specified if desired, although if not an empty state 6473 will be used (a provided starting state is NOT copied, but used 6474 directly). 6475 6476 Example: 6477 6478 >>> g = DecisionGraph() 6479 >>> g.addDecision('Room1') 6480 0 6481 >>> g.addDecision('Room2') 6482 1 6483 >>> g.addTransition('Room1', 'door', 'Room2', 'door') 6484 >>> e = DiscreteExploration.fromGraph(g) 6485 >>> len(e) 6486 1 6487 >>> e.getSituation().graph == g 6488 True 6489 >>> e.getActiveDecisions() 6490 set() 6491 >>> e.primaryDecision() is None 6492 True 6493 >>> e.observe('Room1', 'hatch') 6494 2 6495 >>> e.getSituation().graph == g 6496 False 6497 >>> e.getSituation().graph.destinationsFrom('Room1') 6498 {'door': 1, 'hatch': 2} 6499 >>> g.destinationsFrom('Room1') 6500 {'door': 1} 6501 """ 6502 result = DiscreteExploration() 6503 result.situations[0] = base.Situation( 6504 graph=copy.deepcopy(graph), 6505 state=base.emptyState() if state is None else state, 6506 type='pending', 6507 action=None, 6508 saves={}, 6509 tags={}, 6510 annotations=[] 6511 ) 6512 return result 6513 6514 def __len__(self) -> int: 6515 """ 6516 The 'length' of an exploration is the number of steps. 6517 """ 6518 return len(self.situations) 6519 6520 def __getitem__(self, i: int) -> base.Situation: 6521 """ 6522 Indexing an exploration returns the situation at that step. 6523 """ 6524 return self.situations[i] 6525 6526 def __iter__(self) -> Iterator[base.Situation]: 6527 """ 6528 Iterating over an exploration yields each `Situation` in order. 6529 """ 6530 for i in range(len(self)): 6531 yield self[i] 6532 6533 def getSituation(self, step: int = -1) -> base.Situation: 6534 """ 6535 Returns a `base.Situation` named tuple detailing the state of 6536 the exploration at a given step (or at the current step if no 6537 argument is given). Note that this method works the same 6538 way as indexing the exploration: see `__getitem__`. 6539 6540 Raises an `IndexError` if asked for a step that's out-of-range. 6541 """ 6542 return self[step] 6543 6544 def primaryDecision(self, step: int = -1) -> Optional[base.DecisionID]: 6545 """ 6546 Returns the current primary `base.DecisionID`, or the primary 6547 decision from a specific step if one is specified. This may be 6548 `None` for some steps, but mostly it's the destination of the 6549 transition taken in the previous step. 6550 """ 6551 return self[step].state['primaryDecision'] 6552 6553 def effectiveCapabilities( 6554 self, 6555 step: int = -1 6556 ) -> base.CapabilitySet: 6557 """ 6558 Returns the effective capability set for the specified step 6559 (default is the last/current step). See 6560 `base.effectiveCapabilities`. 6561 """ 6562 return base.effectiveCapabilitySet(self.getSituation(step).state) 6563 6564 def getCommonContext( 6565 self, 6566 step: Optional[int] = None 6567 ) -> base.FocalContext: 6568 """ 6569 Returns the common `FocalContext` at the specified step, or at 6570 the current step if no argument is given. Raises an `IndexError` 6571 if an invalid step is specified. 6572 """ 6573 if step is None: 6574 step = -1 6575 state = self.getSituation(step).state 6576 return state['common'] 6577 6578 def getActiveContext( 6579 self, 6580 step: Optional[int] = None 6581 ) -> base.FocalContext: 6582 """ 6583 Returns the active `FocalContext` at the specified step, or at 6584 the current step if no argument is provided. Raises an 6585 `IndexError` if an invalid step is specified. 6586 """ 6587 if step is None: 6588 step = -1 6589 state = self.getSituation(step).state 6590 return state['contexts'][state['activeContext']] 6591 6592 def addFocalContext(self, name: base.FocalContextName) -> None: 6593 """ 6594 Adds a new empty focal context to our set of focal contexts (see 6595 `emptyFocalContext`). Use `setActiveContext` to swap to it. 6596 Raises a `FocalContextCollisionError` if the name is already in 6597 use. 6598 """ 6599 contextMap = self.getSituation().state['contexts'] 6600 if name in contextMap: 6601 raise FocalContextCollisionError( 6602 f"Cannot add focal context {name!r}: a focal context" 6603 f" with that name already exists." 6604 ) 6605 contextMap[name] = base.emptyFocalContext() 6606 6607 def setActiveContext(self, which: base.FocalContextName) -> None: 6608 """ 6609 Sets the active context to the named focal context, creating it 6610 if it did not already exist (makes changes to the current 6611 situation only). Does not add an exploration step (use 6612 `advanceSituation` with a 'swap' action for that). 6613 """ 6614 state = self.getSituation().state 6615 contextMap = state['contexts'] 6616 if which not in contextMap: 6617 self.addFocalContext(which) 6618 state['activeContext'] = which 6619 6620 def createDomain( 6621 self, 6622 name: base.Domain, 6623 focalization: base.DomainFocalization = 'singular', 6624 makeActive: bool = False, 6625 inCommon: Union[bool, Literal["both"]] = "both" 6626 ) -> None: 6627 """ 6628 Creates a new domain with the given focalization type, in either 6629 the common context (`inCommon` = `True`) the active context 6630 (`inCommon` = `False`) or both (the default; `inCommon` = 'both'). 6631 The domain's focalization will be set to the given 6632 `focalization` value (default 'singular') and it will have no 6633 active decisions. Raises a `DomainCollisionError` if a domain 6634 with the specified name already exists. 6635 6636 Creates the domain in the current situation. 6637 6638 If `makeActive` is set to `True` (default is `False`) then the 6639 domain will be made active in whichever context(s) it's created 6640 in. 6641 """ 6642 now = self.getSituation() 6643 state = now.state 6644 modify = [] 6645 if inCommon in (True, "both"): 6646 modify.append(('common', state['common'])) 6647 if inCommon in (False, "both"): 6648 acName = state['activeContext'] 6649 modify.append( 6650 ('current ({repr(acName)})', state['contexts'][acName]) 6651 ) 6652 6653 for (fcType, fc) in modify: 6654 if name in fc['focalization']: 6655 raise DomainCollisionError( 6656 f"Cannot create domain {repr(name)} because a" 6657 f" domain with that name already exists in the" 6658 f" {fcType} focal context." 6659 ) 6660 fc['focalization'][name] = focalization 6661 if makeActive: 6662 fc['activeDomains'].add(name) 6663 if focalization == "spreading": 6664 fc['activeDecisions'][name] = set() 6665 elif focalization == "plural": 6666 fc['activeDecisions'][name] = {} 6667 else: 6668 fc['activeDecisions'][name] = None 6669 6670 def activateDomain( 6671 self, 6672 domain: base.Domain, 6673 activate: bool = True, 6674 inContext: base.ContextSpecifier = "active" 6675 ) -> None: 6676 """ 6677 Sets the given domain as active (or inactive if 'activate' is 6678 given as `False`) in the specified context (default "active"). 6679 6680 Modifies the current situation. 6681 """ 6682 fc: base.FocalContext 6683 if inContext == "active": 6684 fc = self.getActiveContext() 6685 elif inContext == "common": 6686 fc = self.getCommonContext() 6687 6688 if activate: 6689 fc['activeDomains'].add(domain) 6690 else: 6691 try: 6692 fc['activeDomains'].remove(domain) 6693 except KeyError: 6694 pass 6695 6696 def createTriggerGroup( 6697 self, 6698 name: base.DecisionName 6699 ) -> base.DecisionID: 6700 """ 6701 Creates a new trigger group with the given name, returning the 6702 decision ID for that trigger group. If this is the first trigger 6703 group being created, also creates the `TRIGGERS_DOMAIN` domain 6704 as a spreading-focalized domain that's active in the common 6705 context (but does NOT set the created trigger group as an active 6706 decision in that domain). 6707 6708 You can use 'goto' effects to activate trigger domains via 6709 consequences, and 'retreat' effects to deactivate them. 6710 6711 Creating a second trigger group with the same name as another 6712 results in a `ValueError`. 6713 6714 TODO: Retreat effects 6715 """ 6716 ctx = self.getCommonContext() 6717 if TRIGGERS_DOMAIN not in ctx['focalization']: 6718 self.createDomain( 6719 TRIGGERS_DOMAIN, 6720 focalization='spreading', 6721 makeActive=True, 6722 inCommon=True 6723 ) 6724 6725 graph = self.getSituation().graph 6726 if graph.getDecision( 6727 base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name) 6728 ) is not None: 6729 raise ValueError( 6730 f"Cannot create trigger group {name!r}: a trigger group" 6731 f" with that name already exists." 6732 ) 6733 6734 return self.getSituation().graph.triggerGroupID(name) 6735 6736 def toggleTriggerGroup( 6737 self, 6738 name: base.DecisionName, 6739 setActive: Union[bool, None] = None 6740 ): 6741 """ 6742 Toggles whether the specified trigger group (a decision in the 6743 `TRIGGERS_DOMAIN`) is active or not. Pass `True` or `False` as 6744 the `setActive` argument (instead of the default `None`) to set 6745 the state directly instead of toggling it. 6746 6747 Note that trigger groups are decisions in a spreading-focalized 6748 domain, so they can be activated or deactivated by the 'goto' 6749 and 'retreat' effects as well. 6750 6751 This does not affect whether the `TRIGGERS_DOMAIN` itself is 6752 active (normally it would always be active). 6753 6754 Raises a `MissingDecisionError` if the specified trigger group 6755 does not exist yet, including when the entire `TRIGGERS_DOMAIN` 6756 does not exist. Raises a `KeyError` if the target group exists 6757 but the `TRIGGERS_DOMAIN` has not been set up properly. 6758 """ 6759 ctx = self.getCommonContext() 6760 tID = self.getSituation().graph.resolveDecision( 6761 base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name) 6762 ) 6763 activeGroups = ctx['activeDecisions'][TRIGGERS_DOMAIN] 6764 assert isinstance(activeGroups, set) 6765 if tID in activeGroups: 6766 if setActive is not True: 6767 activeGroups.remove(tID) 6768 else: 6769 if setActive is not False: 6770 activeGroups.add(tID) 6771 6772 def getActiveDecisions( 6773 self, 6774 step: Optional[int] = None, 6775 inCommon: Union[bool, Literal["both"]] = "both" 6776 ) -> Set[base.DecisionID]: 6777 """ 6778 Returns the set of active decisions at the given step index, or 6779 at the current step if no step is specified. Raises an 6780 `IndexError` if the step index is out of bounds (see `__len__`). 6781 May return an empty set if no decisions are active. 6782 6783 If `inCommon` is set to "both" (the default) then decisions 6784 active in either the common or active context are returned. Set 6785 it to `True` or `False` to return only decisions active in the 6786 common (when `True`) or active (when `False`) context. 6787 """ 6788 if step is None: 6789 step = -1 6790 state = self.getSituation(step).state 6791 if inCommon == "both": 6792 return base.combinedDecisionSet(state) 6793 elif inCommon is True: 6794 return base.activeDecisionSet(state['common']) 6795 elif inCommon is False: 6796 return base.activeDecisionSet( 6797 state['contexts'][state['activeContext']] 6798 ) 6799 else: 6800 raise ValueError( 6801 f"Invalid inCommon value {repr(inCommon)} (must be" 6802 f" 'both', True, or False)." 6803 ) 6804 6805 def setActiveDecisionsAtStep( 6806 self, 6807 step: int, 6808 domain: base.Domain, 6809 activate: Union[ 6810 base.DecisionID, 6811 Dict[base.FocalPointName, Optional[base.DecisionID]], 6812 Set[base.DecisionID] 6813 ], 6814 inCommon: bool = False 6815 ) -> None: 6816 """ 6817 Changes the activation status of decisions in the active 6818 `FocalContext` at the specified step, for the specified domain 6819 (see `currentActiveContext`). Does this without adding an 6820 exploration step, which is unusual: normally you should use 6821 another method like `warp` to update active decisions. 6822 6823 Note that this does not change which domains are active, and 6824 setting active decisions in inactive domains does not make those 6825 decisions active overall. 6826 6827 Which decisions to activate or deactivate are specified as 6828 either a single `DecisionID`, a list of them, or a set of them, 6829 depending on the `DomainFocalization` setting in the selected 6830 `FocalContext` for the specified domain. A `TypeError` will be 6831 raised if the wrong kind of decision information is provided. If 6832 the focalization context does not have any focalization value for 6833 the domain in question, it will be set based on the kind of 6834 active decision information specified. 6835 6836 A `MissingDecisionError` will be raised if a decision is 6837 included which is not part of the current `DecisionGraph`. 6838 The provided information will overwrite the previous active 6839 decision information. 6840 6841 If `inCommon` is set to `True`, then decisions are activated or 6842 deactivated in the common context, instead of in the active 6843 context. 6844 6845 Example: 6846 6847 >>> e = DiscreteExploration() 6848 >>> e.getActiveDecisions() 6849 set() 6850 >>> graph = e.getSituation().graph 6851 >>> graph.addDecision('A') 6852 0 6853 >>> graph.addDecision('B') 6854 1 6855 >>> graph.addDecision('C') 6856 2 6857 >>> e.setActiveDecisionsAtStep(0, 'main', 0) 6858 >>> e.getActiveDecisions() 6859 {0} 6860 >>> e.setActiveDecisionsAtStep(0, 'main', 1) 6861 >>> e.getActiveDecisions() 6862 {1} 6863 >>> graph = e.getSituation().graph 6864 >>> graph.addDecision('One', domain='numbers') 6865 3 6866 >>> graph.addDecision('Two', domain='numbers') 6867 4 6868 >>> graph.addDecision('Three', domain='numbers') 6869 5 6870 >>> graph.addDecision('Bear', domain='animals') 6871 6 6872 >>> graph.addDecision('Spider', domain='animals') 6873 7 6874 >>> graph.addDecision('Eel', domain='animals') 6875 8 6876 >>> ac = e.getActiveContext() 6877 >>> ac['focalization']['numbers'] = 'plural' 6878 >>> ac['focalization']['animals'] = 'spreading' 6879 >>> ac['activeDecisions']['numbers'] = {'a': None, 'b': None} 6880 >>> ac['activeDecisions']['animals'] = set() 6881 >>> cc = e.getCommonContext() 6882 >>> cc['focalization']['numbers'] = 'plural' 6883 >>> cc['focalization']['animals'] = 'spreading' 6884 >>> cc['activeDecisions']['numbers'] = {'z': None} 6885 >>> cc['activeDecisions']['animals'] = set() 6886 >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 3, 'b': 3}) 6887 >>> e.getActiveDecisions() 6888 {1} 6889 >>> e.activateDomain('numbers') 6890 >>> e.getActiveDecisions() 6891 {1, 3} 6892 >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 4, 'b': None}) 6893 >>> e.getActiveDecisions() 6894 {1, 4} 6895 >>> # Wrong domain for the decision ID: 6896 >>> e.setActiveDecisionsAtStep(0, 'main', 3) 6897 Traceback (most recent call last): 6898 ... 6899 ValueError... 6900 >>> # Wrong domain for one of the decision IDs: 6901 >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 2, 'b': None}) 6902 Traceback (most recent call last): 6903 ... 6904 ValueError... 6905 >>> # Wrong kind of decision information provided. 6906 >>> e.setActiveDecisionsAtStep(0, 'numbers', 3) 6907 Traceback (most recent call last): 6908 ... 6909 TypeError... 6910 >>> e.getActiveDecisions() 6911 {1, 4} 6912 >>> e.setActiveDecisionsAtStep(0, 'animals', {6, 7}) 6913 >>> e.getActiveDecisions() 6914 {1, 4} 6915 >>> e.activateDomain('animals') 6916 >>> e.getActiveDecisions() 6917 {1, 4, 6, 7} 6918 >>> e.setActiveDecisionsAtStep(0, 'animals', {8}) 6919 >>> e.getActiveDecisions() 6920 {8, 1, 4} 6921 >>> e.setActiveDecisionsAtStep(1, 'main', 2) # invalid step 6922 Traceback (most recent call last): 6923 ... 6924 IndexError... 6925 >>> e.setActiveDecisionsAtStep(0, 'novel', 0) # domain mismatch 6926 Traceback (most recent call last): 6927 ... 6928 ValueError... 6929 6930 Example of active/common contexts: 6931 6932 >>> e = DiscreteExploration() 6933 >>> graph = e.getSituation().graph 6934 >>> graph.addDecision('A') 6935 0 6936 >>> graph.addDecision('B') 6937 1 6938 >>> e.activateDomain('main', inContext="common") 6939 >>> e.setActiveDecisionsAtStep(0, 'main', 0, inCommon=True) 6940 >>> e.getActiveDecisions() 6941 {0} 6942 >>> e.setActiveDecisionsAtStep(0, 'main', None) 6943 >>> e.getActiveDecisions() 6944 {0} 6945 >>> # (Still active since it's active in the common context) 6946 >>> e.setActiveDecisionsAtStep(0, 'main', 1) 6947 >>> e.getActiveDecisions() 6948 {0, 1} 6949 >>> e.setActiveDecisionsAtStep(0, 'main', 1, inCommon=True) 6950 >>> e.getActiveDecisions() 6951 {1} 6952 >>> e.setActiveDecisionsAtStep(0, 'main', None, inCommon=True) 6953 >>> e.getActiveDecisions() 6954 {1} 6955 >>> # (Still active since it's active in the active context) 6956 >>> e.setActiveDecisionsAtStep(0, 'main', None) 6957 >>> e.getActiveDecisions() 6958 set() 6959 """ 6960 now = self.getSituation(step) 6961 graph = now.graph 6962 if inCommon: 6963 context = self.getCommonContext(step) 6964 else: 6965 context = self.getActiveContext(step) 6966 6967 defaultFocalization: base.DomainFocalization = 'singular' 6968 if isinstance(activate, base.DecisionID): 6969 defaultFocalization = 'singular' 6970 elif isinstance(activate, dict): 6971 defaultFocalization = 'plural' 6972 elif isinstance(activate, set): 6973 defaultFocalization = 'spreading' 6974 elif domain not in context['focalization']: 6975 raise TypeError( 6976 f"Domain {domain!r} has no focalization in the" 6977 f" {'common' if inCommon else 'active'} context," 6978 f" and the specified position doesn't imply one." 6979 ) 6980 6981 focalization = base.getDomainFocalization( 6982 context, 6983 domain, 6984 defaultFocalization 6985 ) 6986 6987 # Check domain & existence of decision(s) in question 6988 if activate is None: 6989 pass 6990 elif isinstance(activate, base.DecisionID): 6991 if activate not in graph: 6992 raise MissingDecisionError( 6993 f"There is no decision {activate} at step {step}." 6994 ) 6995 if graph.domainFor(activate) != domain: 6996 raise ValueError( 6997 f"Can't set active decisions in domain {domain!r}" 6998 f" to decision {graph.identityOf(activate)} because" 6999 f" that decision is in actually in domain" 7000 f" {graph.domainFor(activate)!r}." 7001 ) 7002 elif isinstance(activate, dict): 7003 for fpName, pos in activate.items(): 7004 if pos is None: 7005 continue 7006 if pos not in graph: 7007 raise MissingDecisionError( 7008 f"There is no decision {pos} at step {step}." 7009 ) 7010 if graph.domainFor(pos) != domain: 7011 raise ValueError( 7012 f"Can't set active decision for focal point" 7013 f" {fpName!r} in domain {domain!r}" 7014 f" to decision {graph.identityOf(pos)} because" 7015 f" that decision is in actually in domain" 7016 f" {graph.domainFor(pos)!r}." 7017 ) 7018 elif isinstance(activate, set): 7019 for pos in activate: 7020 if pos not in graph: 7021 raise MissingDecisionError( 7022 f"There is no decision {pos} at step {step}." 7023 ) 7024 if graph.domainFor(pos) != domain: 7025 raise ValueError( 7026 f"Can't set {graph.identityOf(pos)} as an" 7027 f" active decision in domain {domain!r} to" 7028 f" decision because that decision is in" 7029 f" actually in domain {graph.domainFor(pos)!r}." 7030 ) 7031 else: 7032 raise TypeError( 7033 f"Domain {domain!r} has no focalization in the" 7034 f" {'common' if inCommon else 'active'} context," 7035 f" and the specified position doesn't imply one:" 7036 f"\n{activate!r}" 7037 ) 7038 7039 if focalization == 'singular': 7040 if activate is None or isinstance(activate, base.DecisionID): 7041 if activate is not None: 7042 targetDomain = graph.domainFor(activate) 7043 if activate not in graph: 7044 raise MissingDecisionError( 7045 f"There is no decision {activate} in the" 7046 f" graph at step {step}." 7047 ) 7048 elif targetDomain != domain: 7049 raise ValueError( 7050 f"At step {step}, decision {activate} cannot" 7051 f" be the active decision for domain" 7052 f" {repr(domain)} because it is in a" 7053 f" different domain ({repr(targetDomain)})." 7054 ) 7055 context['activeDecisions'][domain] = activate 7056 else: 7057 raise TypeError( 7058 f"{'Common' if inCommon else 'Active'} focal" 7059 f" context at step {step} has {repr(focalization)}" 7060 f" focalization for domain {repr(domain)}, so the" 7061 f" active decision must be a single decision or" 7062 f" None.\n(You provided: {repr(activate)})" 7063 ) 7064 elif focalization == 'plural': 7065 if ( 7066 isinstance(activate, dict) 7067 and all( 7068 isinstance(k, base.FocalPointName) 7069 for k in activate.keys() 7070 ) 7071 and all( 7072 v is None or isinstance(v, base.DecisionID) 7073 for v in activate.values() 7074 ) 7075 ): 7076 for v in activate.values(): 7077 if v is not None: 7078 targetDomain = graph.domainFor(v) 7079 if v not in graph: 7080 raise MissingDecisionError( 7081 f"There is no decision {v} in the graph" 7082 f" at step {step}." 7083 ) 7084 elif targetDomain != domain: 7085 raise ValueError( 7086 f"At step {step}, decision {activate}" 7087 f" cannot be an active decision for" 7088 f" domain {repr(domain)} because it is" 7089 f" in a different domain" 7090 f" ({repr(targetDomain)})." 7091 ) 7092 context['activeDecisions'][domain] = activate 7093 else: 7094 raise TypeError( 7095 f"{'Common' if inCommon else 'Active'} focal" 7096 f" context at step {step} has {repr(focalization)}" 7097 f" focalization for domain {repr(domain)}, so the" 7098 f" active decision must be a dictionary mapping" 7099 f" focal point names to decision IDs (or Nones)." 7100 f"\n(You provided: {repr(activate)})" 7101 ) 7102 elif focalization == 'spreading': 7103 if ( 7104 isinstance(activate, set) 7105 and all(isinstance(x, base.DecisionID) for x in activate) 7106 ): 7107 for x in activate: 7108 targetDomain = graph.domainFor(x) 7109 if x not in graph: 7110 raise MissingDecisionError( 7111 f"There is no decision {x} in the graph" 7112 f" at step {step}." 7113 ) 7114 elif targetDomain != domain: 7115 raise ValueError( 7116 f"At step {step}, decision {activate}" 7117 f" cannot be an active decision for" 7118 f" domain {repr(domain)} because it is" 7119 f" in a different domain" 7120 f" ({repr(targetDomain)})." 7121 ) 7122 context['activeDecisions'][domain] = activate 7123 else: 7124 raise TypeError( 7125 f"{'Common' if inCommon else 'Active'} focal" 7126 f" context at step {step} has {repr(focalization)}" 7127 f" focalization for domain {repr(domain)}, so the" 7128 f" active decision must be a set of decision IDs" 7129 f"\n(You provided: {repr(activate)})" 7130 ) 7131 else: 7132 raise RuntimeError( 7133 f"Invalid focalization value {repr(focalization)} for" 7134 f" domain {repr(domain)} at step {step}." 7135 ) 7136 7137 def movementAtStep(self, step: int = -1) -> Tuple[ 7138 Union[base.DecisionID, Set[base.DecisionID], None], 7139 Optional[base.Transition], 7140 Union[base.DecisionID, Set[base.DecisionID], None] 7141 ]: 7142 """ 7143 Given a step number, returns information about the starting 7144 decision, transition taken, and destination decision for that 7145 step. Not all steps have all of those, so some items may be 7146 `None`. 7147 7148 For steps where there is no action, where a decision is still 7149 pending, or where the action type is 'focus', 'swap', 7150 or 'focalize', the result will be (`None`, `None`, `None`), 7151 unless a primary decision is available in which case the first 7152 item in the tuple will be that decision. For 'start' actions, the 7153 starting position and transition will be `None` (again unless the 7154 step had a primary decision) but the destination will be the ID 7155 of the node started at. 7156 7157 Also, if the action taken has multiple potential or actual start 7158 or end points, these may be sets of decision IDs instead of 7159 single IDs. 7160 7161 Note that the primary decision of the starting state is usually 7162 used as the from-decision, but in some cases an action dictates 7163 taking a transition from a different decision, and this function 7164 will return that decision as the from-decision. 7165 7166 TODO: Examples! 7167 7168 TODO: Account for bounce/follow/goto effects!!! 7169 """ 7170 now = self.getSituation(step) 7171 action = now.action 7172 graph = now.graph 7173 primary = now.state['primaryDecision'] 7174 7175 if action is None: 7176 return (primary, None, None) 7177 7178 aType = action[0] 7179 fromID: Optional[base.DecisionID] 7180 destID: Optional[base.DecisionID] 7181 transition: base.Transition 7182 outcomes: List[bool] 7183 7184 if aType in ('noAction', 'focus', 'swap', 'focalize'): 7185 return (primary, None, None) 7186 elif aType == 'start': 7187 assert len(action) == 7 7188 where = cast( 7189 Union[ 7190 base.DecisionID, 7191 Dict[base.FocalPointName, base.DecisionID], 7192 Set[base.DecisionID] 7193 ], 7194 action[1] 7195 ) 7196 if isinstance(where, dict): 7197 where = set(where.values()) 7198 return (primary, None, where) 7199 elif aType in ('take', 'explore'): 7200 if ( 7201 (len(action) == 4 or len(action) == 7) 7202 and isinstance(action[2], base.DecisionID) 7203 ): 7204 fromID = action[2] 7205 assert isinstance(action[3], tuple) 7206 transition, outcomes = action[3] 7207 if ( 7208 action[0] == "explore" 7209 and isinstance(action[4], base.DecisionID) 7210 ): 7211 destID = action[4] 7212 else: 7213 destID = graph.getDestination(fromID, transition) 7214 return (fromID, transition, destID) 7215 elif ( 7216 (len(action) == 3 or len(action) == 6) 7217 and isinstance(action[1], tuple) 7218 and isinstance(action[2], base.Transition) 7219 and len(action[1]) == 3 7220 and action[1][0] in get_args(base.ContextSpecifier) 7221 and isinstance(action[1][1], base.Domain) 7222 and isinstance(action[1][2], base.FocalPointName) 7223 ): 7224 fromID = base.resolvePosition(now, action[1]) 7225 if fromID is None: 7226 raise InvalidActionError( 7227 f"{aType!r} action at step {step} has position" 7228 f" {action[1]!r} which cannot be resolved to a" 7229 f" decision." 7230 ) 7231 transition, outcomes = action[2] 7232 if ( 7233 action[0] == "explore" 7234 and isinstance(action[3], base.DecisionID) 7235 ): 7236 destID = action[3] 7237 else: 7238 destID = graph.getDestination(fromID, transition) 7239 return (fromID, transition, destID) 7240 else: 7241 raise InvalidActionError( 7242 f"Malformed {aType!r} action:\n{repr(action)}" 7243 ) 7244 elif aType == 'warp': 7245 if len(action) != 3: 7246 raise InvalidActionError( 7247 f"Malformed 'warp' action:\n{repr(action)}" 7248 ) 7249 dest = action[2] 7250 assert isinstance(dest, base.DecisionID) 7251 if action[1] in get_args(base.ContextSpecifier): 7252 # Unspecified starting point; find active decisions in 7253 # same domain if primary is None 7254 if primary is not None: 7255 return (primary, None, dest) 7256 else: 7257 toDomain = now.graph.domainFor(dest) 7258 # TODO: Could check destination focalization here... 7259 active = self.getActiveDecisions(step) 7260 sameDomain = set( 7261 dID 7262 for dID in active 7263 if now.graph.domainFor(dID) == toDomain 7264 ) 7265 if len(sameDomain) == 1: 7266 return ( 7267 list(sameDomain)[0], 7268 None, 7269 dest 7270 ) 7271 else: 7272 return ( 7273 sameDomain, 7274 None, 7275 dest 7276 ) 7277 else: 7278 if ( 7279 not isinstance(action[1], tuple) 7280 or not len(action[1]) == 3 7281 or not action[1][0] in get_args(base.ContextSpecifier) 7282 or not isinstance(action[1][1], base.Domain) 7283 or not isinstance(action[1][2], base.FocalPointName) 7284 ): 7285 raise InvalidActionError( 7286 f"Malformed 'warp' action:\n{repr(action)}" 7287 ) 7288 return ( 7289 base.resolvePosition(now, action[1]), 7290 None, 7291 dest 7292 ) 7293 else: 7294 raise InvalidActionError( 7295 f"Action taken had invalid action type {repr(aType)}:" 7296 f"\n{repr(action)}" 7297 ) 7298 7299 def tagStep( 7300 self, 7301 tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]], 7302 tagValue: Union[ 7303 base.TagValue, 7304 type[base.NoTagValue] 7305 ] = base.NoTagValue, 7306 step: int = -1 7307 ) -> None: 7308 """ 7309 Adds a tag (or multiple tags) to the current step, or to a 7310 specific step if `n` is given as an integer rather than the 7311 default `None`. A tag value should be supplied when a tag is 7312 given (unless you want to use the default of `1`), but it's a 7313 `ValueError` to supply a tag value when a dictionary of tags to 7314 update is provided. 7315 """ 7316 if isinstance(tagOrTags, base.Tag): 7317 if tagValue is base.NoTagValue: 7318 tagValue = 1 7319 7320 # Not sure why this is necessary... 7321 tagValue = cast(base.TagValue, tagValue) 7322 7323 self.getSituation(step).tags.update({tagOrTags: tagValue}) 7324 else: 7325 self.getSituation(step).tags.update(tagOrTags) 7326 7327 def annotateStep( 7328 self, 7329 annotationOrAnnotations: Union[ 7330 base.Annotation, 7331 Sequence[base.Annotation] 7332 ], 7333 step: Optional[int] = None 7334 ) -> None: 7335 """ 7336 Adds an annotation to the current exploration step, or to a 7337 specific step if `n` is given as an integer rather than the 7338 default `None`. 7339 """ 7340 if step is None: 7341 step = -1 7342 if isinstance(annotationOrAnnotations, base.Annotation): 7343 self.getSituation(step).annotations.append( 7344 annotationOrAnnotations 7345 ) 7346 else: 7347 self.getSituation(step).annotations.extend( 7348 annotationOrAnnotations 7349 ) 7350 7351 def hasCapability( 7352 self, 7353 capability: base.Capability, 7354 step: Optional[int] = None, 7355 inCommon: Union[bool, Literal['both']] = "both" 7356 ) -> bool: 7357 """ 7358 Returns True if the player currently had the specified 7359 capability, at the specified exploration step, and False 7360 otherwise. Checks the current state if no step is given. Does 7361 NOT return true if the game state means that the player has an 7362 equivalent for that capability (see 7363 `hasCapabilityOrEquivalent`). 7364 7365 Normally, `inCommon` is set to 'both' by default and so if 7366 either the common `FocalContext` or the active one has the 7367 capability, this will return `True`. `inCommon` may instead be 7368 set to `True` or `False` to ask about just the common (or 7369 active) focal context. 7370 """ 7371 state = self.getSituation().state 7372 commonCapabilities = state['common']['capabilities']\ 7373 ['capabilities'] # noqa 7374 activeCapabilities = state['contexts'][state['activeContext']]\ 7375 ['capabilities']['capabilities'] # noqa 7376 7377 if inCommon == 'both': 7378 return ( 7379 capability in commonCapabilities 7380 or capability in activeCapabilities 7381 ) 7382 elif inCommon is True: 7383 return capability in commonCapabilities 7384 elif inCommon is False: 7385 return capability in activeCapabilities 7386 else: 7387 raise ValueError( 7388 f"Invalid inCommon value (must be False, True, or" 7389 f" 'both'; got {repr(inCommon)})." 7390 ) 7391 7392 def hasCapabilityOrEquivalent( 7393 self, 7394 capability: base.Capability, 7395 step: Optional[int] = None, 7396 location: Optional[Set[base.DecisionID]] = None 7397 ) -> bool: 7398 """ 7399 Works like `hasCapability`, but also returns `True` if the 7400 player counts as having the specified capability via an equivalence 7401 that's part of the current graph. As with `hasCapability`, the 7402 optional `step` argument is used to specify which step to check, 7403 with the current step being used as the default. 7404 7405 The `location` set can specify where to start looking for 7406 mechanisms; if left unspecified active decisions for that step 7407 will be used. 7408 """ 7409 if step is None: 7410 step = -1 7411 if location is None: 7412 location = self.getActiveDecisions(step) 7413 situation = self.getSituation(step) 7414 return base.hasCapabilityOrEquivalent( 7415 capability, 7416 base.RequirementContext( 7417 state=situation.state, 7418 graph=situation.graph, 7419 searchFrom=location 7420 ) 7421 ) 7422 7423 def gainCapabilityNow( 7424 self, 7425 capability: base.Capability, 7426 inCommon: bool = False 7427 ) -> None: 7428 """ 7429 Modifies the current game state to add the specified `Capability` 7430 to the player's capabilities. No changes are made to the current 7431 graph. 7432 7433 If `inCommon` is set to `True` (default is `False`) then the 7434 capability will be added to the common `FocalContext` and will 7435 therefore persist even when a focal context switch happens. 7436 Normally, it will be added to the currently-active focal 7437 context. 7438 """ 7439 state = self.getSituation().state 7440 if inCommon: 7441 context = state['common'] 7442 else: 7443 context = state['contexts'][state['activeContext']] 7444 context['capabilities']['capabilities'].add(capability) 7445 7446 def loseCapabilityNow( 7447 self, 7448 capability: base.Capability, 7449 inCommon: Union[bool, Literal['both']] = "both" 7450 ) -> None: 7451 """ 7452 Modifies the current game state to remove the specified `Capability` 7453 from the player's capabilities. Does nothing if the player 7454 doesn't already have that capability. 7455 7456 By default, this removes the capability from both the common 7457 capabilities set and the active `FocalContext`'s capabilities 7458 set, so that afterwards the player will definitely not have that 7459 capability. However, if you set `inCommon` to either `True` or 7460 `False`, it will remove the capability from just the common 7461 capabilities set (if `True`) or just the active capabilities set 7462 (if `False`). In these cases, removing the capability from just 7463 one capability set will not actually remove it in terms of the 7464 `hasCapability` result if it had been present in the other set. 7465 Set `inCommon` to "both" to use the default behavior explicitly. 7466 """ 7467 now = self.getSituation() 7468 if inCommon in ("both", True): 7469 context = now.state['common'] 7470 try: 7471 context['capabilities']['capabilities'].remove(capability) 7472 except KeyError: 7473 pass 7474 elif inCommon in ("both", False): 7475 context = now.state['contexts'][now.state['activeContext']] 7476 try: 7477 context['capabilities']['capabilities'].remove(capability) 7478 except KeyError: 7479 pass 7480 else: 7481 raise ValueError( 7482 f"Invalid inCommon value (must be False, True, or" 7483 f" 'both'; got {repr(inCommon)})." 7484 ) 7485 7486 def tokenCountNow(self, tokenType: base.Token) -> Optional[int]: 7487 """ 7488 Returns the number of tokens the player currently has of a given 7489 type. Returns `None` if the player has never acquired or lost 7490 tokens of that type. 7491 7492 This method adds together tokens from the common and active 7493 focal contexts. 7494 """ 7495 state = self.getSituation().state 7496 commonContext = state['common'] 7497 activeContext = state['contexts'][state['activeContext']] 7498 base = commonContext['capabilities']['tokens'].get(tokenType) 7499 if base is None: 7500 return activeContext['capabilities']['tokens'].get(tokenType) 7501 else: 7502 return base + activeContext['capabilities']['tokens'].get( 7503 tokenType, 7504 0 7505 ) 7506 7507 def adjustTokensNow( 7508 self, 7509 tokenType: base.Token, 7510 amount: int, 7511 inCommon: bool = False 7512 ) -> None: 7513 """ 7514 Modifies the current game state to add the specified number of 7515 `Token`s of the given type to the player's tokens. No changes are 7516 made to the current graph. Reduce the number of tokens by 7517 supplying a negative amount; note that negative token amounts 7518 are possible. 7519 7520 By default, the number of tokens for the current active 7521 `FocalContext` will be adjusted. However, if `inCommon` is set 7522 to `True`, then the number of tokens for the common context will 7523 be adjusted instead. 7524 """ 7525 # TODO: Custom token caps! 7526 state = self.getSituation().state 7527 if inCommon: 7528 context = state['common'] 7529 else: 7530 context = state['contexts'][state['activeContext']] 7531 tokens = context['capabilities']['tokens'] 7532 tokens[tokenType] = tokens.get(tokenType, 0) + amount 7533 7534 def setTokensNow( 7535 self, 7536 tokenType: base.Token, 7537 amount: int, 7538 inCommon: bool = False 7539 ) -> None: 7540 """ 7541 Modifies the current game state to set number of `Token`s of the 7542 given type to a specific amount, regardless of the old value. No 7543 changes are made to the current graph. 7544 7545 By default this sets the number of tokens for the active 7546 `FocalContext`. But if you set `inCommon` to `True`, it will 7547 set the number of tokens in the common context instead. 7548 """ 7549 # TODO: Custom token caps! 7550 state = self.getSituation().state 7551 if inCommon: 7552 context = state['common'] 7553 else: 7554 context = state['contexts'][state['activeContext']] 7555 context['capabilities']['tokens'][tokenType] = amount 7556 7557 def lookupMechanism( 7558 self, 7559 mechanism: base.MechanismName, 7560 step: Optional[int] = None, 7561 where: Union[ 7562 Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]], 7563 Collection[base.AnyDecisionSpecifier], 7564 None 7565 ] = None 7566 ) -> base.MechanismID: 7567 """ 7568 Looks up a mechanism ID by name, in the graph for the specified 7569 step. The `where` argument specifies where to start looking, 7570 which helps disambiguate. It can be a tuple with a decision 7571 specifier and `None` to start from a single decision, or with a 7572 decision specifier and a transition name to start from either 7573 end of that transition. It can also be `None` to look at global 7574 mechanisms and then all decisions directly, although this 7575 increases the chance of a `MechanismCollisionError`. Finally, it 7576 can be some other non-tuple collection of decision specifiers to 7577 start from that set. 7578 7579 If no step is specified, uses the current step. 7580 """ 7581 if step is None: 7582 step = -1 7583 situation = self.getSituation(step) 7584 graph = situation.graph 7585 searchFrom: Collection[base.AnyDecisionSpecifier] 7586 if where is None: 7587 searchFrom = set() 7588 elif isinstance(where, tuple): 7589 if len(where) != 2: 7590 raise ValueError( 7591 f"Mechanism lookup location was a tuple with an" 7592 f" invalid length (must be length-2 if it's a" 7593 f" tuple):\n {repr(where)}" 7594 ) 7595 where = cast( 7596 Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]], 7597 where 7598 ) 7599 if where[1] is None: 7600 searchFrom = {graph.resolveDecision(where[0])} 7601 else: 7602 searchFrom = graph.bothEnds(where[0], where[1]) 7603 else: # must be a collection of specifiers 7604 searchFrom = cast(Collection[base.AnyDecisionSpecifier], where) 7605 return graph.lookupMechanism(searchFrom, mechanism) 7606 7607 def mechanismState( 7608 self, 7609 mechanism: base.AnyMechanismSpecifier, 7610 where: Optional[Set[base.DecisionID]] = None, 7611 step: int = -1 7612 ) -> Optional[base.MechanismState]: 7613 """ 7614 Returns the current state for the specified mechanism (or the 7615 state at the specified step if a step index is given). `where` 7616 may be provided as a set of decision IDs to indicate where to 7617 search for the named mechanism, or a mechanism ID may be provided 7618 in the first place. Mechanism states are properties of a `State` 7619 but are not associated with focal contexts. 7620 """ 7621 situation = self.getSituation(step) 7622 mID = situation.graph.resolveMechanism(mechanism, startFrom=where) 7623 return situation.state['mechanisms'].get( 7624 mID, 7625 base.DEFAULT_MECHANISM_STATE 7626 ) 7627 7628 def setMechanismStateNow( 7629 self, 7630 mechanism: base.AnyMechanismSpecifier, 7631 toState: base.MechanismState, 7632 where: Optional[Set[base.DecisionID]] = None 7633 ) -> None: 7634 """ 7635 Sets the state of the specified mechanism to the specified 7636 state. Mechanisms can only be in one state at once, so this 7637 removes any previous states for that mechanism (note that via 7638 equivalences multiple mechanism states can count as active). 7639 7640 The mechanism can be any kind of mechanism specifier (see 7641 `base.AnyMechanismSpecifier`). If it's not a mechanism ID and 7642 doesn't have its own position information, the 'where' argument 7643 can be used to hint where to search for the mechanism. 7644 """ 7645 now = self.getSituation() 7646 mID = now.graph.resolveMechanism(mechanism, startFrom=where) 7647 if mID is None: 7648 raise MissingMechanismError( 7649 f"Couldn't find mechanism for {repr(mechanism)}." 7650 ) 7651 now.state['mechanisms'][mID] = toState 7652 7653 def skillLevel( 7654 self, 7655 skill: base.Skill, 7656 step: Optional[int] = None 7657 ) -> Optional[base.Level]: 7658 """ 7659 Returns the skill level the player had in a given skill at a 7660 given step, or for the current step if no step is specified. 7661 Returns `None` if the player had never acquired or lost levels 7662 in that skill before the specified step (skill level would count 7663 as 0 in that case). 7664 7665 This method adds together levels from the common and active 7666 focal contexts. 7667 """ 7668 if step is None: 7669 step = -1 7670 state = self.getSituation(step).state 7671 commonContext = state['common'] 7672 activeContext = state['contexts'][state['activeContext']] 7673 base = commonContext['capabilities']['skills'].get(skill) 7674 if base is None: 7675 return activeContext['capabilities']['skills'].get(skill) 7676 else: 7677 return base + activeContext['capabilities']['skills'].get( 7678 skill, 7679 0 7680 ) 7681 7682 def adjustSkillLevelNow( 7683 self, 7684 skill: base.Skill, 7685 levels: base.Level, 7686 inCommon: bool = False 7687 ) -> None: 7688 """ 7689 Modifies the current game state to add the specified number of 7690 `Level`s of the given skill. No changes are made to the current 7691 graph. Reduce the skill level by supplying negative levels; note 7692 that negative skill levels are possible. 7693 7694 By default, the skill level for the current active 7695 `FocalContext` will be adjusted. However, if `inCommon` is set 7696 to `True`, then the skill level for the common context will be 7697 adjusted instead. 7698 """ 7699 # TODO: Custom level caps? 7700 state = self.getSituation().state 7701 if inCommon: 7702 context = state['common'] 7703 else: 7704 context = state['contexts'][state['activeContext']] 7705 skills = context['capabilities']['skills'] 7706 skills[skill] = skills.get(skill, 0) + levels 7707 7708 def setSkillLevelNow( 7709 self, 7710 skill: base.Skill, 7711 level: base.Level, 7712 inCommon: bool = False 7713 ) -> None: 7714 """ 7715 Modifies the current game state to set `Skill` `Level` for the 7716 given skill, regardless of the old value. No changes are made to 7717 the current graph. 7718 7719 By default this sets the skill level for the active 7720 `FocalContext`. But if you set `inCommon` to `True`, it will set 7721 the skill level in the common context instead. 7722 """ 7723 # TODO: Custom level caps? 7724 state = self.getSituation().state 7725 if inCommon: 7726 context = state['common'] 7727 else: 7728 context = state['contexts'][state['activeContext']] 7729 skills = context['capabilities']['skills'] 7730 skills[skill] = level 7731 7732 def updateRequirementNow( 7733 self, 7734 decision: base.AnyDecisionSpecifier, 7735 transition: base.Transition, 7736 requirement: Optional[base.Requirement] 7737 ) -> None: 7738 """ 7739 Updates the requirement for a specific transition in a specific 7740 decision. Use `None` to remove the requirement for that edge. 7741 """ 7742 if requirement is None: 7743 requirement = base.ReqNothing() 7744 self.getSituation().graph.setTransitionRequirement( 7745 decision, 7746 transition, 7747 requirement 7748 ) 7749 7750 def isTraversable( 7751 self, 7752 decision: base.AnyDecisionSpecifier, 7753 transition: base.Transition, 7754 step: int = -1 7755 ) -> bool: 7756 """ 7757 Returns True if the specified transition from the specified 7758 decision had its requirement satisfied by the game state at the 7759 specified step (or at the current step if no step is specified). 7760 Raises an `IndexError` if the specified step doesn't exist, and 7761 a `KeyError` if the decision or transition specified does not 7762 exist in the `DecisionGraph` at that step. 7763 """ 7764 situation = self.getSituation(step) 7765 req = situation.graph.getTransitionRequirement(decision, transition) 7766 ctx = base.contextForTransition(situation, decision, transition) 7767 fromID = situation.graph.resolveDecision(decision) 7768 return ( 7769 req.satisfied(ctx) 7770 and (fromID, transition) not in situation.state['deactivated'] 7771 ) 7772 7773 def applyTransitionEffect( 7774 self, 7775 whichEffect: base.EffectSpecifier, 7776 moveWhich: Optional[base.FocalPointName] = None 7777 ) -> Optional[base.DecisionID]: 7778 """ 7779 Applies an effect attached to a transition, taking charges and 7780 delay into account based on the current `Situation`. 7781 Modifies the effect's trigger count (but may not actually 7782 trigger the effect if the charges and/or delay values indicate 7783 not to; see `base.doTriggerEffect`). 7784 7785 If a specific focal point in a plural-focalized domain is 7786 triggering the effect, the focal point name should be specified 7787 via `moveWhich` so that goto `Effect`s can know which focal 7788 point to move when it's not explicitly specified in the effect. 7789 TODO: Test this! 7790 7791 Returns None most of the time, but if a 'goto', 'bounce', or 7792 'follow' effect was applied, it returns the decision ID for that 7793 effect's destination, which would override a transition's normal 7794 destination. If it returns a destination ID, then the exploration 7795 state will already have been updated to set the position there, 7796 and further position updates are not needed. 7797 7798 Note that transition effects which update active decisions will 7799 also update the exploration status of those decisions to 7800 'exploring' if they had been in an unvisited status (see 7801 `updatePosition` and `hasBeenVisited`). 7802 7803 Note: callers should immediately update situation-based variables 7804 that might have been changes by a 'revert' effect. 7805 """ 7806 now = self.getSituation() 7807 effect, triggerCount = base.doTriggerEffect(now, whichEffect) 7808 if triggerCount is not None: 7809 return self.applyExtraneousEffect( 7810 effect, 7811 where=whichEffect[:2], 7812 moveWhich=moveWhich 7813 ) 7814 else: 7815 return None 7816 7817 def applyExtraneousEffect( 7818 self, 7819 effect: base.Effect, 7820 where: Optional[ 7821 Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]] 7822 ] = None, 7823 moveWhich: Optional[base.FocalPointName] = None, 7824 challengePolicy: base.ChallengePolicy = "specified" 7825 ) -> Optional[base.DecisionID]: 7826 """ 7827 Applies a single extraneous effect to the state & graph, 7828 *without* accounting for charges or delay values, since the 7829 effect is not part of the graph (use `applyTransitionEffect` to 7830 apply effects that are attached to transitions, which is almost 7831 always the function you should be using). An associated 7832 transition for the extraneous effect can be supplied using the 7833 `where` argument, and effects like 'deactivate' and 'edit' will 7834 affect it (but the effect's charges and delay values will still 7835 be ignored). 7836 7837 If the effect would change the destination of a transition, the 7838 altered destination ID is returned: 'bounce' effects return the 7839 provided decision part of `where`, 'goto' effects return their 7840 target, and 'follow' effects return the destination followed to 7841 (possibly via chained follows in the extreme case). In all other 7842 cases, `None` is returned indicating no change to a normal 7843 destination. 7844 7845 If a specific focal point in a plural-focalized domain is 7846 triggering the effect, the focal point name should be specified 7847 via `moveWhich` so that goto `Effect`s can know which focal 7848 point to move when it's not explicitly specified in the effect. 7849 TODO: Test this! 7850 7851 Note that transition effects which update active decisions will 7852 also update the exploration status of those decisions to 7853 'exploring' if they had been in an unvisited status and will 7854 remove any 'unconfirmed' tag they might still have (see 7855 `updatePosition` and `hasBeenVisited`). 7856 7857 The given `challengePolicy` is applied when traversing further 7858 transitions due to 'follow' effects. 7859 7860 Note: Anyone calling `applyExtraneousEffect` should update any 7861 situation-based variables immediately after the call, as a 7862 'revert' effect may have changed the current graph and/or state. 7863 """ 7864 typ = effect['type'] 7865 value = effect['value'] 7866 applyTo = effect['applyTo'] 7867 inCommon = applyTo == 'common' 7868 7869 now = self.getSituation() 7870 7871 if where is not None: 7872 if where[1] is not None: 7873 searchFrom = now.graph.bothEnds(where[0], where[1]) 7874 else: 7875 searchFrom = {now.graph.resolveDecision(where[0])} 7876 else: 7877 searchFrom = None 7878 7879 # Note: Delay and charges are ignored! 7880 7881 if typ in ("gain", "lose"): 7882 value = cast( 7883 Union[ 7884 base.Capability, 7885 Tuple[base.Token, base.TokenCount], 7886 Tuple[Literal['skill'], base.Skill, base.Level], 7887 ], 7888 value 7889 ) 7890 if isinstance(value, base.Capability): 7891 if typ == "gain": 7892 self.gainCapabilityNow(value, inCommon) 7893 else: 7894 self.loseCapabilityNow(value, inCommon) 7895 elif len(value) == 2: # must be a token, amount pair 7896 token, amount = cast( 7897 Tuple[base.Token, base.TokenCount], 7898 value 7899 ) 7900 if typ == "lose": 7901 amount *= -1 7902 self.adjustTokensNow(token, amount, inCommon) 7903 else: # must be a 'skill', skill, level triple 7904 _, skill, levels = cast( 7905 Tuple[Literal['skill'], base.Skill, base.Level], 7906 value 7907 ) 7908 if typ == "lose": 7909 levels *= -1 7910 self.adjustSkillLevelNow(skill, levels, inCommon) 7911 7912 elif typ == "set": 7913 value = cast( 7914 Union[ 7915 Tuple[base.Token, base.TokenCount], 7916 Tuple[base.AnyMechanismSpecifier, base.MechanismState], 7917 Tuple[Literal['skill'], base.Skill, base.Level], 7918 ], 7919 value 7920 ) 7921 if len(value) == 2: # must be a token or mechanism pair 7922 if isinstance(value[1], base.TokenCount): # token 7923 token, amount = cast( 7924 Tuple[base.Token, base.TokenCount], 7925 value 7926 ) 7927 self.setTokensNow(token, amount, inCommon) 7928 else: # mechanism 7929 mechanism, state = cast( 7930 Tuple[ 7931 base.AnyMechanismSpecifier, 7932 base.MechanismState 7933 ], 7934 value 7935 ) 7936 self.setMechanismStateNow(mechanism, state, searchFrom) 7937 else: # must be a 'skill', skill, level triple 7938 _, skill, level = cast( 7939 Tuple[Literal['skill'], base.Skill, base.Level], 7940 value 7941 ) 7942 self.setSkillLevelNow(skill, level, inCommon) 7943 7944 elif typ == "toggle": 7945 # Length-1 list just toggles a capability on/off based on current 7946 # state (not attending to equivalents): 7947 if isinstance(value, List): # capabilities list 7948 value = cast(List[base.Capability], value) 7949 if len(value) == 0: 7950 raise ValueError( 7951 "Toggle effect has empty capabilities list." 7952 ) 7953 if len(value) == 1: 7954 capability = value[0] 7955 if self.hasCapability(capability, inCommon=False): 7956 self.loseCapabilityNow(capability, inCommon=False) 7957 else: 7958 self.gainCapabilityNow(capability) 7959 else: 7960 # Otherwise toggle all powers off, then one on, 7961 # based on the first capability that's currently on. 7962 # Note we do NOT count equivalences. 7963 7964 # Find first capability that's on: 7965 firstIndex: Optional[int] = None 7966 for i, capability in enumerate(value): 7967 if self.hasCapability(capability): 7968 firstIndex = i 7969 break 7970 7971 # Turn them all off: 7972 for capability in value: 7973 self.loseCapabilityNow(capability, inCommon=False) 7974 # TODO: inCommon for the check? 7975 7976 if firstIndex is None: 7977 self.gainCapabilityNow(value[0]) 7978 else: 7979 self.gainCapabilityNow( 7980 value[(firstIndex + 1) % len(value)] 7981 ) 7982 else: # must be a mechanism w/ states list 7983 mechanism, states = cast( 7984 Tuple[ 7985 base.AnyMechanismSpecifier, 7986 List[base.MechanismState] 7987 ], 7988 value 7989 ) 7990 currentState = self.mechanismState(mechanism, where=searchFrom) 7991 if len(states) == 1: 7992 if currentState == states[0]: 7993 # default alternate state 7994 self.setMechanismStateNow( 7995 mechanism, 7996 base.DEFAULT_MECHANISM_STATE, 7997 searchFrom 7998 ) 7999 else: 8000 self.setMechanismStateNow( 8001 mechanism, 8002 states[0], 8003 searchFrom 8004 ) 8005 else: 8006 # Find our position in the list, if any 8007 try: 8008 currentIndex = states.index(cast(str, currentState)) 8009 # Cast here just because we know that None will 8010 # raise a ValueError but we'll catch it, and we 8011 # want to suppress the mypy warning about the 8012 # option 8013 except ValueError: 8014 currentIndex = len(states) - 1 8015 # Set next state in list as current state 8016 nextIndex = (currentIndex + 1) % len(states) 8017 self.setMechanismStateNow( 8018 mechanism, 8019 states[nextIndex], 8020 searchFrom 8021 ) 8022 8023 elif typ == "deactivate": 8024 if where is None or where[1] is None: 8025 raise ValueError( 8026 "Can't apply a deactivate effect without specifying" 8027 " which transition it applies to." 8028 ) 8029 8030 decision, transition = cast( 8031 Tuple[base.AnyDecisionSpecifier, base.Transition], 8032 where 8033 ) 8034 8035 dID = now.graph.resolveDecision(decision) 8036 now.state['deactivated'].add((dID, transition)) 8037 8038 elif typ == "edit": 8039 value = cast(List[List[commands.Command]], value) 8040 # If there are no blocks, do nothing 8041 if len(value) > 0: 8042 # Apply the first block of commands and then rotate the list 8043 scope: commands.Scope = {} 8044 if where is not None: 8045 here: base.DecisionID = now.graph.resolveDecision( 8046 where[0] 8047 ) 8048 outwards: Optional[base.Transition] = where[1] 8049 scope['@'] = here 8050 scope['@t'] = outwards 8051 if outwards is not None: 8052 reciprocal = now.graph.getReciprocal(here, outwards) 8053 destination = now.graph.getDestination(here, outwards) 8054 else: 8055 reciprocal = None 8056 destination = None 8057 scope['@r'] = reciprocal 8058 scope['@d'] = destination 8059 self.runCommandBlock(value[0], scope) 8060 value.append(value.pop(0)) 8061 8062 elif typ == "goto": 8063 if isinstance(value, base.DecisionSpecifier): 8064 target: base.AnyDecisionSpecifier = value 8065 # use moveWhich provided as argument 8066 elif isinstance(value, tuple): 8067 target, moveWhich = cast( 8068 Tuple[base.AnyDecisionSpecifier, base.FocalPointName], 8069 value 8070 ) 8071 else: 8072 target = cast(base.AnyDecisionSpecifier, value) 8073 # use moveWhich provided as argument 8074 8075 destID = now.graph.resolveDecision(target) 8076 base.updatePosition(now, destID, applyTo, moveWhich) 8077 return destID 8078 8079 elif typ == "bounce": 8080 # Just need to let the caller know they should cancel 8081 if where is None: 8082 raise ValueError( 8083 "Can't apply a 'bounce' effect without a position" 8084 " to apply it from." 8085 ) 8086 return now.graph.resolveDecision(where[0]) 8087 8088 elif typ == "follow": 8089 if where is None: 8090 raise ValueError( 8091 f"Can't follow transition {value!r} because there" 8092 f" is no position information when applying the" 8093 f" effect." 8094 ) 8095 if where[1] is not None: 8096 followFrom = now.graph.getDestination(where[0], where[1]) 8097 if followFrom is None: 8098 raise ValueError( 8099 f"Can't follow transition {value!r} because the" 8100 f" position information specifies transition" 8101 f" {where[1]!r} from decision" 8102 f" {now.graph.identityOf(where[0])} but that" 8103 f" transition does not exist." 8104 ) 8105 else: 8106 followFrom = now.graph.resolveDecision(where[0]) 8107 8108 following = cast(base.Transition, value) 8109 8110 followTo = now.graph.getDestination(followFrom, following) 8111 8112 if followTo is None: 8113 raise ValueError( 8114 f"Can't follow transition {following!r} because" 8115 f" that transition doesn't exist at the specified" 8116 f" destination {now.graph.identityOf(followFrom)}." 8117 ) 8118 8119 if self.isTraversable(followFrom, following): # skip if not 8120 # Perform initial position update before following new 8121 # transition: 8122 base.updatePosition( 8123 now, 8124 followFrom, 8125 applyTo, 8126 moveWhich 8127 ) 8128 8129 # Apply consequences of followed transition 8130 fullFollowTo = self.applyTransitionConsequence( 8131 followFrom, 8132 following, 8133 moveWhich, 8134 challengePolicy 8135 ) 8136 8137 # Now update to end of followed transition 8138 if fullFollowTo is None: 8139 base.updatePosition( 8140 now, 8141 followTo, 8142 applyTo, 8143 moveWhich 8144 ) 8145 fullFollowTo = followTo 8146 8147 # Skip the normal update: we've taken care of that plus more 8148 return fullFollowTo 8149 else: 8150 # Normal position updates still applies since follow 8151 # transition wasn't possible 8152 return None 8153 8154 elif typ == "save": 8155 assert isinstance(value, base.SaveSlot) 8156 now.saves[value] = copy.deepcopy((now.graph, now.state)) 8157 8158 else: 8159 raise ValueError(f"Invalid effect type {typ!r}.") 8160 8161 return None # default return value if we didn't return above 8162 8163 def applyExtraneousConsequence( 8164 self, 8165 consequence: base.Consequence, 8166 where: Optional[ 8167 Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]] 8168 ] = None, 8169 moveWhich: Optional[base.FocalPointName] = None 8170 ) -> Optional[base.DecisionID]: 8171 """ 8172 Applies an extraneous consequence not associated with a 8173 transition. Unlike `applyTransitionConsequence`, the provided 8174 `base.Consequence` must already have observed outcomes (see 8175 `base.observeChallengeOutcomes`). Returns the decision ID for a 8176 decision implied by a goto, follow, or bounce effect, or `None` 8177 if no effect implies a destination. 8178 8179 The `where` and `moveWhich` optional arguments specify which 8180 decision and/or transition to use as the application position, 8181 and/or which focal point to move. This affects mechanism lookup 8182 as well as the end position when 'follow' effects are used. 8183 Specifically: 8184 8185 - A 'follow' trigger will search for transitions to follow from 8186 the destination of the specified transition, or if only a 8187 decision was supplied, from that decision. 8188 - Mechanism lookups will start with both ends of the specified 8189 transition as their search field (or with just the specified 8190 decision if no transition is included). 8191 8192 'bounce' effects will cause an error unless position information 8193 is provided, and will set the position to the base decision 8194 provided in `where`. 8195 8196 Note: callers should update any situation-based variables 8197 immediately after calling this as a 'revert' effect could change 8198 the current graph and/or state and other changes could get lost 8199 if they get applied to a stale graph/state. 8200 8201 # TODO: Examples for goto and follow effects. 8202 """ 8203 now = self.getSituation() 8204 searchFrom = set() 8205 if where is not None: 8206 if where[1] is not None: 8207 searchFrom = now.graph.bothEnds(where[0], where[1]) 8208 else: 8209 searchFrom = {now.graph.resolveDecision(where[0])} 8210 8211 context = base.RequirementContext( 8212 state=now.state, 8213 graph=now.graph, 8214 searchFrom=searchFrom 8215 ) 8216 8217 effectIndices = base.observedEffects(context, consequence) 8218 destID = None 8219 for index in effectIndices: 8220 effect = base.consequencePart(consequence, index) 8221 if not isinstance(effect, dict) or 'value' not in effect: 8222 raise RuntimeError( 8223 f"Invalid effect index {index}: Consequence part at" 8224 f" that index is not an Effect. Got:\n{effect}" 8225 ) 8226 effect = cast(base.Effect, effect) 8227 destID = self.applyExtraneousEffect( 8228 effect, 8229 where, 8230 moveWhich 8231 ) 8232 # technically this variable is not used later in this 8233 # function, but the `applyExtraneousEffect` call means it 8234 # needs an update, so we're doing that in case someone later 8235 # adds code to this function that uses 'now' after this 8236 # point. 8237 now = self.getSituation() 8238 8239 return destID 8240 8241 def applyTransitionConsequence( 8242 self, 8243 decision: base.AnyDecisionSpecifier, 8244 transition: base.AnyTransition, 8245 moveWhich: Optional[base.FocalPointName] = None, 8246 policy: base.ChallengePolicy = "specified", 8247 fromIndex: Optional[int] = None, 8248 toIndex: Optional[int] = None 8249 ) -> Optional[base.DecisionID]: 8250 """ 8251 Applies the effects of the specified transition to the current 8252 graph and state, possibly overriding observed outcomes using 8253 outcomes specified as part of a `base.TransitionWithOutcomes`. 8254 8255 The `where` and `moveWhich` function serve the same purpose as 8256 for `applyExtraneousEffect`. If `where` is `None`, then the 8257 effects will be applied as extraneous effects, meaning that 8258 their delay and charges values will be ignored and their trigger 8259 count will not be tracked. If `where` is supplied 8260 8261 Returns either None to indicate that the position update for the 8262 transition should apply as usual, or a decision ID indicating 8263 another destination which has already been applied by a 8264 transition effect. 8265 8266 If `fromIndex` and/or `toIndex` are specified, then only effects 8267 which have indices between those two (inclusive) will be 8268 applied, and other effects will neither apply nor be updated in 8269 any way. Note that `onlyPart` does not override the challenge 8270 policy: if the effects in the specified part are not applied due 8271 to a challenge outcome, they still won't happen, including 8272 challenge outcomes outside of that part. Also, outcomes for 8273 challenges of the entire consequence are re-observed if the 8274 challenge policy implies it. 8275 8276 Note: Anyone calling this should update any situation-based 8277 variables immediately after the call, as a 'revert' effect may 8278 have changed the current graph and/or state. 8279 """ 8280 now = self.getSituation() 8281 dID = now.graph.resolveDecision(decision) 8282 8283 transitionName, outcomes = base.nameAndOutcomes(transition) 8284 8285 searchFrom = set() 8286 searchFrom = now.graph.bothEnds(dID, transitionName) 8287 8288 context = base.RequirementContext( 8289 state=now.state, 8290 graph=now.graph, 8291 searchFrom=searchFrom 8292 ) 8293 8294 consequence = now.graph.getConsequence(dID, transitionName) 8295 8296 # Make sure that challenge outcomes are known 8297 if policy != "specified": 8298 base.resetChallengeOutcomes(consequence) 8299 useUp = outcomes[:] 8300 base.observeChallengeOutcomes( 8301 context, 8302 consequence, 8303 location=searchFrom, 8304 policy=policy, 8305 knownOutcomes=useUp 8306 ) 8307 if len(useUp) > 0: 8308 raise ValueError( 8309 f"More outcomes specified than challenges observed in" 8310 f" consequence:\n{consequence}" 8311 f"\nRemaining outcomes:\n{useUp}" 8312 ) 8313 8314 # Figure out which effects apply, and apply each of them 8315 effectIndices = base.observedEffects(context, consequence) 8316 if fromIndex is None: 8317 fromIndex = 0 8318 8319 altDest = None 8320 for index in effectIndices: 8321 if ( 8322 index >= fromIndex 8323 and (toIndex is None or index <= toIndex) 8324 ): 8325 thisDest = self.applyTransitionEffect( 8326 (dID, transitionName, index), 8327 moveWhich 8328 ) 8329 if thisDest is not None: 8330 altDest = thisDest 8331 # TODO: What if this updates state with 'revert' to a 8332 # graph that doesn't contain the same effects? 8333 # TODO: Update 'now' and 'context'?! 8334 return altDest 8335 8336 def allDecisions(self) -> List[base.DecisionID]: 8337 """ 8338 Returns the list of all decisions which existed at any point 8339 within the exploration. Example: 8340 8341 >>> ex = DiscreteExploration() 8342 >>> ex.start('A') 8343 0 8344 >>> ex.observe('A', 'right') 8345 1 8346 >>> ex.explore('right', 'B', 'left') 8347 1 8348 >>> ex.observe('B', 'right') 8349 2 8350 >>> ex.allDecisions() # 'A', 'B', and the unnamed 'right of B' 8351 [0, 1, 2] 8352 """ 8353 seen = set() 8354 result = [] 8355 for situation in self: 8356 for decision in situation.graph: 8357 if decision not in seen: 8358 result.append(decision) 8359 seen.add(decision) 8360 8361 return result 8362 8363 def allExploredDecisions(self) -> List[base.DecisionID]: 8364 """ 8365 Returns the list of all decisions which existed at any point 8366 within the exploration, excluding decisions whose highest 8367 exploration status was `noticed` or lower. May still include 8368 decisions which don't exist in the final situation's graph due to 8369 things like decision merging. Example: 8370 8371 >>> ex = DiscreteExploration() 8372 >>> ex.start('A') 8373 0 8374 >>> ex.observe('A', 'right') 8375 1 8376 >>> ex.explore('right', 'B', 'left') 8377 1 8378 >>> ex.observe('B', 'right') 8379 2 8380 >>> graph = ex.getSituation().graph 8381 >>> graph.addDecision('C') # add isolated decision; doesn't set status 8382 3 8383 >>> ex.hasBeenVisited('C') 8384 False 8385 >>> ex.allExploredDecisions() 8386 [0, 1] 8387 >>> ex.setExplorationStatus('C', 'exploring') 8388 >>> ex.allExploredDecisions() # 2 is the decision right from 'B' 8389 [0, 1, 3] 8390 >>> ex.setExplorationStatus('A', 'explored') 8391 >>> ex.allExploredDecisions() 8392 [0, 1, 3] 8393 >>> ex.setExplorationStatus('A', 'unknown') 8394 >>> # remains visisted in an earlier step 8395 >>> ex.allExploredDecisions() 8396 [0, 1, 3] 8397 >>> ex.setExplorationStatus('C', 'unknown') # not explored earlier 8398 >>> ex.allExploredDecisions() # 2 is the decision right from 'B' 8399 [0, 1] 8400 """ 8401 seen = set() 8402 result = [] 8403 for situation in self: 8404 graph = situation.graph 8405 for decision in graph: 8406 if ( 8407 decision not in seen 8408 and base.hasBeenVisited(situation, decision) 8409 ): 8410 result.append(decision) 8411 seen.add(decision) 8412 8413 return result 8414 8415 def allVisitedDecisions(self) -> List[base.DecisionID]: 8416 """ 8417 Returns the list of all decisions which existed at any point 8418 within the exploration and which were visited at least once. 8419 Usually all of these will be present in the final situation's 8420 graph, but sometimes merging or other factors means there might 8421 be some that won't be. Being present on the game state's 'active' 8422 list in a step for its domain is what counts as "being visited," 8423 which means that nodes which were passed through directly via a 8424 'follow' effect won't be counted, for example. 8425 8426 This should usually correspond with the absence of the 8427 'unconfirmed' tag. 8428 8429 Example: 8430 8431 >>> ex = DiscreteExploration() 8432 >>> ex.start('A') 8433 0 8434 >>> ex.observe('A', 'right') 8435 1 8436 >>> ex.explore('right', 'B', 'left') 8437 1 8438 >>> ex.observe('B', 'right') 8439 2 8440 >>> ex.getSituation().graph.addDecision('C') # add isolated decision 8441 3 8442 >>> av = ex.allVisitedDecisions() 8443 >>> av 8444 [0, 1] 8445 >>> all( # no decisions in the 'visited' list are tagged 8446 ... 'unconfirmed' not in ex.getSituation().graph.decisionTags(d) 8447 ... for d in av 8448 ... ) 8449 True 8450 >>> graph = ex.getSituation().graph 8451 >>> 'unconfirmed' in graph.decisionTags(0) 8452 False 8453 >>> 'unconfirmed' in graph.decisionTags(1) 8454 False 8455 >>> 'unconfirmed' in graph.decisionTags(2) 8456 True 8457 >>> 'unconfirmed' in graph.decisionTags(3) # not tagged; not explored 8458 False 8459 """ 8460 seen = set() 8461 result = [] 8462 for step in range(len(self)): 8463 active = self.getActiveDecisions(step) 8464 for dID in active: 8465 if dID not in seen: 8466 result.append(dID) 8467 seen.add(dID) 8468 8469 return result 8470 8471 def start( 8472 self, 8473 decision: base.AnyDecisionSpecifier, 8474 startCapabilities: Optional[base.CapabilitySet] = None, 8475 setMechanismStates: Optional[ 8476 Dict[base.MechanismID, base.MechanismState] 8477 ] = None, 8478 setCustomState: Optional[dict] = None, 8479 decisionType: base.DecisionType = "imposed" 8480 ) -> base.DecisionID: 8481 """ 8482 Sets the initial position information for a newly-relevant 8483 domain for the current focal context. Creates a new decision 8484 if the decision is specified by name or `DecisionSpecifier` and 8485 that decision doesn't already exist. Returns the decision ID for 8486 the newly-placed decision (or for the specified decision if it 8487 already existed). 8488 8489 Raises a `BadStart` error if the current focal context already 8490 has position information for the specified domain. 8491 8492 - The given `startCapabilities` replaces any existing 8493 capabilities for the current focal context, although you can 8494 leave it as the default `None` to avoid that and retain any 8495 capabilities that have been set up already. 8496 - The given `setMechanismStates` and `setCustomState` 8497 dictionaries override all previous mechanism states & custom 8498 states in the new situation. Leave these as the default 8499 `None` to maintain those states. 8500 - If created, the decision will be placed in the DEFAULT_DOMAIN 8501 domain unless it's specified as a `base.DecisionSpecifier` 8502 with a domain part, in which case that domain is used. 8503 - If specified as a `base.DecisionSpecifier` with a zone part 8504 and a new decision needs to be created, the decision will be 8505 added to that zone, creating it at level 0 if necessary, 8506 although otherwise no zone information will be changed. 8507 - Resets the decision type to "pending" and the action taken to 8508 `None`. Sets the decision type of the previous situation to 8509 'imposed' (or the specified `decisionType`) and sets an 8510 appropriate 'start' action for that situation. 8511 - Tags the step with 'start'. 8512 - Even in a plural- or spreading-focalized domain, you still need 8513 to pick one decision to start at. 8514 """ 8515 now = self.getSituation() 8516 8517 startID = now.graph.getDecision(decision) 8518 zone = None 8519 domain = base.DEFAULT_DOMAIN 8520 if startID is None: 8521 if isinstance(decision, base.DecisionID): 8522 raise MissingDecisionError( 8523 f"Cannot start at decision {decision} because no" 8524 f" decision with that ID exists. Supply a name or" 8525 f" DecisionSpecifier if you need the start decision" 8526 f" to be created automatically." 8527 ) 8528 elif isinstance(decision, base.DecisionName): 8529 decision = base.DecisionSpecifier( 8530 domain=None, 8531 zone=None, 8532 name=decision 8533 ) 8534 startID = now.graph.addDecision( 8535 decision.name, 8536 domain=decision.domain 8537 ) 8538 zone = decision.zone 8539 if decision.domain is not None: 8540 domain = decision.domain 8541 8542 if zone is not None: 8543 if now.graph.getZoneInfo(zone) is None: 8544 now.graph.createZone(zone, 0) 8545 now.graph.addDecisionToZone(startID, zone) 8546 8547 action: base.ExplorationAction = ( 8548 'start', 8549 startID, 8550 startID, 8551 domain, 8552 startCapabilities, 8553 setMechanismStates, 8554 setCustomState 8555 ) 8556 8557 self.advanceSituation(action, decisionType) 8558 8559 return startID 8560 8561 def hasBeenVisited( 8562 self, 8563 decision: base.AnyDecisionSpecifier, 8564 step: int = -1 8565 ): 8566 """ 8567 Returns whether or not the specified decision has been visited in 8568 the specified step (default current step). 8569 """ 8570 return base.hasBeenVisited(self.getSituation(step), decision) 8571 8572 def setExplorationStatus( 8573 self, 8574 decision: base.AnyDecisionSpecifier, 8575 status: base.ExplorationStatus, 8576 upgradeOnly: bool = False 8577 ): 8578 """ 8579 Updates the current exploration status of a specific decision in 8580 the current situation. If `upgradeOnly` is true (default is 8581 `False` then the update will only apply if the new exploration 8582 status counts as 'more-explored' than the old one (see 8583 `base.moreExplored`). 8584 """ 8585 base.setExplorationStatus( 8586 self.getSituation(), 8587 decision, 8588 status, 8589 upgradeOnly 8590 ) 8591 8592 def getExplorationStatus( 8593 self, 8594 decision: base.AnyDecisionSpecifier, 8595 step: int = -1 8596 ): 8597 """ 8598 Returns the exploration status of the specified decision at the 8599 specified step (default is last step). Decisions whose 8600 exploration status has never been set will have a default status 8601 of 'unknown'. 8602 """ 8603 situation = self.getSituation(step) 8604 dID = situation.graph.resolveDecision(decision) 8605 return situation.state['exploration'].get(dID, 'unknown') 8606 8607 def deduceTransitionDetailsAtStep( 8608 self, 8609 step: int, 8610 transition: base.Transition, 8611 fromDecision: Optional[base.AnyDecisionSpecifier] = None, 8612 whichFocus: Optional[base.FocalPointSpecifier] = None, 8613 inCommon: Union[bool, Literal["auto"]] = "auto" 8614 ) -> Tuple[ 8615 base.ContextSpecifier, 8616 base.DecisionID, 8617 base.DecisionID, 8618 Optional[base.FocalPointSpecifier] 8619 ]: 8620 """ 8621 Given just a transition name which the player intends to take in 8622 a specific step, deduces the `ContextSpecifier` for which 8623 context should be updated, the source and destination 8624 `DecisionID`s for the transition, and if the destination 8625 decision's domain is plural-focalized, the `FocalPointName` 8626 specifying which focal point should be moved. 8627 8628 Because many of those things are ambiguous, you may get an 8629 `AmbiguousTransitionError` when things are underspecified, and 8630 there are options for specifying some of the extra information 8631 directly: 8632 8633 - `fromDecision` may be used to specify the source decision. 8634 - `whichFocus` may be used to specify the focal point (within a 8635 particular context/domain) being updated. When focal point 8636 ambiguity remains and this is unspecified, the 8637 alphabetically-earliest relevant focal point will be used 8638 (either among all focal points which activate the source 8639 decision, if there are any, or among all focal points for 8640 the entire domain of the destination decision). 8641 - `inCommon` (a `ContextSpecifier`) may be used to specify which 8642 context to update. The default of "auto" will cause the 8643 active context to be selected unless it does not activate 8644 the source decision, in which case the common context will 8645 be selected. 8646 8647 A `MissingDecisionError` will be raised if there are no current 8648 active decisions (e.g., before `start` has been called), and a 8649 `MissingTransitionError` will be raised if the listed transition 8650 does not exist from any active decision (or from the specified 8651 decision if `fromDecision` is used). 8652 """ 8653 now = self.getSituation(step) 8654 active = self.getActiveDecisions(step) 8655 if len(active) == 0: 8656 raise MissingDecisionError( 8657 f"There are no active decisions from which transition" 8658 f" {repr(transition)} could be taken at step {step}." 8659 ) 8660 8661 # All source/destination decision pairs for transitions with the 8662 # given transition name. 8663 allDecisionPairs: Dict[base.DecisionID, base.DecisionID] = {} 8664 8665 # TODO: When should we be trimming the active decisions to match 8666 # any alterations to the graph? 8667 for dID in active: 8668 outgoing = now.graph.destinationsFrom(dID) 8669 if transition in outgoing: 8670 allDecisionPairs[dID] = outgoing[transition] 8671 8672 if len(allDecisionPairs) == 0: 8673 raise MissingTransitionError( 8674 f"No transitions named {repr(transition)} are outgoing" 8675 f" from active decisions at step {step}." 8676 f"\nActive decisions are:" 8677 f"\n{now.graph.namesListing(active)}" 8678 ) 8679 8680 if ( 8681 fromDecision is not None 8682 and fromDecision not in allDecisionPairs 8683 ): 8684 raise MissingTransitionError( 8685 f"{fromDecision} was specified as the source decision" 8686 f" for traversing transition {repr(transition)} but" 8687 f" there is no transition of that name from that" 8688 f" decision at step {step}." 8689 f"\nValid source decisions are:" 8690 f"\n{now.graph.namesListing(allDecisionPairs)}" 8691 ) 8692 elif fromDecision is not None: 8693 fromID = now.graph.resolveDecision(fromDecision) 8694 destID = allDecisionPairs[fromID] 8695 fromDomain = now.graph.domainFor(fromID) 8696 elif len(allDecisionPairs) == 1: 8697 fromID, destID = list(allDecisionPairs.items())[0] 8698 fromDomain = now.graph.domainFor(fromID) 8699 else: 8700 fromID = None 8701 destID = None 8702 fromDomain = None 8703 # Still ambiguous; resolve this below 8704 8705 # Use whichFocus if provided 8706 if whichFocus is not None: 8707 # Type/value check for whichFocus 8708 if ( 8709 not isinstance(whichFocus, tuple) 8710 or len(whichFocus) != 3 8711 or whichFocus[0] not in ("active", "common") 8712 or not isinstance(whichFocus[1], base.Domain) 8713 or not isinstance(whichFocus[2], base.FocalPointName) 8714 ): 8715 raise ValueError( 8716 f"Invalid whichFocus value {repr(whichFocus)}." 8717 f"\nMust be a length-3 tuple with 'active' or 'common'" 8718 f" as the first element, a Domain as the second" 8719 f" element, and a FocalPointName as the third" 8720 f" element." 8721 ) 8722 8723 # Resolve focal point specified 8724 fromID = base.resolvePosition( 8725 now, 8726 whichFocus 8727 ) 8728 if fromID is None: 8729 raise MissingTransitionError( 8730 f"Focal point {repr(whichFocus)} was specified as" 8731 f" the transition source, but that focal point does" 8732 f" not have a position." 8733 ) 8734 else: 8735 destID = now.graph.destination(fromID, transition) 8736 fromDomain = now.graph.domainFor(fromID) 8737 8738 elif fromID is None: # whichFocus is None, so it can't disambiguate 8739 raise AmbiguousTransitionError( 8740 f"Transition {repr(transition)} was selected for" 8741 f" disambiguation, but there are multiple transitions" 8742 f" with that name from currently-active decisions, and" 8743 f" neither fromDecision nor whichFocus adequately" 8744 f" disambiguates the specific transition taken." 8745 f"\nValid source decisions at step {step} are:" 8746 f"\n{now.graph.namesListing(allDecisionPairs)}" 8747 ) 8748 8749 # At this point, fromID, destID, and fromDomain have 8750 # been resolved. 8751 if fromID is None or destID is None or fromDomain is None: 8752 raise RuntimeError( 8753 f"One of fromID, destID, or fromDomain was None after" 8754 f" disambiguation was finished:" 8755 f"\nfromID: {fromID}, destID: {destID}, fromDomain:" 8756 f" {repr(fromDomain)}" 8757 ) 8758 8759 # Now figure out which context activated the source so we know 8760 # which focal point we're moving: 8761 context = self.getActiveContext() 8762 active = base.activeDecisionSet(context) 8763 using: base.ContextSpecifier = "active" 8764 if fromID not in active: 8765 context = self.getCommonContext(step) 8766 using = "common" 8767 8768 destDomain = now.graph.domainFor(destID) 8769 if ( 8770 whichFocus is None 8771 and base.getDomainFocalization(context, destDomain) == 'plural' 8772 ): 8773 # Need to figure out which focal point is moving; use the 8774 # alphabetically earliest one that's positioned at the 8775 # fromID, or just the earliest one overall if none of them 8776 # are there. 8777 contextFocalPoints: Dict[ 8778 base.FocalPointName, 8779 Optional[base.DecisionID] 8780 ] = cast( 8781 Dict[base.FocalPointName, Optional[base.DecisionID]], 8782 context['activeDecisions'][destDomain] 8783 ) 8784 if not isinstance(contextFocalPoints, dict): 8785 raise RuntimeError( 8786 f"Active decisions specifier for domain" 8787 f" {repr(destDomain)} with plural focalization has" 8788 f" a non-dictionary value." 8789 ) 8790 8791 if fromDomain == destDomain: 8792 focalCandidates = [ 8793 fp 8794 for fp, pos in contextFocalPoints.items() 8795 if pos == fromID 8796 ] 8797 else: 8798 focalCandidates = list(contextFocalPoints) 8799 8800 whichFocus = (using, destDomain, min(focalCandidates)) 8801 8802 # Now whichFocus has been set if it wasn't already specified; 8803 # might still be None if it's not relevant. 8804 return (using, fromID, destID, whichFocus) 8805 8806 def advanceSituation( 8807 self, 8808 action: base.ExplorationAction, 8809 decisionType: base.DecisionType = "active", 8810 challengePolicy: base.ChallengePolicy = "specified" 8811 ) -> Tuple[base.Situation, Set[base.DecisionID]]: 8812 """ 8813 Given an `ExplorationAction`, sets that as the action taken in 8814 the current situation, and adds a new situation with the results 8815 of that action. A `DoubleActionError` will be raised if the 8816 current situation already has an action specified, and/or has a 8817 decision type other than 'pending'. By default the type of the 8818 decision will be 'active' but another `DecisionType` can be 8819 specified via the `decisionType` parameter. 8820 8821 If the action specified is `('noAction',)`, then the new 8822 situation will be a copy of the old one; this represents waiting 8823 or being at an ending (a decision type other than 'pending' 8824 should be used). 8825 8826 Although `None` can appear as the action entry in situations 8827 with pending decisions, you cannot call `advanceSituation` with 8828 `None` as the action. 8829 8830 If the action includes taking a transition whose requirements 8831 are not satisfied, the transition will still be taken (and any 8832 consequences applied) but a `TransitionBlockedWarning` will be 8833 issued. 8834 8835 A `ChallengePolicy` may be specified, the default is 'specified' 8836 which requires that outcomes are pre-specified. If any other 8837 policy is set, the challenge outcomes will be reset before 8838 re-resolving them according to the provided policy. 8839 8840 The new situation will have decision type 'pending' and `None` 8841 as the action. 8842 8843 The new situation created as a result of the action is returned, 8844 along with the set of destination decision IDs, including 8845 possibly a modified destination via 'bounce', 'goto', and/or 8846 'follow' effects. For actions that don't have a destination, the 8847 second part of the returned tuple will be an empty set. Multiple 8848 IDs may be in the set when using a start action in a plural- or 8849 spreading-focalized domain, for example. 8850 8851 If the action updates active decisions (including via transition 8852 effects) this will also update the exploration status of those 8853 decisions to 'exploring' if they had been in an unvisited 8854 status (see `updatePosition` and `hasBeenVisited`). This 8855 includes decisions traveled through but not ultimately arrived 8856 at via 'follow' effects. 8857 8858 If any decisions are active in the `ENDINGS_DOMAIN`, attempting 8859 to 'warp', 'explore', 'take', or 'start' will raise an 8860 `InvalidActionError`. 8861 """ 8862 now = self.getSituation() 8863 if now.type != 'pending' or now.action is not None: 8864 raise DoubleActionError( 8865 f"Attempted to take action {repr(action)} at step" 8866 f" {len(self) - 1}, but an action and/or decision type" 8867 f" had already been specified:" 8868 f"\nAction: {repr(now.action)}" 8869 f"\nType: {repr(now.type)}" 8870 ) 8871 8872 # Update the now situation to add in the decision type and 8873 # action taken: 8874 revised = base.Situation( 8875 now.graph, 8876 now.state, 8877 decisionType, 8878 action, 8879 now.saves, 8880 now.tags, 8881 now.annotations 8882 ) 8883 self.situations[-1] = revised 8884 8885 # Separate update process when reverting (this branch returns) 8886 if ( 8887 action is not None 8888 and isinstance(action, tuple) 8889 and len(action) == 3 8890 and action[0] == 'revertTo' 8891 and isinstance(action[1], base.SaveSlot) 8892 and isinstance(action[2], set) 8893 and all(isinstance(x, str) for x in action[2]) 8894 ): 8895 _, slot, aspects = action 8896 if slot not in now.saves: 8897 raise KeyError( 8898 f"Cannot load save slot {slot!r} because no save" 8899 f" data has been established for that slot." 8900 ) 8901 load = now.saves[slot] 8902 rGraph, rState = base.revertedState( 8903 (now.graph, now.state), 8904 load, 8905 aspects 8906 ) 8907 reverted = base.Situation( 8908 graph=rGraph, 8909 state=rState, 8910 type='pending', 8911 action=None, 8912 saves=copy.deepcopy(now.saves), 8913 tags={}, 8914 annotations=[] 8915 ) 8916 self.situations.append(reverted) 8917 # Apply any active triggers (edits reverted) 8918 self.applyActiveTriggers() 8919 # Figure out destinations set to return 8920 newDestinations = set() 8921 newPr = rState['primaryDecision'] 8922 if newPr is not None: 8923 newDestinations.add(newPr) 8924 return (reverted, newDestinations) 8925 8926 # TODO: These deep copies are expensive time-wise. Can we avoid 8927 # them? Probably not. 8928 newGraph = copy.deepcopy(now.graph) 8929 newState = copy.deepcopy(now.state) 8930 newSaves = copy.copy(now.saves) # a shallow copy 8931 newTags: Dict[base.Tag, base.TagValue] = {} 8932 newAnnotations: List[base.Annotation] = [] 8933 updated = base.Situation( 8934 graph=newGraph, 8935 state=newState, 8936 type='pending', 8937 action=None, 8938 saves=newSaves, 8939 tags=newTags, 8940 annotations=newAnnotations 8941 ) 8942 8943 targetContext: base.FocalContext 8944 8945 # Now that action effects have been imprinted into the updated 8946 # situation, append it to our situations list 8947 self.situations.append(updated) 8948 8949 # Figure out effects of the action: 8950 if action is None: 8951 raise InvalidActionError( 8952 "None cannot be used as an action when advancing the" 8953 " situation." 8954 ) 8955 8956 aLen = len(action) 8957 8958 destIDs = set() 8959 8960 if ( 8961 action[0] in ('start', 'take', 'explore', 'warp') 8962 and any( 8963 newGraph.domainFor(d) == ENDINGS_DOMAIN 8964 for d in self.getActiveDecisions() 8965 ) 8966 ): 8967 activeEndings = [ 8968 d 8969 for d in self.getActiveDecisions() 8970 if newGraph.domainFor(d) == ENDINGS_DOMAIN 8971 ] 8972 raise InvalidActionError( 8973 f"Attempted to {action[0]!r} while an ending was" 8974 f" active. Active endings are:" 8975 f"\n{newGraph.namesListing(activeEndings)}" 8976 ) 8977 8978 if action == ('noAction',): 8979 # No updates needed 8980 pass 8981 8982 elif ( 8983 not isinstance(action, tuple) 8984 or (action[0] not in get_args(base.ExplorationActionType)) 8985 or not (2 <= aLen <= 7) 8986 ): 8987 raise InvalidActionError( 8988 f"Invalid ExplorationAction tuple (must be a tuple that" 8989 f" starts with an ExplorationActionType and has 2-6" 8990 f" entries if it's not ('noAction',)):" 8991 f"\n{repr(action)}" 8992 ) 8993 8994 elif action[0] == 'start': 8995 ( 8996 _, 8997 positionSpecifier, 8998 primary, 8999 domain, 9000 capabilities, 9001 mechanismStates, 9002 customState 9003 ) = cast( 9004 Tuple[ 9005 Literal['start'], 9006 Union[ 9007 base.DecisionID, 9008 Dict[base.FocalPointName, base.DecisionID], 9009 Set[base.DecisionID] 9010 ], 9011 Optional[base.DecisionID], 9012 base.Domain, 9013 Optional[base.CapabilitySet], 9014 Optional[Dict[base.MechanismID, base.MechanismState]], 9015 Optional[dict] 9016 ], 9017 action 9018 ) 9019 targetContext = newState['contexts'][ 9020 newState['activeContext'] 9021 ] 9022 9023 targetFocalization = base.getDomainFocalization( 9024 targetContext, 9025 domain 9026 ) # sets up 'singular' as default if 9027 9028 # Check if there are any already-active decisions. 9029 if targetContext['activeDecisions'][domain] is not None: 9030 raise BadStart( 9031 f"Cannot start in domain {repr(domain)} because" 9032 f" that domain already has a position. 'start' may" 9033 f" only be used with domains that don't yet have" 9034 f" any position information." 9035 ) 9036 9037 # Make the domain active 9038 if domain not in targetContext['activeDomains']: 9039 targetContext['activeDomains'].add(domain) 9040 9041 # Check position info matches focalization type and update 9042 # exploration statuses 9043 if isinstance(positionSpecifier, base.DecisionID): 9044 if targetFocalization != 'singular': 9045 raise BadStart( 9046 f"Invalid position specifier" 9047 f" {repr(positionSpecifier)} (type" 9048 f" {type(positionSpecifier)}). Domain" 9049 f" {repr(domain)} has {targetFocalization}" 9050 f" focalization." 9051 ) 9052 base.setExplorationStatus( 9053 updated, 9054 positionSpecifier, 9055 'exploring', 9056 upgradeOnly=True 9057 ) 9058 destIDs.add(positionSpecifier) 9059 elif isinstance(positionSpecifier, dict): 9060 if targetFocalization != 'plural': 9061 raise BadStart( 9062 f"Invalid position specifier" 9063 f" {repr(positionSpecifier)} (type" 9064 f" {type(positionSpecifier)}). Domain" 9065 f" {repr(domain)} has {targetFocalization}" 9066 f" focalization." 9067 ) 9068 destIDs |= set(positionSpecifier.values()) 9069 elif isinstance(positionSpecifier, set): 9070 if targetFocalization != 'spreading': 9071 raise BadStart( 9072 f"Invalid position specifier" 9073 f" {repr(positionSpecifier)} (type" 9074 f" {type(positionSpecifier)}). Domain" 9075 f" {repr(domain)} has {targetFocalization}" 9076 f" focalization." 9077 ) 9078 destIDs |= positionSpecifier 9079 else: 9080 raise TypeError( 9081 f"Invalid position specifier" 9082 f" {repr(positionSpecifier)} (type" 9083 f" {type(positionSpecifier)}). It must be a" 9084 f" DecisionID, a dictionary from FocalPointNames to" 9085 f" DecisionIDs, or a set of DecisionIDs, according" 9086 f" to the focalization of the relevant domain." 9087 ) 9088 9089 # Put specified position(s) in place 9090 # TODO: This cast is really silly... 9091 targetContext['activeDecisions'][domain] = cast( 9092 Union[ 9093 None, 9094 base.DecisionID, 9095 Dict[base.FocalPointName, Optional[base.DecisionID]], 9096 Set[base.DecisionID] 9097 ], 9098 positionSpecifier 9099 ) 9100 9101 # Set primary decision 9102 newState['primaryDecision'] = primary 9103 9104 # Set capabilities 9105 if capabilities is not None: 9106 targetContext['capabilities'] = capabilities 9107 9108 # Set mechanism states 9109 if mechanismStates is not None: 9110 newState['mechanisms'] = mechanismStates 9111 9112 # Set custom state 9113 if customState is not None: 9114 newState['custom'] = customState 9115 9116 elif action[0] in ('explore', 'take', 'warp'): # similar handling 9117 assert ( 9118 len(action) == 3 9119 or len(action) == 4 9120 or len(action) == 6 9121 or len(action) == 7 9122 ) 9123 # Set up necessary variables 9124 cSpec: base.ContextSpecifier = "active" 9125 fromID: Optional[base.DecisionID] = None 9126 takeTransition: Optional[base.Transition] = None 9127 outcomes: List[bool] = [] 9128 destID: base.DecisionID # No starting value as it's not optional 9129 moveInDomain: Optional[base.Domain] = None 9130 moveWhich: Optional[base.FocalPointName] = None 9131 9132 # Figure out target context 9133 if isinstance(action[1], str): 9134 if action[1] not in get_args(base.ContextSpecifier): 9135 raise InvalidActionError( 9136 f"Action specifies {repr(action[1])} context," 9137 f" but that's not a valid context specifier." 9138 f" The valid options are:" 9139 f"\n{repr(get_args(base.ContextSpecifier))}" 9140 ) 9141 else: 9142 cSpec = cast(base.ContextSpecifier, action[1]) 9143 else: # Must be a `FocalPointSpecifier` 9144 cSpec, moveInDomain, moveWhich = cast( 9145 base.FocalPointSpecifier, 9146 action[1] 9147 ) 9148 assert moveInDomain is not None 9149 9150 # Grab target context to work in 9151 if cSpec == 'common': 9152 targetContext = newState['common'] 9153 else: 9154 targetContext = newState['contexts'][ 9155 newState['activeContext'] 9156 ] 9157 9158 # Check focalization of the target domain 9159 if moveInDomain is not None: 9160 fType = base.getDomainFocalization( 9161 targetContext, 9162 moveInDomain 9163 ) 9164 if ( 9165 ( 9166 isinstance(action[1], str) 9167 and fType == 'plural' 9168 ) or ( 9169 not isinstance(action[1], str) 9170 and fType != 'plural' 9171 ) 9172 ): 9173 raise ImpossibleActionError( 9174 f"Invalid ExplorationAction (moves in" 9175 f" plural-focalized domains must include a" 9176 f" FocalPointSpecifier, while moves in" 9177 f" non-plural-focalized domains must not." 9178 f" Domain {repr(moveInDomain)} is" 9179 f" {fType}-focalized):" 9180 f"\n{repr(action)}" 9181 ) 9182 9183 if action[0] == "warp": 9184 # It's a warp, so destination is specified directly 9185 if not isinstance(action[2], base.DecisionID): 9186 raise TypeError( 9187 f"Invalid ExplorationAction tuple (third part" 9188 f" must be a decision ID for 'warp' actions):" 9189 f"\n{repr(action)}" 9190 ) 9191 else: 9192 destID = cast(base.DecisionID, action[2]) 9193 9194 elif aLen == 4 or aLen == 7: 9195 # direct 'take' or 'explore' 9196 fromID = cast(base.DecisionID, action[2]) 9197 takeTransition, outcomes = cast( 9198 base.TransitionWithOutcomes, 9199 action[3] # type: ignore [misc] 9200 ) 9201 if ( 9202 not isinstance(fromID, base.DecisionID) 9203 or not isinstance(takeTransition, base.Transition) 9204 ): 9205 raise InvalidActionError( 9206 f"Invalid ExplorationAction tuple (for 'take' or" 9207 f" 'explore', if the length is 4/7, parts 2-4" 9208 f" must be a context specifier, a decision ID, and a" 9209 f" transition name. Got:" 9210 f"\n{repr(action)}" 9211 ) 9212 9213 try: 9214 destID = newGraph.destination(fromID, takeTransition) 9215 except MissingDecisionError: 9216 raise ImpossibleActionError( 9217 f"Invalid ExplorationAction: move from decision" 9218 f" {fromID} is invalid because there is no" 9219 f" decision with that ID in the current" 9220 f" graph." 9221 f"\nValid decisions are:" 9222 f"\n{newGraph.namesListing(newGraph)}" 9223 ) 9224 except MissingTransitionError: 9225 valid = newGraph.destinationsFrom(fromID) 9226 listing = newGraph.destinationsListing(valid) 9227 raise ImpossibleActionError( 9228 f"Invalid ExplorationAction: move from decision" 9229 f" {newGraph.identityOf(fromID)}" 9230 f" along transition {repr(takeTransition)} is" 9231 f" invalid because there is no such transition" 9232 f" at that decision." 9233 f"\nValid transitions there are:" 9234 f"\n{listing}" 9235 ) 9236 targetActive = targetContext['activeDecisions'] 9237 if moveInDomain is not None: 9238 activeInDomain = targetActive[moveInDomain] 9239 if ( 9240 ( 9241 isinstance(activeInDomain, base.DecisionID) 9242 and fromID != activeInDomain 9243 ) 9244 or ( 9245 isinstance(activeInDomain, set) 9246 and fromID not in activeInDomain 9247 ) 9248 or ( 9249 isinstance(activeInDomain, dict) 9250 and fromID not in activeInDomain.values() 9251 ) 9252 ): 9253 raise ImpossibleActionError( 9254 f"Invalid ExplorationAction: move from" 9255 f" decision {fromID} is invalid because" 9256 f" that decision is not active in domain" 9257 f" {repr(moveInDomain)} in the current" 9258 f" graph." 9259 f"\nValid decisions are:" 9260 f"\n{newGraph.namesListing(newGraph)}" 9261 ) 9262 9263 elif aLen == 3 or aLen == 6: 9264 # 'take' or 'explore' focal point 9265 # We know that moveInDomain is not None here. 9266 assert moveInDomain is not None 9267 if not isinstance(action[2], base.Transition): 9268 raise InvalidActionError( 9269 f"Invalid ExplorationAction tuple (for 'take'" 9270 f" actions if the second part is a" 9271 f" FocalPointSpecifier the third part must be a" 9272 f" transition name):" 9273 f"\n{repr(action)}" 9274 ) 9275 9276 takeTransition, outcomes = cast( 9277 base.TransitionWithOutcomes, 9278 action[2] 9279 ) 9280 targetActive = targetContext['activeDecisions'] 9281 activeInDomain = cast( 9282 Dict[base.FocalPointName, Optional[base.DecisionID]], 9283 targetActive[moveInDomain] 9284 ) 9285 if ( 9286 moveInDomain is not None 9287 and ( 9288 not isinstance(activeInDomain, dict) 9289 or moveWhich not in activeInDomain 9290 ) 9291 ): 9292 raise ImpossibleActionError( 9293 f"Invalid ExplorationAction: move of focal" 9294 f" point {repr(moveWhich)} in domain" 9295 f" {repr(moveInDomain)} is invalid because" 9296 f" that domain does not have a focal point" 9297 f" with that name." 9298 ) 9299 fromID = activeInDomain[moveWhich] 9300 if fromID is None: 9301 raise ImpossibleActionError( 9302 f"Invalid ExplorationAction: move of focal" 9303 f" point {repr(moveWhich)} in domain" 9304 f" {repr(moveInDomain)} is invalid because" 9305 f" that focal point does not have a position" 9306 f" at this step." 9307 ) 9308 try: 9309 destID = newGraph.destination(fromID, takeTransition) 9310 except MissingDecisionError: 9311 raise ImpossibleActionError( 9312 f"Invalid exploration state: focal point" 9313 f" {repr(moveWhich)} in domain" 9314 f" {repr(moveInDomain)} specifies decision" 9315 f" {fromID} as the current position, but" 9316 f" that decision does not exist!" 9317 ) 9318 except MissingTransitionError: 9319 valid = newGraph.destinationsFrom(fromID) 9320 listing = newGraph.destinationsListing(valid) 9321 raise ImpossibleActionError( 9322 f"Invalid ExplorationAction: move of focal" 9323 f" point {repr(moveWhich)} in domain" 9324 f" {repr(moveInDomain)} along transition" 9325 f" {repr(takeTransition)} is invalid because" 9326 f" that focal point is at decision" 9327 f" {newGraph.identityOf(fromID)} and that" 9328 f" decision does not have an outgoing" 9329 f" transition with that name.\nValid" 9330 f" transitions from that decision are:" 9331 f"\n{listing}" 9332 ) 9333 9334 else: 9335 raise InvalidActionError( 9336 f"Invalid ExplorationAction: unrecognized" 9337 f" 'explore', 'take' or 'warp' format:" 9338 f"\n{action}" 9339 ) 9340 9341 # If we're exploring, update information for the destination 9342 if action[0] == 'explore': 9343 zone = cast( 9344 Union[base.Zone, None, type[base.DefaultZone]], 9345 action[-1] 9346 ) 9347 recipName = cast(Optional[base.Transition], action[-2]) 9348 destOrName = cast( 9349 Union[base.DecisionName, base.DecisionID, None], 9350 action[-3] 9351 ) 9352 if isinstance(destOrName, base.DecisionID): 9353 destID = destOrName 9354 9355 if fromID is None or takeTransition is None: 9356 raise ImpossibleActionError( 9357 f"Invalid ExplorationAction: exploration" 9358 f" has unclear origin decision or transition." 9359 f" Got:\n{action}" 9360 ) 9361 9362 currentDest = newGraph.destination(fromID, takeTransition) 9363 if not newGraph.isConfirmed(currentDest): 9364 newGraph.replaceUnconfirmed( 9365 fromID, 9366 takeTransition, 9367 destOrName, 9368 recipName, 9369 placeInZone=zone, 9370 forceNew=not isinstance(destOrName, base.DecisionID) 9371 ) 9372 else: 9373 # Otherwise, since the destination already existed 9374 # and was hooked up at the right decision, no graph 9375 # edits need to be made, unless we need to rename 9376 # the reciprocal. 9377 # TODO: Do we care about zones here? 9378 if recipName is not None: 9379 oldReciprocal = newGraph.getReciprocal( 9380 fromID, 9381 takeTransition 9382 ) 9383 if ( 9384 oldReciprocal is not None 9385 and oldReciprocal != recipName 9386 ): 9387 newGraph.addTransition( 9388 destID, 9389 recipName, 9390 fromID, 9391 None 9392 ) 9393 newGraph.setReciprocal( 9394 destID, 9395 recipName, 9396 takeTransition, 9397 setBoth=True 9398 ) 9399 newGraph.mergeTransitions( 9400 destID, 9401 oldReciprocal, 9402 recipName 9403 ) 9404 9405 # If we are moving along a transition, check requirements 9406 # and apply transition effects *before* updating our 9407 # position, and check that they don't cancel the normal 9408 # position update 9409 finalDest = None 9410 if takeTransition is not None: 9411 assert fromID is not None # both or neither 9412 if not self.isTraversable(fromID, takeTransition): 9413 req = now.graph.getTransitionRequirement( 9414 fromID, 9415 takeTransition 9416 ) 9417 # TODO: Alter warning message if transition is 9418 # deactivated vs. requirement not satisfied 9419 warnings.warn( 9420 ( 9421 f"The requirements for transition" 9422 f" {takeTransition!r} from decision" 9423 f" {now.graph.identityOf(fromID)} are" 9424 f" not met at step {len(self) - 1} (or that" 9425 f" transition has been deactivated):\n{req}" 9426 ), 9427 TransitionBlockedWarning 9428 ) 9429 9430 # Apply transition consequences to our new state and 9431 # figure out if we need to skip our normal update or not 9432 finalDest = self.applyTransitionConsequence( 9433 fromID, 9434 (takeTransition, outcomes), 9435 moveWhich, 9436 challengePolicy 9437 ) 9438 9439 # Check moveInDomain 9440 destDomain = newGraph.domainFor(destID) 9441 if moveInDomain is not None and moveInDomain != destDomain: 9442 raise ImpossibleActionError( 9443 f"Invalid ExplorationAction: move specified" 9444 f" domain {repr(moveInDomain)} as the domain of" 9445 f" the focal point to move, but the destination" 9446 f" of the move is {now.graph.identityOf(destID)}" 9447 f" which is in domain {repr(destDomain)}, so focal" 9448 f" point {repr(moveWhich)} cannot be moved there." 9449 ) 9450 9451 # Now that we know where we're going, update position 9452 # information (assuming it wasn't already set): 9453 if finalDest is None: 9454 finalDest = destID 9455 base.updatePosition( 9456 updated, 9457 destID, 9458 cSpec, 9459 moveWhich 9460 ) 9461 9462 destIDs.add(finalDest) 9463 9464 elif action[0] == "focus": 9465 # Figure out target context 9466 action = cast( 9467 Tuple[ 9468 Literal['focus'], 9469 base.ContextSpecifier, 9470 Set[base.Domain], 9471 Set[base.Domain] 9472 ], 9473 action 9474 ) 9475 contextSpecifier: base.ContextSpecifier = action[1] 9476 if contextSpecifier == 'common': 9477 targetContext = newState['common'] 9478 else: 9479 targetContext = newState['contexts'][ 9480 newState['activeContext'] 9481 ] 9482 9483 # Just need to swap out active domains 9484 goingOut, comingIn = cast( 9485 Tuple[Set[base.Domain], Set[base.Domain]], 9486 action[2:] 9487 ) 9488 if ( 9489 not isinstance(goingOut, set) 9490 or not isinstance(comingIn, set) 9491 or not all(isinstance(d, base.Domain) for d in goingOut) 9492 or not all(isinstance(d, base.Domain) for d in comingIn) 9493 ): 9494 raise InvalidActionError( 9495 f"Invalid ExplorationAction tuple (must have 4" 9496 f" parts if the first part is 'focus' and" 9497 f" the third and fourth parts must be sets of" 9498 f" domains):" 9499 f"\n{repr(action)}" 9500 ) 9501 activeSet = targetContext['activeDomains'] 9502 for dom in goingOut: 9503 try: 9504 activeSet.remove(dom) 9505 except KeyError: 9506 warnings.warn( 9507 ( 9508 f"Domain {repr(dom)} was deactivated at" 9509 f" step {len(self)} but it was already" 9510 f" inactive at that point." 9511 ), 9512 InactiveDomainWarning 9513 ) 9514 # TODO: Also warn for doubly-activated domains? 9515 activeSet |= comingIn 9516 9517 # destIDs remains empty in this case 9518 9519 elif action[0] == 'swap': # update which `FocalContext` is active 9520 newContext = cast(base.FocalContextName, action[1]) 9521 if newContext not in newState['contexts']: 9522 raise MissingFocalContextError( 9523 f"'swap' action with target {repr(newContext)} is" 9524 f" invalid because no context with that name" 9525 f" exists." 9526 ) 9527 newState['activeContext'] = newContext 9528 9529 # destIDs remains empty in this case 9530 9531 elif action[0] == 'focalize': # create new `FocalContext` 9532 newContext = cast(base.FocalContextName, action[1]) 9533 if newContext in newState['contexts']: 9534 raise FocalContextCollisionError( 9535 f"'focalize' action with target {repr(newContext)}" 9536 f" is invalid because a context with that name" 9537 f" already exists." 9538 ) 9539 newState['contexts'][newContext] = base.emptyFocalContext() 9540 newState['activeContext'] = newContext 9541 9542 # destIDs remains empty in this case 9543 9544 # revertTo is handled above 9545 else: 9546 raise InvalidActionError( 9547 f"Invalid ExplorationAction tuple (first item must be" 9548 f" an ExplorationActionType, and tuple must be length-1" 9549 f" if the action type is 'noAction'):" 9550 f"\n{repr(action)}" 9551 ) 9552 9553 # Apply any active triggers 9554 followTo = self.applyActiveTriggers() 9555 if followTo is not None: 9556 destIDs.add(followTo) 9557 # TODO: Re-work to work with multiple position updates in 9558 # different focal contexts, domains, and/or for different 9559 # focal points in plural-focalized domains. 9560 9561 return (updated, destIDs) 9562 9563 def applyActiveTriggers(self) -> Optional[base.DecisionID]: 9564 """ 9565 Finds all actions with the 'trigger' tag attached to currently 9566 active decisions, and applies their effects if their requirements 9567 are met (ordered by decision-ID with ties broken alphabetically 9568 by action name). 9569 9570 'bounce', 'goto' and 'follow' effects may apply. However, any 9571 new triggers that would be activated because of decisions 9572 reached by such effects will not apply. Note that 'bounce' 9573 effects update position to the decision where the action was 9574 attached, which is usually a no-op. This function returns the 9575 decision ID of the decision reached by the last decision-moving 9576 effect applied, or `None` if no such effects triggered. 9577 9578 TODO: What about situations where positions are updated in 9579 multiple domains or multiple foal points in a plural domain are 9580 independently updated? 9581 9582 TODO: Tests for this! 9583 """ 9584 active = self.getActiveDecisions() 9585 now = self.getSituation() 9586 graph = now.graph 9587 finalFollow = None 9588 for decision in sorted(active): 9589 for action in graph.decisionActions(decision): 9590 if ( 9591 'trigger' in graph.transitionTags(decision, action) 9592 and self.isTraversable(decision, action) 9593 ): 9594 followTo = self.applyTransitionConsequence( 9595 decision, 9596 action 9597 ) 9598 if followTo is not None: 9599 # TODO: How will triggers interact with 9600 # plural-focalized domains? Probably need to fix 9601 # this to detect moveWhich based on which focal 9602 # points are at the decision where the transition 9603 # is, and then apply this to each of them? 9604 base.updatePosition(now, followTo) 9605 finalFollow = followTo 9606 9607 return finalFollow 9608 9609 def explore( 9610 self, 9611 transition: base.AnyTransition, 9612 destination: Union[base.DecisionName, base.DecisionID, None], 9613 reciprocal: Optional[base.Transition] = None, 9614 zone: Union[ 9615 base.Zone, 9616 type[base.DefaultZone], 9617 None 9618 ] = base.DefaultZone, 9619 fromDecision: Optional[base.AnyDecisionSpecifier] = None, 9620 whichFocus: Optional[base.FocalPointSpecifier] = None, 9621 inCommon: Union[bool, Literal["auto"]] = "auto", 9622 decisionType: base.DecisionType = "active", 9623 challengePolicy: base.ChallengePolicy = "specified" 9624 ) -> base.DecisionID: 9625 """ 9626 Adds a new situation to the exploration representing the 9627 traversal of the specified transition (possibly with outcomes 9628 specified for challenges among that transitions consequences). 9629 Uses `deduceTransitionDetailsAtStep` to figure out from the 9630 transition name which specific transition is taken (and which 9631 focal point is updated if necessary). This uses the 9632 `fromDecision`, `whichFocus`, and `inCommon` optional 9633 parameters, and also determines whether to update the common or 9634 the active `FocalContext`. Sets the exploration status of the 9635 decision explored to 'exploring'. Returns the decision ID for 9636 the destination reached, accounting for goto/bounce/follow 9637 effects that might have triggered. 9638 9639 The `destination` will be used to name the newly-explored 9640 decision, except when it's a `DecisionID`, in which case that 9641 decision must be unvisited, and we'll connect the specified 9642 transition to that decision. 9643 9644 The focalization of the destination domain in the context to be 9645 updated determines how active decisions are changed: 9646 9647 - If the destination domain is focalized as 'single', then in 9648 the subsequent `Situation`, the destination decision will 9649 become the single active decision in that domain. 9650 - If it's focalized as 'plural', then one of the 9651 `FocalPointName`s for that domain will be moved to activate 9652 that decision; which one can be specified using `whichFocus` 9653 or if left unspecified, will be deduced: if the starting 9654 decision is in the same domain, then the 9655 alphabetically-earliest focal point which is at the starting 9656 decision will be moved. If the starting position is in a 9657 different domain, then the alphabetically earliest focal 9658 point among all focal points in the destination domain will 9659 be moved. 9660 - If it's focalized as 'spreading', then the destination 9661 decision will be added to the set of active decisions in 9662 that domain, without removing any. 9663 9664 The transition named must have been pointing to an unvisited 9665 decision (see `hasBeenVisited`), and the name of that decision 9666 will be updated if a `destination` value is given (a 9667 `DecisionCollisionWarning` will be issued if the destination 9668 name is a duplicate of another name in the graph, although this 9669 is not an error). Additionally: 9670 9671 - If a `reciprocal` name is specified, the reciprocal transition 9672 will be renamed using that name, or created with that name if 9673 it didn't already exist. If reciprocal is left as `None` (the 9674 default) then no change will be made to the reciprocal 9675 transition, and it will not be created if it doesn't exist. 9676 - If a `zone` is specified, the newly-explored decision will be 9677 added to that zone (and that zone will be created at level 0 9678 if it didn't already exist). If `zone` is set to `None` then 9679 it will not be added to any new zones. If `zone` is left as 9680 the default (the `DefaultZone` class) then the explored 9681 decision will be added to each zone that the decision it was 9682 explored from is a part of. If a zone needs to be created, 9683 that zone will be added as a sub-zone of each zone which is a 9684 parent of a zone that directly contains the origin decision. 9685 - An `ExplorationStatusError` will be raised if the specified 9686 transition leads to a decision whose `ExplorationStatus` is 9687 'exploring' or higher (i.e., `hasBeenVisited`). (Use 9688 `returnTo` instead to adjust things when a transition to an 9689 unknown destination turns out to lead to an already-known 9690 destination.) 9691 - A `TransitionBlockedWarning` will be issued if the specified 9692 transition is not traversable given the current game state 9693 (but in that last case the step will still be taken). 9694 - By default, the decision type for the new step will be 9695 'active', but a `decisionType` value can be specified to 9696 override that. 9697 - By default, the 'mostLikely' `ChallengePolicy` will be used to 9698 resolve challenges in the consequence of the transition 9699 taken, but an alternate policy can be supplied using the 9700 `challengePolicy` argument. 9701 """ 9702 now = self.getSituation() 9703 9704 transitionName, outcomes = base.nameAndOutcomes(transition) 9705 9706 # Deduce transition details from the name + optional specifiers 9707 ( 9708 using, 9709 fromID, 9710 destID, 9711 whichFocus 9712 ) = self.deduceTransitionDetailsAtStep( 9713 -1, 9714 transitionName, 9715 fromDecision, 9716 whichFocus, 9717 inCommon 9718 ) 9719 9720 # Issue a warning if the destination name is already in use 9721 if destination is not None: 9722 if isinstance(destination, base.DecisionName): 9723 try: 9724 existingID = now.graph.resolveDecision(destination) 9725 collision = existingID != destID 9726 except MissingDecisionError: 9727 collision = False 9728 except AmbiguousDecisionSpecifierError: 9729 collision = True 9730 9731 if collision and WARN_OF_NAME_COLLISIONS: 9732 warnings.warn( 9733 ( 9734 f"The destination name {repr(destination)} is" 9735 f" already in use when exploring transition" 9736 f" {repr(transition)} from decision" 9737 f" {now.graph.identityOf(fromID)} at step" 9738 f" {len(self) - 1}." 9739 ), 9740 DecisionCollisionWarning 9741 ) 9742 9743 # TODO: Different terminology for "exploration state above 9744 # noticed" vs. "DG thinks it's been visited"... 9745 if ( 9746 self.hasBeenVisited(destID) 9747 ): 9748 raise ExplorationStatusError( 9749 f"Cannot explore to decision" 9750 f" {now.graph.identityOf(destID)} because it has" 9751 f" already been visited. Use returnTo instead of" 9752 f" explore when discovering a connection back to a" 9753 f" previously-explored decision." 9754 ) 9755 9756 if ( 9757 isinstance(destination, base.DecisionID) 9758 and self.hasBeenVisited(destination) 9759 ): 9760 raise ExplorationStatusError( 9761 f"Cannot explore to decision" 9762 f" {now.graph.identityOf(destination)} because it has" 9763 f" already been visited. Use returnTo instead of" 9764 f" explore when discovering a connection back to a" 9765 f" previously-explored decision." 9766 ) 9767 9768 actionTaken: base.ExplorationAction = ( 9769 'explore', 9770 using, 9771 fromID, 9772 (transitionName, outcomes), 9773 destination, 9774 reciprocal, 9775 zone 9776 ) 9777 if whichFocus is not None: 9778 # A move-from-specific-focal-point action 9779 actionTaken = ( 9780 'explore', 9781 whichFocus, 9782 (transitionName, outcomes), 9783 destination, 9784 reciprocal, 9785 zone 9786 ) 9787 9788 # Advance the situation, applying transition effects and 9789 # updating the destination decision. 9790 _, finalDest = self.advanceSituation( 9791 actionTaken, 9792 decisionType, 9793 challengePolicy 9794 ) 9795 9796 # TODO: Is this assertion always valid? 9797 assert len(finalDest) == 1 9798 return next(x for x in finalDest) 9799 9800 def returnTo( 9801 self, 9802 transition: base.AnyTransition, 9803 destination: base.AnyDecisionSpecifier, 9804 reciprocal: Optional[base.Transition] = None, 9805 fromDecision: Optional[base.AnyDecisionSpecifier] = None, 9806 whichFocus: Optional[base.FocalPointSpecifier] = None, 9807 inCommon: Union[bool, Literal["auto"]] = "auto", 9808 decisionType: base.DecisionType = "active", 9809 challengePolicy: base.ChallengePolicy = "specified" 9810 ) -> base.DecisionID: 9811 """ 9812 Adds a new graph to the exploration that replaces the given 9813 transition at the current position (which must lead to an unknown 9814 node, or a `MissingDecisionError` will result). The new 9815 transition will connect back to the specified destination, which 9816 must already exist (or a different `ValueError` will be raised). 9817 Returns the decision ID for the destination reached. 9818 9819 Deduces transition details using the optional `fromDecision`, 9820 `whichFocus`, and `inCommon` arguments in addition to the 9821 `transition` value; see `deduceTransitionDetailsAtStep`. 9822 9823 If a `reciprocal` transition is specified, that transition must 9824 either not already exist in the destination decision or lead to 9825 an unknown region; it will be replaced (or added) as an edge 9826 leading back to the current position. 9827 9828 The `decisionType` and `challengePolicy` optional arguments are 9829 used for `advanceSituation`. 9830 9831 A `TransitionBlockedWarning` will be issued if the requirements 9832 for the transition are not met, but the step will still be taken. 9833 Raises a `MissingDecisionError` if there is no current 9834 transition. 9835 """ 9836 now = self.getSituation() 9837 9838 transitionName, outcomes = base.nameAndOutcomes(transition) 9839 9840 # Deduce transition details from the name + optional specifiers 9841 ( 9842 using, 9843 fromID, 9844 destID, 9845 whichFocus 9846 ) = self.deduceTransitionDetailsAtStep( 9847 -1, 9848 transitionName, 9849 fromDecision, 9850 whichFocus, 9851 inCommon 9852 ) 9853 9854 # Replace with connection to existing destination 9855 destID = now.graph.resolveDecision(destination) 9856 if not self.hasBeenVisited(destID): 9857 raise ExplorationStatusError( 9858 f"Cannot return to decision" 9859 f" {now.graph.identityOf(destID)} because it has NOT" 9860 f" already been at least partially explored. Use" 9861 f" explore instead of returnTo when discovering a" 9862 f" connection to a previously-unexplored decision." 9863 ) 9864 9865 now.graph.replaceUnconfirmed( 9866 fromID, 9867 transitionName, 9868 destID, 9869 reciprocal 9870 ) 9871 9872 # A move-from-decision action 9873 actionTaken: base.ExplorationAction = ( 9874 'take', 9875 using, 9876 fromID, 9877 (transitionName, outcomes) 9878 ) 9879 if whichFocus is not None: 9880 # A move-from-specific-focal-point action 9881 actionTaken = ('take', whichFocus, (transitionName, outcomes)) 9882 9883 # Next, advance the situation, applying transition effects 9884 _, finalDest = self.advanceSituation( 9885 actionTaken, 9886 decisionType, 9887 challengePolicy 9888 ) 9889 9890 assert len(finalDest) == 1 9891 return next(x for x in finalDest) 9892 9893 def takeAction( 9894 self, 9895 action: base.AnyTransition, 9896 requires: Optional[base.Requirement] = None, 9897 consequence: Optional[base.Consequence] = None, 9898 fromDecision: Optional[base.AnyDecisionSpecifier] = None, 9899 whichFocus: Optional[base.FocalPointSpecifier] = None, 9900 inCommon: Union[bool, Literal["auto"]] = "auto", 9901 decisionType: base.DecisionType = "active", 9902 challengePolicy: base.ChallengePolicy = "specified" 9903 ) -> base.DecisionID: 9904 """ 9905 Adds a new graph to the exploration based on taking the given 9906 action, which must be a self-transition in the graph. If the 9907 action does not already exist in the graph, it will be created. 9908 Either way if requirements and/or a consequence are supplied, 9909 the requirements and consequence of the action will be updated 9910 to match them, and those are the requirements/consequence that 9911 will count. 9912 9913 Returns the decision ID for the decision reached, which normally 9914 is the same action you were just at, but which might be altered 9915 by goto, bounce, and/or follow effects. 9916 9917 Issues a `TransitionBlockedWarning` if the current game state 9918 doesn't satisfy the requirements for the action. 9919 9920 The `fromDecision`, `whichFocus`, and `inCommon` arguments are 9921 used for `deduceTransitionDetailsAtStep`, while `decisionType` 9922 and `challengePolicy` are used for `advanceSituation`. 9923 9924 When an action is being created, `fromDecision` (or 9925 `whichFocus`) must be specified, since the source decision won't 9926 be deducible from the transition name. Note that if a transition 9927 with the given name exists from *any* active decision, it will 9928 be used instead of creating a new action (possibly resulting in 9929 an error if it's not a self-loop transition). Also, you may get 9930 an `AmbiguousTransitionError` if several transitions with that 9931 name exist; in that case use `fromDecision` and/or `whichFocus` 9932 to disambiguate. 9933 """ 9934 now = self.getSituation() 9935 graph = now.graph 9936 9937 actionName, outcomes = base.nameAndOutcomes(action) 9938 9939 try: 9940 ( 9941 using, 9942 fromID, 9943 destID, 9944 whichFocus 9945 ) = self.deduceTransitionDetailsAtStep( 9946 -1, 9947 actionName, 9948 fromDecision, 9949 whichFocus, 9950 inCommon 9951 ) 9952 9953 if destID != fromID: 9954 raise ValueError( 9955 f"Cannot take action {repr(action)} because it's a" 9956 f" transition to another decision, not an action" 9957 f" (use explore, returnTo, and/or retrace instead)." 9958 ) 9959 9960 except MissingTransitionError: 9961 using = 'active' 9962 if inCommon is True: 9963 using = 'common' 9964 9965 if fromDecision is not None: 9966 fromID = graph.resolveDecision(fromDecision) 9967 elif whichFocus is not None: 9968 maybeFromID = base.resolvePosition(now, whichFocus) 9969 if maybeFromID is None: 9970 raise MissingDecisionError( 9971 f"Focal point {repr(whichFocus)} was specified" 9972 f" in takeAction but that focal point doesn't" 9973 f" have a position." 9974 ) 9975 else: 9976 fromID = maybeFromID 9977 else: 9978 raise AmbiguousTransitionError( 9979 f"Taking action {repr(action)} is ambiguous because" 9980 f" the source decision has not been specified via" 9981 f" either fromDecision or whichFocus, and we" 9982 f" couldn't find an existing action with that name." 9983 ) 9984 9985 # Since the action doesn't exist, add it: 9986 graph.addAction(fromID, actionName, requires, consequence) 9987 9988 # Update the transition requirement/consequence if requested 9989 # (before the action is taken) 9990 if requires is not None: 9991 graph.setTransitionRequirement(fromID, actionName, requires) 9992 if consequence is not None: 9993 graph.setConsequence(fromID, actionName, consequence) 9994 9995 # A move-from-decision action 9996 actionTaken: base.ExplorationAction = ( 9997 'take', 9998 using, 9999 fromID, 10000 (actionName, outcomes) 10001 ) 10002 if whichFocus is not None: 10003 # A move-from-specific-focal-point action 10004 actionTaken = ('take', whichFocus, (actionName, outcomes)) 10005 10006 _, finalDest = self.advanceSituation( 10007 actionTaken, 10008 decisionType, 10009 challengePolicy 10010 ) 10011 10012 assert len(finalDest) in (0, 1) 10013 if len(finalDest) == 1: 10014 return next(x for x in finalDest) 10015 else: 10016 return fromID 10017 10018 def retrace( 10019 self, 10020 transition: base.AnyTransition, 10021 fromDecision: Optional[base.AnyDecisionSpecifier] = None, 10022 whichFocus: Optional[base.FocalPointSpecifier] = None, 10023 inCommon: Union[bool, Literal["auto"]] = "auto", 10024 decisionType: base.DecisionType = "active", 10025 challengePolicy: base.ChallengePolicy = "specified" 10026 ) -> base.DecisionID: 10027 """ 10028 Adds a new graph to the exploration based on taking the given 10029 transition, which must already exist and which must not lead to 10030 an unknown region. Returns the ID of the destination decision, 10031 accounting for goto, bounce, and/or follow effects. 10032 10033 Issues a `TransitionBlockedWarning` if the current game state 10034 doesn't satisfy the requirements for the transition. 10035 10036 The `fromDecision`, `whichFocus`, and `inCommon` arguments are 10037 used for `deduceTransitionDetailsAtStep`, while `decisionType` 10038 and `challengePolicy` are used for `advanceSituation`. 10039 """ 10040 now = self.getSituation() 10041 10042 transitionName, outcomes = base.nameAndOutcomes(transition) 10043 10044 ( 10045 using, 10046 fromID, 10047 destID, 10048 whichFocus 10049 ) = self.deduceTransitionDetailsAtStep( 10050 -1, 10051 transitionName, 10052 fromDecision, 10053 whichFocus, 10054 inCommon 10055 ) 10056 10057 visited = self.hasBeenVisited(destID) 10058 confirmed = now.graph.isConfirmed(destID) 10059 if not confirmed: 10060 raise ExplorationStatusError( 10061 f"Cannot retrace transition {transition!r} from" 10062 f" decision {now.graph.identityOf(fromID)} because it" 10063 f" leads to an unconfirmed decision.\nUse" 10064 f" `DiscreteExploration.explore` and provide" 10065 f" destination decision details instead." 10066 ) 10067 if not visited: 10068 raise ExplorationStatusError( 10069 f"Cannot retrace transition {transition!r} from" 10070 f" decision {now.graph.identityOf(fromID)} because it" 10071 f" leads to an unvisited decision.\nUse" 10072 f" `DiscreteExploration.explore` and provide" 10073 f" destination decision details instead." 10074 ) 10075 10076 # A move-from-decision action 10077 actionTaken: base.ExplorationAction = ( 10078 'take', 10079 using, 10080 fromID, 10081 (transitionName, outcomes) 10082 ) 10083 if whichFocus is not None: 10084 # A move-from-specific-focal-point action 10085 actionTaken = ('take', whichFocus, (transitionName, outcomes)) 10086 10087 _, finalDest = self.advanceSituation( 10088 actionTaken, 10089 decisionType, 10090 challengePolicy 10091 ) 10092 10093 assert len(finalDest) == 1 10094 return next(x for x in finalDest) 10095 10096 def warp( 10097 self, 10098 destination: base.AnyDecisionSpecifier, 10099 consequence: Optional[base.Consequence] = None, 10100 domain: Optional[base.Domain] = None, 10101 zone: Union[ 10102 base.Zone, 10103 type[base.DefaultZone], 10104 None 10105 ] = base.DefaultZone, 10106 whichFocus: Optional[base.FocalPointSpecifier] = None, 10107 inCommon: Union[bool] = False, 10108 decisionType: base.DecisionType = "active", 10109 challengePolicy: base.ChallengePolicy = "specified" 10110 ) -> base.DecisionID: 10111 """ 10112 Adds a new graph to the exploration that's a copy of the current 10113 graph, with the position updated to be at the destination without 10114 actually creating a transition from the old position to the new 10115 one. Returns the ID of the decision warped to (accounting for 10116 any goto or follow effects triggered). 10117 10118 Any provided consequences are applied, but are not associated 10119 with any transition (so any delays and charges are ignored, and 10120 'bounce' effects don't actually cancel the warp). 'goto' or 10121 'follow' effects might change the warp destination; 'follow' 10122 effects take the original destination as their starting point. 10123 Any mechanisms mentioned in extra consequences will be found 10124 based on the destination. Outcomes in supplied challenges should 10125 be pre-specified, or else they will be resolved with the 10126 `challengePolicy`. 10127 10128 `whichFocus` may be specified when the destination domain's 10129 focalization is 'plural' but for 'singular' or 'spreading' 10130 destination domains it is not allowed. `inCommon` determines 10131 whether the common or the active focal context is updated 10132 (default is to update the active context). The `decisionType` 10133 and `challengePolicy` are used for `advanceSituation`. 10134 10135 - If the destination did not already exist, it will be created. 10136 Initially, it will be disconnected from all other decisions. 10137 In this case, the `domain` value can be used to put it in a 10138 non-default domain. 10139 - The position is set to the specified destination, and if a 10140 `consequence` is specified it is applied. Note that 10141 'deactivate' effects are NOT allowed, and 'edit' effects 10142 must establish their own transition target because there is 10143 no transition that the effects are being applied to. 10144 - If the destination had been unexplored, its exploration status 10145 will be set to 'exploring'. 10146 - If a `zone` is specified, the destination will be added to that 10147 zone (even if the destination already existed) and that zone 10148 will be created (as a level-0 zone) if need be. If `zone` is 10149 set to `None`, then no zone will be applied. If `zone` is 10150 left as the default (`DefaultZone`) and the focalization of 10151 the destination domain is 'singular' or 'plural' and the 10152 destination is newly created and there is an origin and the 10153 origin is in the same domain as the destination, then the 10154 destination will be added to all zones that the origin was a 10155 part of if the destination is newly created, but otherwise 10156 the destination will not be added to any zones. If the 10157 specified zone has to be created and there's an origin 10158 decision, it will be added as a sub-zone to all parents of 10159 zones directly containing the origin, as long as the origin 10160 is in the same domain as the destination. 10161 """ 10162 now = self.getSituation() 10163 graph = now.graph 10164 10165 fromID: Optional[base.DecisionID] 10166 10167 new = False 10168 try: 10169 destID = graph.resolveDecision(destination) 10170 except MissingDecisionError: 10171 if isinstance(destination, tuple): 10172 # just the name; ignore zone/domain 10173 destination = destination[-1] 10174 10175 if not isinstance(destination, base.DecisionName): 10176 raise TypeError( 10177 f"Warp destination {repr(destination)} does not" 10178 f" exist, and cannot be created as it is not a" 10179 f" decision name." 10180 ) 10181 destID = graph.addDecision(destination, domain) 10182 graph.tagDecision(destID, 'unconfirmed') 10183 self.setExplorationStatus(destID, 'unknown') 10184 new = True 10185 10186 using: base.ContextSpecifier 10187 if inCommon: 10188 targetContext = self.getCommonContext() 10189 using = "common" 10190 else: 10191 targetContext = self.getActiveContext() 10192 using = "active" 10193 10194 destDomain = graph.domainFor(destID) 10195 targetFocalization = base.getDomainFocalization( 10196 targetContext, 10197 destDomain 10198 ) 10199 if targetFocalization == 'singular': 10200 targetActive = targetContext['activeDecisions'] 10201 if destDomain in targetActive: 10202 fromID = cast( 10203 base.DecisionID, 10204 targetContext['activeDecisions'][destDomain] 10205 ) 10206 else: 10207 fromID = None 10208 elif targetFocalization == 'plural': 10209 if whichFocus is None: 10210 raise AmbiguousTransitionError( 10211 f"Warping to {repr(destination)} is ambiguous" 10212 f" becuase domain {repr(destDomain)} has plural" 10213 f" focalization, and no whichFocus value was" 10214 f" specified." 10215 ) 10216 10217 fromID = base.resolvePosition( 10218 self.getSituation(), 10219 whichFocus 10220 ) 10221 else: 10222 fromID = None 10223 10224 # Handle zones 10225 if zone is base.DefaultZone: 10226 if ( 10227 new 10228 and fromID is not None 10229 and graph.domainFor(fromID) == destDomain 10230 ): 10231 for prevZone in graph.zoneParents(fromID): 10232 graph.addDecisionToZone(destination, prevZone) 10233 # Otherwise don't update zones 10234 elif zone is not None: 10235 # Newness is ignored when a zone is specified 10236 zone = cast(base.Zone, zone) 10237 # Create the zone at level 0 if it didn't already exist 10238 if graph.getZoneInfo(zone) is None: 10239 graph.createZone(zone, 0) 10240 # Add the newly created zone to each 2nd-level parent of 10241 # the previous decision if there is one and it's in the 10242 # same domain 10243 if ( 10244 fromID is not None 10245 and graph.domainFor(fromID) == destDomain 10246 ): 10247 for prevZone in graph.zoneParents(fromID): 10248 for prevUpper in graph.zoneParents(prevZone): 10249 graph.addZoneToZone(zone, prevUpper) 10250 # Finally add the destination to the (maybe new) zone 10251 graph.addDecisionToZone(destID, zone) 10252 # else don't touch zones 10253 10254 # Encode the action taken 10255 actionTaken: base.ExplorationAction 10256 if whichFocus is None: 10257 actionTaken = ( 10258 'warp', 10259 using, 10260 destID 10261 ) 10262 else: 10263 actionTaken = ( 10264 'warp', 10265 whichFocus, 10266 destID 10267 ) 10268 10269 # Advance the situation 10270 _, finalDests = self.advanceSituation( 10271 actionTaken, 10272 decisionType, 10273 challengePolicy 10274 ) 10275 now = self.getSituation() # updating just in case 10276 10277 assert len(finalDests) == 1 10278 finalDest = next(x for x in finalDests) 10279 10280 # Apply additional consequences: 10281 if consequence is not None: 10282 altDest = self.applyExtraneousConsequence( 10283 consequence, 10284 where=(destID, None), 10285 # TODO: Mechanism search from both ends? 10286 moveWhich=( 10287 whichFocus[-1] 10288 if whichFocus is not None 10289 else None 10290 ) 10291 ) 10292 if altDest is not None: 10293 finalDest = altDest 10294 now = self.getSituation() # updating just in case 10295 10296 return finalDest 10297 10298 def wait( 10299 self, 10300 consequence: Optional[base.Consequence] = None, 10301 decisionType: base.DecisionType = "active", 10302 challengePolicy: base.ChallengePolicy = "specified" 10303 ) -> Optional[base.DecisionID]: 10304 """ 10305 Adds a wait step. If a consequence is specified, it is applied, 10306 although it will not have any position/transition information 10307 available during resolution/application. 10308 10309 A decision type other than "active" and/or a challenge policy 10310 other than "specified" can be included (see `advanceSituation`). 10311 10312 The "pending" decision type may not be used, a `ValueError` will 10313 result. This allows None as the action for waiting while 10314 preserving the pending/None type/action combination for 10315 unresolved situations. 10316 10317 If a goto or follow effect in the applied consequence implies a 10318 position update, this will return the new destination ID; 10319 otherwise it will return `None`. Triggering a 'bounce' effect 10320 will be an error, because there is no position information for 10321 the effect. 10322 """ 10323 if decisionType == "pending": 10324 raise ValueError( 10325 "The 'pending' decision type may not be used for" 10326 " wait actions." 10327 ) 10328 self.advanceSituation(('noAction',), decisionType, challengePolicy) 10329 now = self.getSituation() 10330 if consequence is not None: 10331 if challengePolicy != "specified": 10332 base.resetChallengeOutcomes(consequence) 10333 observed = base.observeChallengeOutcomes( 10334 base.RequirementContext( 10335 state=now.state, 10336 graph=now.graph, 10337 searchFrom=set() 10338 ), 10339 consequence, 10340 location=None, # No position info 10341 policy=challengePolicy, 10342 knownOutcomes=None # bake outcomes into the consequence 10343 ) 10344 # No location information since we might have multiple 10345 # active decisions and there's no indication of which one 10346 # we're "waiting at." 10347 finalDest = self.applyExtraneousConsequence(observed) 10348 now = self.getSituation() # updating just in case 10349 10350 return finalDest 10351 else: 10352 return None 10353 10354 def revert( 10355 self, 10356 slot: base.SaveSlot = base.DEFAULT_SAVE_SLOT, 10357 aspects: Optional[Set[str]] = None, 10358 decisionType: base.DecisionType = "active" 10359 ) -> None: 10360 """ 10361 Reverts the game state to a previously-saved game state (saved 10362 via a 'save' effect). The save slot name and set of aspects to 10363 revert are required. By default, all aspects except the graph 10364 are reverted. 10365 """ 10366 if aspects is None: 10367 aspects = set() 10368 10369 action: base.ExplorationAction = ("revertTo", slot, aspects) 10370 10371 self.advanceSituation(action, decisionType) 10372 10373 def observeAll( 10374 self, 10375 where: base.AnyDecisionSpecifier, 10376 *transitions: Union[ 10377 base.Transition, 10378 Tuple[base.Transition, base.AnyDecisionSpecifier], 10379 Tuple[ 10380 base.Transition, 10381 base.AnyDecisionSpecifier, 10382 base.Transition 10383 ] 10384 ] 10385 ) -> List[base.DecisionID]: 10386 """ 10387 Observes one or more new transitions, applying changes to the 10388 current graph. The transitions can be specified in one of three 10389 ways: 10390 10391 1. A transition name. The transition will be created and will 10392 point to a new unexplored node. 10393 2. A pair containing a transition name and a destination 10394 specifier. If the destination does not exist it will be 10395 created as an unexplored node, although in that case the 10396 decision specifier may not be an ID. 10397 3. A triple containing a transition name, a destination 10398 specifier, and a reciprocal name. Works the same as the pair 10399 case but also specifies the name for the reciprocal 10400 transition. 10401 10402 The new transitions are outgoing from specified decision. 10403 10404 Yields the ID of each decision connected to, whether those are 10405 new or existing decisions. 10406 """ 10407 now = self.getSituation() 10408 fromID = now.graph.resolveDecision(where) 10409 result = [] 10410 for entry in transitions: 10411 if isinstance(entry, base.Transition): 10412 result.append(self.observe(fromID, entry)) 10413 else: 10414 result.append(self.observe(fromID, *entry)) 10415 return result 10416 10417 def observe( 10418 self, 10419 where: base.AnyDecisionSpecifier, 10420 transition: base.Transition, 10421 destination: Optional[base.AnyDecisionSpecifier] = None, 10422 reciprocal: Optional[base.Transition] = None 10423 ) -> base.DecisionID: 10424 """ 10425 Observes a single new outgoing transition from the specified 10426 decision. If specified the transition connects to a specific 10427 destination and/or has a specific reciprocal. The specified 10428 destination will be created if it doesn't exist, or where no 10429 destination is specified, a new unexplored decision will be 10430 added. The ID of the decision connected to is returned. 10431 10432 Sets the exploration status of the observed destination to 10433 "noticed" if a destination is specified and needs to be created 10434 (but not when no destination is specified). 10435 10436 For example: 10437 10438 >>> e = DiscreteExploration() 10439 >>> e.start('start') 10440 0 10441 >>> e.observe('start', 'up') 10442 1 10443 >>> g = e.getSituation().graph 10444 >>> g.destinationsFrom('start') 10445 {'up': 1} 10446 >>> e.getExplorationStatus(1) # not given a name: assumed unknown 10447 'unknown' 10448 >>> e.observe('start', 'left', 'A') 10449 2 10450 >>> g.destinationsFrom('start') 10451 {'up': 1, 'left': 2} 10452 >>> g.nameFor(2) 10453 'A' 10454 >>> e.getExplorationStatus(2) # given a name: noticed 10455 'noticed' 10456 >>> e.observe('start', 'up2', 1) 10457 1 10458 >>> g.destinationsFrom('start') 10459 {'up': 1, 'left': 2, 'up2': 1} 10460 >>> e.getExplorationStatus(1) # existing decision: status unchanged 10461 'unknown' 10462 >>> e.observe('start', 'right', 'B', 'left') 10463 3 10464 >>> g.destinationsFrom('start') 10465 {'up': 1, 'left': 2, 'up2': 1, 'right': 3} 10466 >>> g.nameFor(3) 10467 'B' 10468 >>> e.getExplorationStatus(3) # new + name -> noticed 10469 'noticed' 10470 >>> e.observe('start', 'right') # repeat transition name 10471 Traceback (most recent call last): 10472 ... 10473 exploration.core.TransitionCollisionError... 10474 >>> e.observe('start', 'right2', 'B', 'left') # repeat reciprocal 10475 Traceback (most recent call last): 10476 ... 10477 exploration.core.TransitionCollisionError... 10478 >>> g = e.getSituation().graph 10479 >>> g.createZone('Z', 0) 10480 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 10481 annotations=[]) 10482 >>> g.addDecisionToZone('start', 'Z') 10483 >>> e.observe('start', 'down', 'C', 'up') 10484 4 10485 >>> g.destinationsFrom('start') 10486 {'up': 1, 'left': 2, 'up2': 1, 'right': 3, 'down': 4} 10487 >>> g.identityOf('C') 10488 '4 (C)' 10489 >>> g.zoneParents(4) # not in any zones, 'cause still unexplored 10490 set() 10491 >>> e.observe( 10492 ... 'C', 10493 ... 'right', 10494 ... base.DecisionSpecifier('main', 'Z2', 'D'), 10495 ... ) # creates zone 10496 5 10497 >>> g.destinationsFrom('C') 10498 {'up': 0, 'right': 5} 10499 >>> g.destinationsFrom('D') # default reciprocal name 10500 {'return': 4} 10501 >>> g.identityOf('D') 10502 '5 (Z2::D)' 10503 >>> g.zoneParents(5) 10504 {'Z2'} 10505 """ 10506 now = self.getSituation() 10507 fromID = now.graph.resolveDecision(where) 10508 10509 kwargs: Dict[ 10510 str, 10511 Union[base.Transition, base.DecisionName, None] 10512 ] = {} 10513 if reciprocal is not None: 10514 kwargs['reciprocal'] = reciprocal 10515 10516 if destination is not None: 10517 try: 10518 destID = now.graph.resolveDecision(destination) 10519 now.graph.addTransition( 10520 fromID, 10521 transition, 10522 destID, 10523 reciprocal 10524 ) 10525 return destID 10526 except MissingDecisionError: 10527 if isinstance(destination, base.DecisionSpecifier): 10528 kwargs['toDomain'] = destination.domain 10529 kwargs['placeInZone'] = destination.zone 10530 kwargs['destinationName'] = destination.name 10531 elif isinstance(destination, base.DecisionName): 10532 kwargs['destinationName'] = destination 10533 else: 10534 assert isinstance(destination, base.DecisionID) 10535 # We got to except by failing to resolve, so it's an 10536 # invalid ID 10537 raise 10538 10539 result = now.graph.addUnexploredEdge( 10540 fromID, 10541 transition, 10542 **kwargs # type: ignore [arg-type] 10543 ) 10544 if 'destinationName' in kwargs: 10545 self.setExplorationStatus(result, 'noticed', upgradeOnly=True) 10546 return result 10547 10548 def observeMechanisms( 10549 self, 10550 where: Optional[base.AnyDecisionSpecifier], 10551 *mechanisms: Union[ 10552 base.MechanismName, 10553 Tuple[base.MechanismName, base.MechanismState] 10554 ] 10555 ) -> List[base.MechanismID]: 10556 """ 10557 Adds one or more mechanisms to the exploration's current graph, 10558 located at the specified decision. Global mechanisms can be 10559 added by using `None` for the location. Mechanisms are named, or 10560 a (name, state) tuple can be used to set them into a specific 10561 state. Mechanisms not set to a state will be in the 10562 `base.DEFAULT_MECHANISM_STATE`. 10563 """ 10564 now = self.getSituation() 10565 result = [] 10566 for mSpec in mechanisms: 10567 setState = None 10568 if isinstance(mSpec, base.MechanismName): 10569 result.append(now.graph.addMechanism(mSpec, where)) 10570 elif ( 10571 isinstance(mSpec, tuple) 10572 and len(mSpec) == 2 10573 and isinstance(mSpec[0], base.MechanismName) 10574 and isinstance(mSpec[1], base.MechanismState) 10575 ): 10576 result.append(now.graph.addMechanism(mSpec[0], where)) 10577 setState = mSpec[1] 10578 else: 10579 raise TypeError( 10580 f"Invalid mechanism: {repr(mSpec)} (must be a" 10581 f" mechanism name or a (name, state) tuple." 10582 ) 10583 10584 if setState: 10585 self.setMechanismStateNow(result[-1], setState) 10586 10587 return result 10588 10589 def reZone( 10590 self, 10591 zone: base.Zone, 10592 where: base.AnyDecisionSpecifier, 10593 replace: Union[base.Zone, int] = 0 10594 ) -> None: 10595 """ 10596 Alters the current graph without adding a new exploration step. 10597 10598 Calls `DecisionGraph.replaceZonesInHierarchy` targeting the 10599 specified decision. Note that per the logic of that method, ALL 10600 zones at the specified hierarchy level are replaced, even if a 10601 specific zone to replace is specified here. 10602 10603 TODO: not that? 10604 10605 The level value is either specified via `replace` (default 0) or 10606 deduced from the zone provided as the `replace` value using 10607 `DecisionGraph.zoneHierarchyLevel`. 10608 """ 10609 now = self.getSituation() 10610 10611 if isinstance(replace, int): 10612 level = replace 10613 else: 10614 level = now.graph.zoneHierarchyLevel(replace) 10615 10616 now.graph.replaceZonesInHierarchy(where, zone, level) 10617 10618 def runCommand( 10619 self, 10620 command: commands.Command, 10621 scope: Optional[commands.Scope] = None, 10622 line: int = -1 10623 ) -> commands.CommandResult: 10624 """ 10625 Runs a single `Command` applying effects to the exploration, its 10626 current graph, and the provided execution context, and returning 10627 a command result, which contains the modified scope plus 10628 optional skip and label values (see `CommandResult`). This 10629 function also directly modifies the scope you give it. Variable 10630 references in the command are resolved via entries in the 10631 provided scope. If no scope is given, an empty one is created. 10632 10633 A line number may be supplied for use in error messages; if left 10634 out line -1 will be used. 10635 10636 Raises an error if the command is invalid. 10637 10638 For commands that establish a value as the 'current value', that 10639 value will be stored in the '_' variable. When this happens, the 10640 old contents of '_' are stored in '__' first, and the old 10641 contents of '__' are discarded. Note that non-automatic 10642 assignment to '_' does not move the old value to '__'. 10643 """ 10644 try: 10645 if scope is None: 10646 scope = {} 10647 10648 skip: Union[int, str, None] = None 10649 label: Optional[str] = None 10650 10651 if command.command == 'val': 10652 command = cast(commands.LiteralValue, command) 10653 result = commands.resolveValue(command.value, scope) 10654 commands.pushCurrentValue(scope, result) 10655 10656 elif command.command == 'empty': 10657 command = cast(commands.EstablishCollection, command) 10658 collection = commands.resolveVarName(command.collection, scope) 10659 commands.pushCurrentValue( 10660 scope, 10661 { 10662 'list': [], 10663 'tuple': (), 10664 'set': set(), 10665 'dict': {}, 10666 }[collection] 10667 ) 10668 10669 elif command.command == 'append': 10670 command = cast(commands.AppendValue, command) 10671 target = scope['_'] 10672 addIt = commands.resolveValue(command.value, scope) 10673 if isinstance(target, list): 10674 target.append(addIt) 10675 elif isinstance(target, tuple): 10676 scope['_'] = target + (addIt,) 10677 elif isinstance(target, set): 10678 target.add(addIt) 10679 elif isinstance(target, dict): 10680 raise TypeError( 10681 "'append' command cannot be used with a" 10682 " dictionary. Use 'set' instead." 10683 ) 10684 else: 10685 raise TypeError( 10686 f"Invalid current value for 'append' command." 10687 f" The current value must be a list, tuple, or" 10688 f" set, but it was a '{type(target).__name__}'." 10689 ) 10690 10691 elif command.command == 'set': 10692 command = cast(commands.SetValue, command) 10693 target = scope['_'] 10694 where = commands.resolveValue(command.location, scope) 10695 what = commands.resolveValue(command.value, scope) 10696 if isinstance(target, list): 10697 if not isinstance(where, int): 10698 raise TypeError( 10699 f"Cannot set item in list: index {where!r}" 10700 f" is not an integer." 10701 ) 10702 target[where] = what 10703 elif isinstance(target, tuple): 10704 if not isinstance(where, int): 10705 raise TypeError( 10706 f"Cannot set item in tuple: index {where!r}" 10707 f" is not an integer." 10708 ) 10709 if not ( 10710 0 <= where < len(target) 10711 or -1 >= where >= -len(target) 10712 ): 10713 raise IndexError( 10714 f"Cannot set item in tuple at index" 10715 f" {where}: Tuple has length {len(target)}." 10716 ) 10717 scope['_'] = target[:where] + (what,) + target[where + 1:] 10718 elif isinstance(target, set): 10719 if what: 10720 target.add(where) 10721 else: 10722 try: 10723 target.remove(where) 10724 except KeyError: 10725 pass 10726 elif isinstance(target, dict): 10727 target[where] = what 10728 10729 elif command.command == 'pop': 10730 command = cast(commands.PopValue, command) 10731 target = scope['_'] 10732 if isinstance(target, list): 10733 result = target.pop() 10734 commands.pushCurrentValue(scope, result) 10735 elif isinstance(target, tuple): 10736 result = target[-1] 10737 updated = target[:-1] 10738 scope['__'] = updated 10739 scope['_'] = result 10740 else: 10741 raise TypeError( 10742 f"Cannot 'pop' from a {type(target).__name__}" 10743 f" (current value must be a list or tuple)." 10744 ) 10745 10746 elif command.command == 'get': 10747 command = cast(commands.GetValue, command) 10748 target = scope['_'] 10749 where = commands.resolveValue(command.location, scope) 10750 if isinstance(target, list): 10751 if not isinstance(where, int): 10752 raise TypeError( 10753 f"Cannot get item from list: index" 10754 f" {where!r} is not an integer." 10755 ) 10756 elif isinstance(target, tuple): 10757 if not isinstance(where, int): 10758 raise TypeError( 10759 f"Cannot get item from tuple: index" 10760 f" {where!r} is not an integer." 10761 ) 10762 elif isinstance(target, set): 10763 result = where in target 10764 commands.pushCurrentValue(scope, result) 10765 elif isinstance(target, dict): 10766 result = target[where] 10767 commands.pushCurrentValue(scope, result) 10768 else: 10769 result = getattr(target, where) 10770 commands.pushCurrentValue(scope, result) 10771 10772 elif command.command == 'remove': 10773 command = cast(commands.RemoveValue, command) 10774 target = scope['_'] 10775 where = commands.resolveValue(command.location, scope) 10776 if isinstance(target, (list, tuple)): 10777 # this cast is not correct but suppresses warnings 10778 # given insufficient narrowing by MyPy 10779 target = cast(Tuple[Any, ...], target) 10780 if not isinstance(where, int): 10781 raise TypeError( 10782 f"Cannot remove item from list or tuple:" 10783 f" index {where!r} is not an integer." 10784 ) 10785 scope['_'] = target[:where] + target[where + 1:] 10786 elif isinstance(target, set): 10787 target.remove(where) 10788 elif isinstance(target, dict): 10789 del target[where] 10790 else: 10791 raise TypeError( 10792 f"Cannot use 'remove' on a/an" 10793 f" {type(target).__name__}." 10794 ) 10795 10796 elif command.command == 'op': 10797 command = cast(commands.ApplyOperator, command) 10798 left = commands.resolveValue(command.left, scope) 10799 right = commands.resolveValue(command.right, scope) 10800 op = command.op 10801 if op == '+': 10802 result = left + right 10803 elif op == '-': 10804 result = left - right 10805 elif op == '*': 10806 result = left * right 10807 elif op == '/': 10808 result = left / right 10809 elif op == '//': 10810 result = left // right 10811 elif op == '**': 10812 result = left ** right 10813 elif op == '%': 10814 result = left % right 10815 elif op == '^': 10816 result = left ^ right 10817 elif op == '|': 10818 result = left | right 10819 elif op == '&': 10820 result = left & right 10821 elif op == 'and': 10822 result = left and right 10823 elif op == 'or': 10824 result = left or right 10825 elif op == '<': 10826 result = left < right 10827 elif op == '>': 10828 result = left > right 10829 elif op == '<=': 10830 result = left <= right 10831 elif op == '>=': 10832 result = left >= right 10833 elif op == '==': 10834 result = left == right 10835 elif op == 'is': 10836 result = left is right 10837 else: 10838 raise RuntimeError("Invalid operator '{op}'.") 10839 10840 commands.pushCurrentValue(scope, result) 10841 10842 elif command.command == 'unary': 10843 command = cast(commands.ApplyUnary, command) 10844 value = commands.resolveValue(command.value, scope) 10845 op = command.op 10846 if op == '-': 10847 result = -value 10848 elif op == '~': 10849 result = ~value 10850 elif op == 'not': 10851 result = not value 10852 10853 commands.pushCurrentValue(scope, result) 10854 10855 elif command.command == 'assign': 10856 command = cast(commands.VariableAssignment, command) 10857 varname = commands.resolveVarName(command.varname, scope) 10858 value = commands.resolveValue(command.value, scope) 10859 scope[varname] = value 10860 10861 elif command.command == 'delete': 10862 command = cast(commands.VariableDeletion, command) 10863 varname = commands.resolveVarName(command.varname, scope) 10864 del scope[varname] 10865 10866 elif command.command == 'load': 10867 command = cast(commands.LoadVariable, command) 10868 varname = commands.resolveVarName(command.varname, scope) 10869 commands.pushCurrentValue(scope, scope[varname]) 10870 10871 elif command.command == 'call': 10872 command = cast(commands.FunctionCall, command) 10873 function = command.function 10874 if function.startswith('$'): 10875 function = commands.resolveValue(function, scope) 10876 10877 toCall: Callable 10878 args: Tuple[str, ...] 10879 kwargs: Dict[str, Any] 10880 10881 if command.target == 'builtin': 10882 toCall = commands.COMMAND_BUILTINS[function] 10883 args = (scope['_'],) 10884 kwargs = {} 10885 if toCall == round: 10886 if 'ndigits' in scope: 10887 kwargs['ndigits'] = scope['ndigits'] 10888 elif toCall == range and args[0] is None: 10889 start = scope.get('start', 0) 10890 stop = scope['stop'] 10891 step = scope.get('step', 1) 10892 args = (start, stop, step) 10893 10894 else: 10895 if command.target == 'stored': 10896 toCall = function 10897 elif command.target == 'graph': 10898 toCall = getattr(self.getSituation().graph, function) 10899 elif command.target == 'exploration': 10900 toCall = getattr(self, function) 10901 else: 10902 raise TypeError( 10903 f"Invalid call target '{command.target}'" 10904 f" (must be one of 'builtin', 'stored'," 10905 f" 'graph', or 'exploration'." 10906 ) 10907 10908 # Fill in arguments via kwargs defined in scope 10909 args = () 10910 kwargs = {} 10911 signature = inspect.signature(toCall) 10912 # TODO: Maybe try some type-checking here? 10913 for argName, param in signature.parameters.items(): 10914 if param.kind == inspect.Parameter.VAR_POSITIONAL: 10915 if argName in scope: 10916 args = args + tuple(scope[argName]) 10917 # Else leave args as-is 10918 elif param.kind == inspect.Parameter.KEYWORD_ONLY: 10919 # These must have a default 10920 if argName in scope: 10921 kwargs[argName] = scope[argName] 10922 elif param.kind == inspect.Parameter.VAR_KEYWORD: 10923 # treat as a dictionary 10924 if argName in scope: 10925 argsToUse = scope[argName] 10926 if not isinstance(argsToUse, dict): 10927 raise TypeError( 10928 f"Variable '{argName}' must" 10929 f" hold a dictionary when" 10930 f" calling function" 10931 f" '{toCall.__name__} which" 10932 f" uses that argument as a" 10933 f" keyword catchall." 10934 ) 10935 kwargs.update(scope[argName]) 10936 else: # a normal parameter 10937 if argName in scope: 10938 args = args + (scope[argName],) 10939 elif param.default == inspect.Parameter.empty: 10940 raise TypeError( 10941 f"No variable named '{argName}' has" 10942 f" been defined to supply the" 10943 f" required parameter with that" 10944 f" name for function" 10945 f" '{toCall.__name__}'." 10946 ) 10947 10948 result = toCall(*args, **kwargs) 10949 commands.pushCurrentValue(scope, result) 10950 10951 elif command.command == 'skip': 10952 command = cast(commands.SkipCommands, command) 10953 doIt = commands.resolveValue(command.condition, scope) 10954 if doIt: 10955 skip = commands.resolveValue(command.amount, scope) 10956 if not isinstance(skip, (int, str)): 10957 raise TypeError( 10958 f"Skip amount must be an integer or a label" 10959 f" name (got {skip!r})." 10960 ) 10961 10962 elif command.command == 'label': 10963 command = cast(commands.Label, command) 10964 label = commands.resolveValue(command.name, scope) 10965 if not isinstance(label, str): 10966 raise TypeError( 10967 f"Label name must be a string (got {label!r})." 10968 ) 10969 10970 else: 10971 raise ValueError( 10972 f"Invalid command type: {command.command!r}" 10973 ) 10974 except ValueError as e: 10975 raise commands.CommandValueError(command, line, e) 10976 except TypeError as e: 10977 raise commands.CommandTypeError(command, line, e) 10978 except IndexError as e: 10979 raise commands.CommandIndexError(command, line, e) 10980 except KeyError as e: 10981 raise commands.CommandKeyError(command, line, e) 10982 except Exception as e: 10983 raise commands.CommandOtherError(command, line, e) 10984 10985 return (scope, skip, label) 10986 10987 def runCommandBlock( 10988 self, 10989 block: List[commands.Command], 10990 scope: Optional[commands.Scope] = None 10991 ) -> commands.Scope: 10992 """ 10993 Runs a list of commands, using the given scope (or creating a new 10994 empty scope if none was provided). Returns the scope after 10995 running all of the commands, which may also edit the exploration 10996 and/or the current graph of course. 10997 10998 Note that if a skip command would skip past the end of the 10999 block, execution will end. If a skip command would skip before 11000 the beginning of the block, execution will start from the first 11001 command. 11002 11003 Example: 11004 11005 >>> e = DiscreteExploration() 11006 >>> scope = e.runCommandBlock([ 11007 ... commands.command('assign', 'decision', "'START'"), 11008 ... commands.command('call', 'exploration', 'start'), 11009 ... commands.command('assign', 'where', '$decision'), 11010 ... commands.command('assign', 'transition', "'left'"), 11011 ... commands.command('call', 'exploration', 'observe'), 11012 ... commands.command('assign', 'transition', "'right'"), 11013 ... commands.command('call', 'exploration', 'observe'), 11014 ... commands.command('call', 'graph', 'destinationsFrom'), 11015 ... commands.command('call', 'builtin', 'print'), 11016 ... commands.command('assign', 'transition', "'right'"), 11017 ... commands.command('assign', 'destination', "'EastRoom'"), 11018 ... commands.command('call', 'exploration', 'explore'), 11019 ... ]) 11020 {'left': 1, 'right': 2} 11021 >>> scope['decision'] 11022 'START' 11023 >>> scope['where'] 11024 'START' 11025 >>> scope['_'] # result of 'explore' call is dest ID 11026 2 11027 >>> scope['transition'] 11028 'right' 11029 >>> scope['destination'] 11030 'EastRoom' 11031 >>> g = e.getSituation().graph 11032 >>> len(e) 11033 3 11034 >>> len(g) 11035 3 11036 >>> g.namesListing(g) 11037 ' 0 (START)\\n 1 (_u.0)\\n 2 (EastRoom)\\n' 11038 """ 11039 if scope is None: 11040 scope = {} 11041 11042 labelPositions: Dict[str, List[int]] = {} 11043 11044 # Keep going until we've exhausted the commands list 11045 index = 0 11046 while index < len(block): 11047 11048 # Execute the next command 11049 scope, skip, label = self.runCommand( 11050 block[index], 11051 scope, 11052 index + 1 11053 ) 11054 11055 # Increment our index, or apply a skip 11056 if skip is None: 11057 index = index + 1 11058 11059 elif isinstance(skip, int): # Integer skip value 11060 if skip < 0: 11061 index += skip 11062 if index < 0: # can't skip before the start 11063 index = 0 11064 else: 11065 index += skip + 1 # may end loop if we skip too far 11066 11067 else: # must be a label name 11068 if skip in labelPositions: # an established label 11069 # We jump to the last previous index, or if there 11070 # are none, to the first future index. 11071 prevIndices = [ 11072 x 11073 for x in labelPositions[skip] 11074 if x < index 11075 ] 11076 futureIndices = [ 11077 x 11078 for x in labelPositions[skip] 11079 if x >= index 11080 ] 11081 if len(prevIndices) > 0: 11082 index = max(prevIndices) 11083 else: 11084 index = min(futureIndices) 11085 else: # must be a forward-reference 11086 for future in range(index + 1, len(block)): 11087 inspect = block[future] 11088 if inspect.command == 'label': 11089 inspect = cast(commands.Label, inspect) 11090 if inspect.name == skip: 11091 index = future 11092 break 11093 else: 11094 raise KeyError( 11095 f"Skip command indicated a jump to label" 11096 f" {skip!r} but that label had not already" 11097 f" been defined and there is no future" 11098 f" label with that name either (future" 11099 f" labels based on variables cannot be" 11100 f" skipped to from above as their names" 11101 f" are not known yet)." 11102 ) 11103 11104 # If there's a label, record it 11105 if label is not None: 11106 labelPositions.setdefault(label, []).append(index) 11107 11108 # And now the while loop continues, or ends if we're at the 11109 # end of the commands list. 11110 11111 # Return the scope object. 11112 return scope
Domain value for endings.
Domain value for triggers.
A type variable for lookup results from the generic
DecisionGraph.localLookup
function.
A list of layers to look things up in, consisting of None
for the
starting provided decision set, integers for zone heights, and some
custom strings like "fallback" and "all" for fallback sets.
78class DecisionInfo(TypedDict): 79 """ 80 The information stored per-decision in a `DecisionGraph` includes 81 the decision name (since the key is a decision ID), the domain, a 82 tags dictionary, and an annotations list. 83 """ 84 name: base.DecisionName 85 domain: base.Domain 86 tags: Dict[base.Tag, base.TagValue] 87 annotations: List[base.Annotation]
The information stored per-decision in a DecisionGraph
includes
the decision name (since the key is a decision ID), the domain, a
tags dictionary, and an annotations list.
94class TransitionProperties(TypedDict, total=False): 95 """ 96 Represents bundled properties of a transition, including a 97 requirement, effects, tags, and/or annotations. Does not include the 98 reciprocal. Has the following slots: 99 100 - `'requirement'`: The requirement for the transition. This is 101 always a `Requirement`, although it might be `ReqNothing` if 102 nothing special is required. 103 - `'consequence'`: The `Consequence` of the transition. 104 - `'tags'`: Any tags applied to the transition (as a dictionary). 105 - `'annotations'`: A list of annotations applied to the transition. 106 """ 107 requirement: base.Requirement 108 consequence: base.Consequence 109 tags: Dict[base.Tag, base.TagValue] 110 annotations: List[base.Annotation]
Represents bundled properties of a transition, including a requirement, effects, tags, and/or annotations. Does not include the reciprocal. Has the following slots:
'requirement'
: The requirement for the transition. This is always aRequirement
, although it might beReqNothing
if nothing special is required.'consequence'
: TheConsequence
of the transition.'tags'
: Any tags applied to the transition (as a dictionary).'annotations'
: A list of annotations applied to the transition.
113def mergeProperties( 114 a: Optional[TransitionProperties], 115 b: Optional[TransitionProperties] 116) -> TransitionProperties: 117 """ 118 Merges two sets of transition properties, following these rules: 119 120 1. Tags and annotations are combined. Annotations from the 121 second property set are ordered after those from the first. 122 2. If one of the transitions has a `ReqNothing` instance as its 123 requirement, we use the other requirement. If both have 124 complex requirements, we create a new `ReqAll` which 125 combines them as the requirement. 126 3. The consequences are merged by placing all of the consequences of 127 the first transition before those of the second one. This may in 128 some cases change the net outcome of those consequences, 129 because not all transition properties are compatible. (Imagine 130 merging two transitions one of which causes a capability to be 131 gained and the other of which causes a capability to be lost. 132 What should happen?). 133 4. The result will not list a reciprocal. 134 135 If either transition is `None`, then a deep copy of the other is 136 returned. If both are `None`, then an empty transition properties 137 dictionary is returned, with `ReqNothing` as the requirement, no 138 effects, no tags, and no annotations. 139 140 Deep copies of consequences are always made, so that any `Effects` 141 applications which edit effects won't end up with entangled effects. 142 """ 143 if a is None: 144 if b is None: 145 return { 146 "requirement": base.ReqNothing(), 147 "consequence": [], 148 "tags": {}, 149 "annotations": [] 150 } 151 else: 152 return copy.deepcopy(b) 153 elif b is None: 154 return copy.deepcopy(a) 155 # implicitly neither a or b is None below 156 157 result: TransitionProperties = { 158 "requirement": base.ReqNothing(), 159 "consequence": copy.deepcopy(a["consequence"] + b["consequence"]), 160 "tags": a["tags"] | b["tags"], 161 "annotations": a["annotations"] + b["annotations"] 162 } 163 164 if a["requirement"] == base.ReqNothing(): 165 result["requirement"] = b["requirement"] 166 elif b["requirement"] == base.ReqNothing(): 167 result["requirement"] = a["requirement"] 168 else: 169 result["requirement"] = base.ReqAll( 170 [a["requirement"], b["requirement"]] 171 ) 172 173 return result
Merges two sets of transition properties, following these rules:
- Tags and annotations are combined. Annotations from the second property set are ordered after those from the first.
- If one of the transitions has a
ReqNothing
instance as its requirement, we use the other requirement. If both have complex requirements, we create a newReqAll
which combines them as the requirement. - The consequences are merged by placing all of the consequences of the first transition before those of the second one. This may in some cases change the net outcome of those consequences, because not all transition properties are compatible. (Imagine merging two transitions one of which causes a capability to be gained and the other of which causes a capability to be lost. What should happen?).
- The result will not list a reciprocal.
If either transition is None
, then a deep copy of the other is
returned. If both are None
, then an empty transition properties
dictionary is returned, with ReqNothing
as the requirement, no
effects, no tags, and no annotations.
Deep copies of consequences are always made, so that any Effects
applications which edit effects won't end up with entangled effects.
180class TransitionBlockedWarning(Warning): 181 """ 182 An warning type for indicating that a transition which has been 183 requested does not have its requirements satisfied by the current 184 game state. 185 """ 186 pass
An warning type for indicating that a transition which has been requested does not have its requirements satisfied by the current game state.
Inherited Members
- builtins.Warning
- Warning
- builtins.BaseException
- with_traceback
- add_note
- args
189class BadStart(ValueError): 190 """ 191 An error raised when the start method is used improperly. 192 """ 193 pass
An error raised when the start method is used improperly.
Inherited Members
- builtins.ValueError
- ValueError
- builtins.BaseException
- with_traceback
- add_note
- args
196class MissingDecisionError(KeyError): 197 """ 198 An error raised when attempting to use a decision that does not 199 exist. 200 """ 201 pass
An error raised when attempting to use a decision that does not exist.
Inherited Members
- builtins.KeyError
- KeyError
- builtins.BaseException
- with_traceback
- add_note
- args
204class AmbiguousDecisionSpecifierError(KeyError): 205 """ 206 An error raised when an ambiguous decision specifier is provided. 207 Note that if a decision specifier simply doesn't match anything, you 208 will get a `MissingDecisionError` instead. 209 """ 210 pass
An error raised when an ambiguous decision specifier is provided.
Note that if a decision specifier simply doesn't match anything, you
will get a MissingDecisionError
instead.
Inherited Members
- builtins.KeyError
- KeyError
- builtins.BaseException
- with_traceback
- add_note
- args
213class AmbiguousTransitionError(KeyError): 214 """ 215 An error raised when an ambiguous transition is specified. 216 If a transition specifier simply doesn't match anything, you 217 will get a `MissingTransitionError` instead. 218 """ 219 pass
An error raised when an ambiguous transition is specified.
If a transition specifier simply doesn't match anything, you
will get a MissingTransitionError
instead.
Inherited Members
- builtins.KeyError
- KeyError
- builtins.BaseException
- with_traceback
- add_note
- args
222class MissingTransitionError(KeyError): 223 """ 224 An error raised when attempting to use a transition that does not 225 exist. 226 """ 227 pass
An error raised when attempting to use a transition that does not exist.
Inherited Members
- builtins.KeyError
- KeyError
- builtins.BaseException
- with_traceback
- add_note
- args
230class MissingMechanismError(KeyError): 231 """ 232 An error raised when attempting to use a mechanism that does not 233 exist. 234 """ 235 pass
An error raised when attempting to use a mechanism that does not exist.
Inherited Members
- builtins.KeyError
- KeyError
- builtins.BaseException
- with_traceback
- add_note
- args
238class MissingZoneError(KeyError): 239 """ 240 An error raised when attempting to use a zone that does not exist. 241 """ 242 pass
An error raised when attempting to use a zone that does not exist.
Inherited Members
- builtins.KeyError
- KeyError
- builtins.BaseException
- with_traceback
- add_note
- args
245class InvalidLevelError(ValueError): 246 """ 247 An error raised when an operation fails because of an invalid zone 248 level. 249 """ 250 pass
An error raised when an operation fails because of an invalid zone level.
Inherited Members
- builtins.ValueError
- ValueError
- builtins.BaseException
- with_traceback
- add_note
- args
253class InvalidDestinationError(ValueError): 254 """ 255 An error raised when attempting to perform an operation with a 256 transition but that transition does not lead to a destination that's 257 compatible with the operation. 258 """ 259 pass
An error raised when attempting to perform an operation with a transition but that transition does not lead to a destination that's compatible with the operation.
Inherited Members
- builtins.ValueError
- ValueError
- builtins.BaseException
- with_traceback
- add_note
- args
262class ExplorationStatusError(ValueError): 263 """ 264 An error raised when attempting to perform an operation that 265 requires a previously-visited destination with a decision that 266 represents a not-yet-visited decision, or vice versa. For 267 `Situation`s, Exploration states 'unknown', 'hypothesized', and 268 'noticed' count as "not-yet-visited" while 'exploring' and 'explored' 269 count as "visited" (see `base.hasBeenVisited`) Meanwhile, in a 270 `DecisionGraph` where exploration statuses are not present, the 271 presence or absence of the 'unconfirmed' tag is used to determine 272 whether something has been confirmed or not. 273 """ 274 pass
An error raised when attempting to perform an operation that
requires a previously-visited destination with a decision that
represents a not-yet-visited decision, or vice versa. For
Situation
s, Exploration states 'unknown', 'hypothesized', and
'noticed' count as "not-yet-visited" while 'exploring' and 'explored'
count as "visited" (see base.hasBeenVisited
) Meanwhile, in a
DecisionGraph
where exploration statuses are not present, the
presence or absence of the 'unconfirmed' tag is used to determine
whether something has been confirmed or not.
Inherited Members
- builtins.ValueError
- ValueError
- builtins.BaseException
- with_traceback
- add_note
- args
Whether or not to issue warnings when two decision names are the same.
283class DecisionCollisionWarning(Warning): 284 """ 285 A warning raised when attempting to create a new decision using the 286 name of a decision that already exists. 287 """ 288 pass
A warning raised when attempting to create a new decision using the name of a decision that already exists.
Inherited Members
- builtins.Warning
- Warning
- builtins.BaseException
- with_traceback
- add_note
- args
291class TransitionCollisionError(ValueError): 292 """ 293 An error raised when attempting to re-use a transition name for a 294 new transition, or otherwise when a transition name conflicts with 295 an already-established transition. 296 """ 297 pass
An error raised when attempting to re-use a transition name for a new transition, or otherwise when a transition name conflicts with an already-established transition.
Inherited Members
- builtins.ValueError
- ValueError
- builtins.BaseException
- with_traceback
- add_note
- args
300class MechanismCollisionError(ValueError): 301 """ 302 An error raised when attempting to re-use a mechanism name at the 303 same decision where a mechanism with that name already exists. 304 """ 305 pass
An error raised when attempting to re-use a mechanism name at the same decision where a mechanism with that name already exists.
Inherited Members
- builtins.ValueError
- ValueError
- builtins.BaseException
- with_traceback
- add_note
- args
308class DomainCollisionError(KeyError): 309 """ 310 An error raised when attempting to create a domain with the same 311 name as an existing domain. 312 """ 313 pass
An error raised when attempting to create a domain with the same name as an existing domain.
Inherited Members
- builtins.KeyError
- KeyError
- builtins.BaseException
- with_traceback
- add_note
- args
316class MissingFocalContextError(KeyError): 317 """ 318 An error raised when attempting to pick out a focal context with a 319 name that doesn't exist. 320 """ 321 pass
An error raised when attempting to pick out a focal context with a name that doesn't exist.
Inherited Members
- builtins.KeyError
- KeyError
- builtins.BaseException
- with_traceback
- add_note
- args
324class FocalContextCollisionError(KeyError): 325 """ 326 An error raised when attempting to create a focal context with the 327 same name as an existing focal context. 328 """ 329 pass
An error raised when attempting to create a focal context with the same name as an existing focal context.
Inherited Members
- builtins.KeyError
- KeyError
- builtins.BaseException
- with_traceback
- add_note
- args
332class InvalidActionError(TypeError): 333 """ 334 An error raised when attempting to take an exploration action which 335 is not correctly formed. 336 """ 337 pass
An error raised when attempting to take an exploration action which is not correctly formed.
Inherited Members
- builtins.TypeError
- TypeError
- builtins.BaseException
- with_traceback
- add_note
- args
340class ImpossibleActionError(ValueError): 341 """ 342 An error raised when attempting to take an exploration action which 343 is correctly formed but which specifies an action that doesn't match 344 up with the graph state. 345 """ 346 pass
An error raised when attempting to take an exploration action which is correctly formed but which specifies an action that doesn't match up with the graph state.
Inherited Members
- builtins.ValueError
- ValueError
- builtins.BaseException
- with_traceback
- add_note
- args
349class DoubleActionError(ValueError): 350 """ 351 An error raised when attempting to set up an `ExplorationAction` 352 when the current situation already has an action specified. 353 """ 354 pass
An error raised when attempting to set up an ExplorationAction
when the current situation already has an action specified.
Inherited Members
- builtins.ValueError
- ValueError
- builtins.BaseException
- with_traceback
- add_note
- args
357class InactiveDomainWarning(Warning): 358 """ 359 A warning used when an inactive domain is referenced but the 360 operation in progress can still succeed (for example when 361 deactivating an already-inactive domain). 362 """
A warning used when an inactive domain is referenced but the operation in progress can still succeed (for example when deactivating an already-inactive domain).
Inherited Members
- builtins.Warning
- Warning
- builtins.BaseException
- with_traceback
- add_note
- args
365class ZoneCollisionError(ValueError): 366 """ 367 An error raised when attempting to re-use a zone name for a new zone, 368 or otherwise when a zone name conflicts with an already-established 369 zone. 370 """ 371 pass
An error raised when attempting to re-use a zone name for a new zone, or otherwise when a zone name conflicts with an already-established zone.
Inherited Members
- builtins.ValueError
- ValueError
- builtins.BaseException
- with_traceback
- add_note
- args
378class DecisionGraph( 379 graphs.UniqueExitsGraph[base.DecisionID, base.Transition] 380): 381 """ 382 Represents a view of the world as a topological graph at a moment in 383 time. It derives from `networkx.MultiDiGraph`. 384 385 Each node (a `Decision`) represents a place in the world where there 386 are multiple opportunities for travel/action, or a dead end where 387 you must turn around and go back; typically this is a single room in 388 a game, but sometimes one room has multiple decision points. Edges 389 (`Transition`s) represent choices that can be made to travel to 390 other decision points (e.g., taking the left door), or when they are 391 self-edges, they represent actions that can be taken within a 392 location that affect the world or the game state. 393 394 Each `Transition` includes a `Effects` dictionary 395 indicating the effects that it has. Other effects of the transition 396 that are not simple enough to be included in this format may be 397 represented in an `DiscreteExploration` by changing the graph in the 398 next step to reflect further effects of a transition. 399 400 In addition to normal transitions between decisions, a 401 `DecisionGraph` can represent potential transitions which lead to 402 unknown destinations. These are represented by adding decisions with 403 the `'unconfirmed'` tag (whose names where not specified begin with 404 `'_u.'`) with a separate unconfirmed decision for each transition 405 (although where it's known that two transitions lead to the same 406 unconfirmed decision, this can be represented as well). 407 408 Both nodes and edges can have `Annotation`s associated with them that 409 include extra details about the explorer's perception of the 410 situation. They can also have `Tag`s, which represent specific 411 categories a transition or decision falls into. 412 413 Nodes can also be part of one or more `Zones`, and zones can also be 414 part of other zones, allowing for a hierarchical description of the 415 underlying space. 416 417 Equivalences can be specified to mark that some combination of 418 capabilities can stand in for another capability. 419 """ 420 def __init__(self) -> None: 421 super().__init__() 422 423 self.zones: Dict[base.Zone, base.ZoneInfo] = {} 424 """ 425 Mapping from zone names to zone info 426 """ 427 428 self.unknownCount: int = 0 429 """ 430 Number of unknown decisions that have been created (not number 431 of current unknown decisions, which is likely lower) 432 """ 433 434 self.equivalences: base.Equivalences = {} 435 """ 436 See `base.Equivalences`. Determines what capabilities and/or 437 mechanism states can count as active based on alternate 438 requirements. 439 """ 440 441 self.reversionTypes: Dict[str, Set[str]] = {} 442 """ 443 This tracks shorthand reversion types. See `base.revertedState` 444 for how these are applied. Keys are custom names and values are 445 reversion type strings that `base.revertedState` could access. 446 """ 447 448 self.nextID: base.DecisionID = 0 449 """ 450 The ID to use for the next new decision we create. 451 """ 452 453 self.nextMechanismID: base.MechanismID = 0 454 """ 455 ID for the next mechanism. 456 """ 457 458 self.mechanisms: Dict[ 459 base.MechanismID, 460 Tuple[Optional[base.DecisionID], base.MechanismName] 461 ] = {} 462 """ 463 Mapping from `MechanismID`s to (`DecisionID`, `MechanismName`) 464 pairs. For global mechanisms, the `DecisionID` is None. 465 """ 466 467 self.globalMechanisms: Dict[ 468 base.MechanismName, 469 base.MechanismID 470 ] = {} 471 """ 472 Global mechanisms 473 """ 474 475 self.nameLookup: Dict[base.DecisionName, List[base.DecisionID]] = {} 476 """ 477 A cache for name -> ID lookups 478 """ 479 480 # Note: not hashable 481 482 def __eq__(self, other): 483 """ 484 Equality checker. `DecisionGraph`s can only be equal to other 485 `DecisionGraph`s, not to other kinds of things. 486 """ 487 if not isinstance(other, DecisionGraph): 488 return False 489 else: 490 # Checks nodes, edges, and all attached data 491 if not super().__eq__(other): 492 return False 493 494 # Check unknown count 495 if self.unknownCount != other.unknownCount: 496 return False 497 498 # Check zones 499 if self.zones != other.zones: 500 return False 501 502 # Check equivalences 503 if self.equivalences != other.equivalences: 504 return False 505 506 # Check reversion types 507 if self.reversionTypes != other.reversionTypes: 508 return False 509 510 # Check mechanisms 511 if self.nextMechanismID != other.nextMechanismID: 512 return False 513 514 if self.mechanisms != other.mechanisms: 515 return False 516 517 if self.globalMechanisms != other.globalMechanisms: 518 return False 519 520 # Check names: 521 if self.nameLookup != other.nameLookup: 522 return False 523 524 return True 525 526 def listDifferences( 527 self, 528 other: 'DecisionGraph' 529 ) -> Generator[str, None, None]: 530 """ 531 Generates strings describing differences between this graph and 532 another graph. This does NOT perform graph matching, so it will 533 consider graphs different even if they have identical structures 534 but use different IDs for the nodes in those structures. 535 """ 536 if not isinstance(other, DecisionGraph): 537 yield "other is not a graph" 538 else: 539 suppress = False 540 myNodes = set(self.nodes) 541 theirNodes = set(other.nodes) 542 for n in myNodes: 543 if n not in theirNodes: 544 suppress = True 545 yield ( 546 f"other graph missing node {n}" 547 ) 548 else: 549 if self.nodes[n] != other.nodes[n]: 550 suppress = True 551 yield ( 552 f"other graph has differences at node {n}:" 553 f"\n Ours: {self.nodes[n]}" 554 f"\nTheirs: {other.nodes[n]}" 555 ) 556 myDests = self.destinationsFrom(n) 557 theirDests = other.destinationsFrom(n) 558 for tr in myDests: 559 myTo = myDests[tr] 560 if tr not in theirDests: 561 suppress = True 562 yield ( 563 f"at {self.identityOf(n)}: other graph" 564 f" missing transition {tr!r}" 565 ) 566 else: 567 theirTo = theirDests[tr] 568 if myTo != theirTo: 569 suppress = True 570 yield ( 571 f"at {self.identityOf(n)}: other" 572 f" graph transition {tr!r} leads to" 573 f" {theirTo} instead of {myTo}" 574 ) 575 else: 576 myProps = self.edges[n, myTo, tr] # type:ignore [index] # noqa 577 theirProps = other.edges[n, myTo, tr] # type:ignore [index] # noqa 578 if myProps != theirProps: 579 suppress = True 580 yield ( 581 f"at {self.identityOf(n)}: other" 582 f" graph transition {tr!r} has" 583 f" different properties:" 584 f"\n Ours: {myProps}" 585 f"\nTheirs: {theirProps}" 586 ) 587 for extra in theirNodes - myNodes: 588 suppress = True 589 yield ( 590 f"other graph has extra node {extra}" 591 ) 592 593 # TODO: Fix networkx stubs! 594 if self.graph != other.graph: # type:ignore [attr-defined] 595 suppress = True 596 yield ( 597 " different graph attributes:" # type:ignore [attr-defined] # noqa 598 f"\n Ours: {self.graph}" 599 f"\nTheirs: {other.graph}" 600 ) 601 602 # Checks any other graph data we might have missed 603 if not super().__eq__(other) and not suppress: 604 for attr in dir(self): 605 if attr.startswith('__') and attr.endswith('__'): 606 continue 607 if not hasattr(other, attr): 608 yield f"other graph missing attribute: {attr!r}" 609 else: 610 myVal = getattr(self, attr) 611 theirVal = getattr(other, attr) 612 if ( 613 myVal != theirVal 614 and not ((callable(myVal) and callable(theirVal))) 615 ): 616 yield ( 617 f"other has different val for {attr!r}:" 618 f"\n Ours: {myVal}" 619 f"\nTheirs: {theirVal}" 620 ) 621 for attr in sorted(set(dir(other)) - set(dir(self))): 622 yield f"other has extra attribute: {attr!r}" 623 yield "graph data is different" 624 # TODO: More detail here! 625 626 # Check unknown count 627 if self.unknownCount != other.unknownCount: 628 yield "unknown count is different" 629 630 # Check zones 631 if self.zones != other.zones: 632 yield "zones are different" 633 634 # Check equivalences 635 if self.equivalences != other.equivalences: 636 yield "equivalences are different" 637 638 # Check reversion types 639 if self.reversionTypes != other.reversionTypes: 640 yield "reversionTypes are different" 641 642 # Check mechanisms 643 if self.nextMechanismID != other.nextMechanismID: 644 yield "nextMechanismID is different" 645 646 if self.mechanisms != other.mechanisms: 647 yield "mechanisms are different" 648 649 if self.globalMechanisms != other.globalMechanisms: 650 yield "global mechanisms are different" 651 652 # Check names: 653 if self.nameLookup != other.nameLookup: 654 for name in self.nameLookup: 655 if name not in other.nameLookup: 656 yield ( 657 f"other graph is missing name lookup entry" 658 f" for {name!r}" 659 ) 660 else: 661 mine = self.nameLookup[name] 662 theirs = other.nameLookup[name] 663 if theirs != mine: 664 yield ( 665 f"name lookup for {name!r} is {theirs}" 666 f" instead of {mine}" 667 ) 668 extras = set(other.nameLookup) - set(self.nameLookup) 669 if extras: 670 yield ( 671 f"other graph has extra name lookup entries:" 672 f" {extras}" 673 ) 674 675 def _assignID(self) -> base.DecisionID: 676 """ 677 Returns the next `base.DecisionID` to use and increments the 678 next ID counter. 679 """ 680 result = self.nextID 681 self.nextID += 1 682 return result 683 684 def _assignMechanismID(self) -> base.MechanismID: 685 """ 686 Returns the next `base.MechanismID` to use and increments the 687 next ID counter. 688 """ 689 result = self.nextMechanismID 690 self.nextMechanismID += 1 691 return result 692 693 def decisionInfo(self, dID: base.DecisionID) -> DecisionInfo: 694 """ 695 Retrieves the decision info for the specified decision, as a 696 live editable dictionary. 697 698 For example: 699 700 >>> g = DecisionGraph() 701 >>> g.addDecision('A') 702 0 703 >>> g.annotateDecision('A', 'note') 704 >>> g.decisionInfo(0) 705 {'name': 'A', 'domain': 'main', 'tags': {}, 'annotations': ['note']} 706 """ 707 return cast(DecisionInfo, self.nodes[dID]) 708 709 def resolveDecision( 710 self, 711 spec: base.AnyDecisionSpecifier, 712 zoneHint: Optional[base.Zone] = None, 713 domainHint: Optional[base.Domain] = None 714 ) -> base.DecisionID: 715 """ 716 Given a decision specifier returns the ID associated with that 717 decision, or raises an `AmbiguousDecisionSpecifierError` or a 718 `MissingDecisionError` if the specified decision is either 719 missing or ambiguous. Cannot handle strings that contain domain 720 and/or zone parts; use 721 `parsing.ParseFormat.parseDecisionSpecifier` to turn such 722 strings into `DecisionSpecifier`s if you need to first. 723 724 Examples: 725 726 >>> g = DecisionGraph() 727 >>> g.addDecision('A') 728 0 729 >>> g.addDecision('B') 730 1 731 >>> g.addDecision('C') 732 2 733 >>> g.addDecision('A') 734 3 735 >>> g.addDecision('B', 'menu') 736 4 737 >>> g.createZone('Z', 0) 738 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 739 annotations=[]) 740 >>> g.createZone('Z2', 0) 741 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 742 annotations=[]) 743 >>> g.createZone('Zup', 1) 744 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 745 annotations=[]) 746 >>> g.addDecisionToZone(0, 'Z') 747 >>> g.addDecisionToZone(1, 'Z') 748 >>> g.addDecisionToZone(2, 'Z') 749 >>> g.addDecisionToZone(3, 'Z2') 750 >>> g.addZoneToZone('Z', 'Zup') 751 >>> g.addZoneToZone('Z2', 'Zup') 752 >>> g.resolveDecision(1) 753 1 754 >>> g.resolveDecision('A') 755 Traceback (most recent call last): 756 ... 757 exploration.core.AmbiguousDecisionSpecifierError... 758 >>> g.resolveDecision('B') 759 Traceback (most recent call last): 760 ... 761 exploration.core.AmbiguousDecisionSpecifierError... 762 >>> g.resolveDecision('C') 763 2 764 >>> g.resolveDecision('A', 'Z') 765 0 766 >>> g.resolveDecision('A', zoneHint='Z2') 767 3 768 >>> g.resolveDecision('B', domainHint='main') 769 1 770 >>> g.resolveDecision('B', None, 'menu') 771 4 772 >>> g.resolveDecision('B', zoneHint='Z2') 773 Traceback (most recent call last): 774 ... 775 exploration.core.MissingDecisionError... 776 >>> g.resolveDecision('A', domainHint='menu') 777 Traceback (most recent call last): 778 ... 779 exploration.core.MissingDecisionError... 780 >>> g.resolveDecision('A', domainHint='madeup') 781 Traceback (most recent call last): 782 ... 783 exploration.core.MissingDecisionError... 784 >>> g.resolveDecision('A', zoneHint='madeup') 785 Traceback (most recent call last): 786 ... 787 exploration.core.MissingDecisionError... 788 """ 789 # Parse it to either an ID or specifier if it's a string: 790 if isinstance(spec, str): 791 try: 792 spec = int(spec) 793 except ValueError: 794 pass 795 796 # If it's an ID, check for existence: 797 if isinstance(spec, base.DecisionID): 798 if spec in self: 799 return spec 800 else: 801 raise MissingDecisionError( 802 f"There is no decision with ID {spec!r}." 803 ) 804 else: 805 if isinstance(spec, base.DecisionName): 806 spec = base.DecisionSpecifier( 807 domain=None, 808 zone=None, 809 name=spec 810 ) 811 elif not isinstance(spec, base.DecisionSpecifier): 812 raise TypeError( 813 f"Specification is not provided as a" 814 f" DecisionSpecifier or other valid type. (got type" 815 f" {type(spec)})." 816 ) 817 818 # Merge domain hints from spec/args 819 if ( 820 spec.domain is not None 821 and domainHint is not None 822 and spec.domain != domainHint 823 ): 824 raise ValueError( 825 f"Specifier {repr(spec)} includes domain hint" 826 f" {repr(spec.domain)} which is incompatible with" 827 f" explicit domain hint {repr(domainHint)}." 828 ) 829 else: 830 domainHint = spec.domain or domainHint 831 832 # Merge zone hints from spec/args 833 if ( 834 spec.zone is not None 835 and zoneHint is not None 836 and spec.zone != zoneHint 837 ): 838 raise ValueError( 839 f"Specifier {repr(spec)} includes zone hint" 840 f" {repr(spec.zone)} which is incompatible with" 841 f" explicit zone hint {repr(zoneHint)}." 842 ) 843 else: 844 zoneHint = spec.zone or zoneHint 845 846 if spec.name not in self.nameLookup: 847 raise MissingDecisionError( 848 f"No decision named {repr(spec.name)}." 849 ) 850 else: 851 options = self.nameLookup[spec.name] 852 if len(options) == 0: 853 raise MissingDecisionError( 854 f"No decision named {repr(spec.name)}." 855 ) 856 filtered = [ 857 opt 858 for opt in options 859 if ( 860 domainHint is None 861 or self.domainFor(opt) == domainHint 862 ) and ( 863 zoneHint is None 864 or zoneHint in self.zoneAncestors(opt) 865 ) 866 ] 867 if len(filtered) == 1: 868 return filtered[0] 869 else: 870 filterDesc = "" 871 if domainHint is not None: 872 filterDesc += f" in domain {repr(domainHint)}" 873 if zoneHint is not None: 874 filterDesc += f" in zone {repr(zoneHint)}" 875 if len(filtered) == 0: 876 raise MissingDecisionError( 877 f"No decisions named" 878 f" {repr(spec.name)}{filterDesc}." 879 ) 880 else: 881 raise AmbiguousDecisionSpecifierError( 882 f"There are {len(filtered)} decisions" 883 f" named {repr(spec.name)}{filterDesc}." 884 ) 885 886 def getDecision( 887 self, 888 decision: base.AnyDecisionSpecifier, 889 zoneHint: Optional[base.Zone] = None, 890 domainHint: Optional[base.Domain] = None 891 ) -> Optional[base.DecisionID]: 892 """ 893 Works like `resolveDecision` but returns None instead of raising 894 a `MissingDecisionError` if the specified decision isn't listed. 895 May still raise an `AmbiguousDecisionSpecifierError`. 896 """ 897 try: 898 return self.resolveDecision( 899 decision, 900 zoneHint, 901 domainHint 902 ) 903 except MissingDecisionError: 904 return None 905 906 def nameFor( 907 self, 908 decision: base.AnyDecisionSpecifier 909 ) -> base.DecisionName: 910 """ 911 Returns the name of the specified decision. Note that names are 912 not necessarily unique. 913 914 Example: 915 916 >>> d = DecisionGraph() 917 >>> d.addDecision('A') 918 0 919 >>> d.addDecision('B') 920 1 921 >>> d.addDecision('B') 922 2 923 >>> d.nameFor(0) 924 'A' 925 >>> d.nameFor(1) 926 'B' 927 >>> d.nameFor(2) 928 'B' 929 >>> d.nameFor(3) 930 Traceback (most recent call last): 931 ... 932 exploration.core.MissingDecisionError... 933 """ 934 dID = self.resolveDecision(decision) 935 return self.nodes[dID]['name'] 936 937 def identityOf( 938 self, 939 decision: Optional[base.AnyDecisionSpecifier], 940 includeZones: bool = True, 941 alwaysDomain: Optional[bool] = None 942 ) -> str: 943 """ 944 Returns a string containing the given decision ID and the name 945 for that decision in parentheses afterwards. If the value 946 provided is `None`, it returns the string "(nowhere)". 947 948 If `includeZones` is true (the default) then zone information 949 is included before the decision name. 950 951 If `alwaysDomain` is true or false, then the domain information 952 will always (or never) be included. If it's `None` (the default) 953 then domain info will only be included for decisions which are 954 not in the default domain. 955 """ 956 if decision is None: 957 return "(nowhere)" 958 else: 959 dID = self.resolveDecision(decision) 960 thisDomain = self.domainFor(dID) 961 dSpec = '' 962 zSpec = '' 963 if ( 964 alwaysDomain is True 965 or ( 966 alwaysDomain is None 967 and thisDomain != base.DEFAULT_DOMAIN 968 ) 969 ): 970 dSpec = thisDomain + '//' # TODO: Don't hardcode this? 971 if includeZones: 972 zones = [ 973 z 974 for z in self.zoneParents(dID) 975 if self.zones[z].level == 0 976 ] 977 if len(zones) == 1: 978 zSpec = zones[0] + '::' # TODO: Don't hardcode this? 979 elif len(zones) > 1: 980 zSpec = '[' + ', '.join(sorted(zones)) + ']::' 981 # else leave zSpec empty 982 983 return f"{dID} ({dSpec}{zSpec}{self.nameFor(dID)})" 984 985 def namesListing( 986 self, 987 decisions: Collection[base.DecisionID], 988 includeZones: bool = True, 989 indent: int = 2 990 ) -> str: 991 """ 992 Returns a multi-line string containing an indented listing of 993 the provided decision IDs with their names in parentheses after 994 each. Useful for debugging & error messages. 995 996 Includes level-0 zones where applicable, with a zone separator 997 before the decision, unless `includeZones` is set to False. Where 998 there are multiple level-0 zones, they're listed together in 999 brackets. 1000 1001 Uses the string '(none)' when there are no decisions are in the 1002 list. 1003 1004 Set `indent` to something other than 2 to control how much 1005 indentation is added. 1006 1007 For example: 1008 1009 >>> g = DecisionGraph() 1010 >>> g.addDecision('A') 1011 0 1012 >>> g.addDecision('B') 1013 1 1014 >>> g.addDecision('C') 1015 2 1016 >>> g.namesListing(['A', 'C', 'B']) 1017 ' 0 (A)\\n 2 (C)\\n 1 (B)\\n' 1018 >>> g.namesListing([]) 1019 ' (none)\\n' 1020 >>> g.createZone('zone', 0) 1021 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1022 annotations=[]) 1023 >>> g.createZone('zone2', 0) 1024 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1025 annotations=[]) 1026 >>> g.createZone('zoneUp', 1) 1027 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 1028 annotations=[]) 1029 >>> g.addDecisionToZone(0, 'zone') 1030 >>> g.addDecisionToZone(1, 'zone') 1031 >>> g.addDecisionToZone(1, 'zone2') 1032 >>> g.addDecisionToZone(2, 'zoneUp') # won't be listed: it's level-1 1033 >>> g.namesListing(['A', 'C', 'B']) 1034 ' 0 (zone::A)\\n 2 (C)\\n 1 ([zone, zone2]::B)\\n' 1035 """ 1036 ind = ' ' * indent 1037 if len(decisions) == 0: 1038 return ind + '(none)\n' 1039 else: 1040 result = '' 1041 for dID in decisions: 1042 result += ind + self.identityOf(dID, includeZones) + '\n' 1043 return result 1044 1045 def destinationsListing( 1046 self, 1047 destinations: Dict[base.Transition, base.DecisionID], 1048 includeZones: bool = True, 1049 indent: int = 2 1050 ) -> str: 1051 """ 1052 Returns a multi-line string containing an indented listing of 1053 the provided transitions along with their destinations and the 1054 names of those destinations in parentheses. Useful for debugging 1055 & error messages. (Use e.g., `destinationsFrom` to get a 1056 transitions -> destinations dictionary in the required format.) 1057 1058 Uses the string '(no transitions)' when there are no transitions 1059 in the dictionary. 1060 1061 Set `indent` to something other than 2 to control how much 1062 indentation is added. 1063 1064 For example: 1065 1066 >>> g = DecisionGraph() 1067 >>> g.addDecision('A') 1068 0 1069 >>> g.addDecision('B') 1070 1 1071 >>> g.addDecision('C') 1072 2 1073 >>> g.addTransition('A', 'north', 'B', 'south') 1074 >>> g.addTransition('B', 'east', 'C', 'west') 1075 >>> g.addTransition('C', 'southwest', 'A', 'northeast') 1076 >>> g.destinationsListing(g.destinationsFrom('A')) 1077 ' north to 1 (B)\\n northeast to 2 (C)\\n' 1078 >>> g.destinationsListing(g.destinationsFrom('B')) 1079 ' south to 0 (A)\\n east to 2 (C)\\n' 1080 >>> g.destinationsListing({}) 1081 ' (none)\\n' 1082 >>> g.createZone('zone', 0) 1083 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1084 annotations=[]) 1085 >>> g.addDecisionToZone(0, 'zone') 1086 >>> g.destinationsListing(g.destinationsFrom('B')) 1087 ' south to 0 (zone::A)\\n east to 2 (C)\\n' 1088 """ 1089 ind = ' ' * indent 1090 if len(destinations) == 0: 1091 return ind + '(none)\n' 1092 else: 1093 result = '' 1094 for transition, dID in destinations.items(): 1095 line = f"{transition} to {self.identityOf(dID, includeZones)}" 1096 result += ind + line + '\n' 1097 return result 1098 1099 def domainFor(self, decision: base.AnyDecisionSpecifier) -> base.Domain: 1100 """ 1101 Returns the domain that a decision belongs to. 1102 """ 1103 dID = self.resolveDecision(decision) 1104 return self.nodes[dID]['domain'] 1105 1106 def allDecisionsInDomain( 1107 self, 1108 domain: base.Domain 1109 ) -> Set[base.DecisionID]: 1110 """ 1111 Returns the set of all `DecisionID`s for decisions in the 1112 specified domain. 1113 """ 1114 return set(dID for dID in self if self.nodes[dID]['domain'] == domain) 1115 1116 def destination( 1117 self, 1118 decision: base.AnyDecisionSpecifier, 1119 transition: base.Transition 1120 ) -> base.DecisionID: 1121 """ 1122 Overrides base `UniqueExitsGraph.destination` to raise 1123 `MissingDecisionError` or `MissingTransitionError` as 1124 appropriate, and to work with an `AnyDecisionSpecifier`. 1125 """ 1126 dID = self.resolveDecision(decision) 1127 try: 1128 return super().destination(dID, transition) 1129 except KeyError: 1130 raise MissingTransitionError( 1131 f"Transition {transition!r} does not exist at decision" 1132 f" {self.identityOf(dID)}." 1133 ) 1134 1135 def getDestination( 1136 self, 1137 decision: base.AnyDecisionSpecifier, 1138 transition: base.Transition, 1139 default: Any = None 1140 ) -> Optional[base.DecisionID]: 1141 """ 1142 Overrides base `UniqueExitsGraph.getDestination` with different 1143 argument names, since those matter for the edit DSL. 1144 """ 1145 dID = self.resolveDecision(decision) 1146 return super().getDestination(dID, transition) 1147 1148 def destinationsFrom( 1149 self, 1150 decision: base.AnyDecisionSpecifier 1151 ) -> Dict[base.Transition, base.DecisionID]: 1152 """ 1153 Override that just changes the type of the exception from a 1154 `KeyError` to a `MissingDecisionError` when the source does not 1155 exist. 1156 """ 1157 dID = self.resolveDecision(decision) 1158 return super().destinationsFrom(dID) 1159 1160 def bothEnds( 1161 self, 1162 decision: base.AnyDecisionSpecifier, 1163 transition: base.Transition 1164 ) -> Set[base.DecisionID]: 1165 """ 1166 Returns a set containing the `DecisionID`(s) for both the start 1167 and end of the specified transition. Raises a 1168 `MissingDecisionError` or `MissingTransitionError`if the 1169 specified decision and/or transition do not exist. 1170 1171 Note that for actions since the source and destination are the 1172 same, the set will have only one element. 1173 """ 1174 dID = self.resolveDecision(decision) 1175 result = {dID} 1176 dest = self.destination(dID, transition) 1177 if dest is not None: 1178 result.add(dest) 1179 return result 1180 1181 def decisionActions( 1182 self, 1183 decision: base.AnyDecisionSpecifier 1184 ) -> Set[base.Transition]: 1185 """ 1186 Retrieves the set of self-edges at a decision. Editing the set 1187 will not affect the graph. 1188 1189 Example: 1190 1191 >>> g = DecisionGraph() 1192 >>> g.addDecision('A') 1193 0 1194 >>> g.addDecision('B') 1195 1 1196 >>> g.addDecision('C') 1197 2 1198 >>> g.addAction('A', 'action1') 1199 >>> g.addAction('A', 'action2') 1200 >>> g.addAction('B', 'action3') 1201 >>> sorted(g.decisionActions('A')) 1202 ['action1', 'action2'] 1203 >>> g.decisionActions('B') 1204 {'action3'} 1205 >>> g.decisionActions('C') 1206 set() 1207 """ 1208 result = set() 1209 dID = self.resolveDecision(decision) 1210 for transition, dest in self.destinationsFrom(dID).items(): 1211 if dest == dID: 1212 result.add(transition) 1213 return result 1214 1215 def getTransitionProperties( 1216 self, 1217 decision: base.AnyDecisionSpecifier, 1218 transition: base.Transition 1219 ) -> TransitionProperties: 1220 """ 1221 Returns a dictionary containing transition properties for the 1222 specified transition from the specified decision. The properties 1223 included are: 1224 1225 - 'requirement': The requirement for the transition. 1226 - 'consequence': Any consequence of the transition. 1227 - 'tags': Any tags applied to the transition. 1228 - 'annotations': Any annotations on the transition. 1229 1230 The reciprocal of the transition is not included. 1231 1232 The result is a clone of the stored properties; edits to the 1233 dictionary will NOT modify the graph. 1234 """ 1235 dID = self.resolveDecision(decision) 1236 dest = self.destination(dID, transition) 1237 1238 info: TransitionProperties = copy.deepcopy( 1239 self.edges[dID, dest, transition] # type:ignore 1240 ) 1241 return { 1242 'requirement': info.get('requirement', base.ReqNothing()), 1243 'consequence': info.get('consequence', []), 1244 'tags': info.get('tags', {}), 1245 'annotations': info.get('annotations', []) 1246 } 1247 1248 def setTransitionProperties( 1249 self, 1250 decision: base.AnyDecisionSpecifier, 1251 transition: base.Transition, 1252 requirement: Optional[base.Requirement] = None, 1253 consequence: Optional[base.Consequence] = None, 1254 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 1255 annotations: Optional[List[base.Annotation]] = None 1256 ) -> None: 1257 """ 1258 Sets one or more transition properties all at once. Can be used 1259 to set the requirement, consequence, tags, and/or annotations. 1260 Old values are overwritten, although if `None`s are provided (or 1261 arguments are omitted), corresponding properties are not 1262 updated. 1263 1264 To add tags or annotations to existing tags/annotations instead 1265 of replacing them, use `tagTransition` or `annotateTransition` 1266 instead. 1267 """ 1268 dID = self.resolveDecision(decision) 1269 if requirement is not None: 1270 self.setTransitionRequirement(dID, transition, requirement) 1271 if consequence is not None: 1272 self.setConsequence(dID, transition, consequence) 1273 if tags is not None: 1274 dest = self.destination(dID, transition) 1275 # TODO: Submit pull request to update MultiDiGraph stubs in 1276 # types-networkx to include OutMultiEdgeView that accepts 1277 # from/to/key tuples as indices. 1278 info = cast( 1279 TransitionProperties, 1280 self.edges[dID, dest, transition] # type:ignore 1281 ) 1282 info['tags'] = tags 1283 if annotations is not None: 1284 dest = self.destination(dID, transition) 1285 info = cast( 1286 TransitionProperties, 1287 self.edges[dID, dest, transition] # type:ignore 1288 ) 1289 info['annotations'] = annotations 1290 1291 def getTransitionRequirement( 1292 self, 1293 decision: base.AnyDecisionSpecifier, 1294 transition: base.Transition 1295 ) -> base.Requirement: 1296 """ 1297 Returns the `Requirement` for accessing a specific transition at 1298 a specific decision. For transitions which don't have 1299 requirements, returns a `ReqNothing` instance. 1300 """ 1301 dID = self.resolveDecision(decision) 1302 dest = self.destination(dID, transition) 1303 1304 info = cast( 1305 TransitionProperties, 1306 self.edges[dID, dest, transition] # type:ignore 1307 ) 1308 1309 return info.get('requirement', base.ReqNothing()) 1310 1311 def setTransitionRequirement( 1312 self, 1313 decision: base.AnyDecisionSpecifier, 1314 transition: base.Transition, 1315 requirement: Optional[base.Requirement] 1316 ) -> None: 1317 """ 1318 Sets the `Requirement` for accessing a specific transition at 1319 a specific decision. Raises a `KeyError` if the decision or 1320 transition does not exist. 1321 1322 Deletes the requirement if `None` is given as the requirement. 1323 1324 Use `parsing.ParseFormat.parseRequirement` first if you have a 1325 requirement in string format. 1326 1327 Does not raise an error if deletion is requested for a 1328 non-existent requirement, and silently overwrites any previous 1329 requirement. 1330 """ 1331 dID = self.resolveDecision(decision) 1332 1333 dest = self.destination(dID, transition) 1334 1335 info = cast( 1336 TransitionProperties, 1337 self.edges[dID, dest, transition] # type:ignore 1338 ) 1339 1340 if requirement is None: 1341 try: 1342 del info['requirement'] 1343 except KeyError: 1344 pass 1345 else: 1346 if not isinstance(requirement, base.Requirement): 1347 raise TypeError( 1348 f"Invalid requirement type: {type(requirement)}" 1349 ) 1350 1351 info['requirement'] = requirement 1352 1353 def getConsequence( 1354 self, 1355 decision: base.AnyDecisionSpecifier, 1356 transition: base.Transition 1357 ) -> base.Consequence: 1358 """ 1359 Retrieves the consequence of a transition. 1360 1361 A `KeyError` is raised if the specified decision/transition 1362 combination doesn't exist. 1363 """ 1364 dID = self.resolveDecision(decision) 1365 1366 dest = self.destination(dID, transition) 1367 1368 info = cast( 1369 TransitionProperties, 1370 self.edges[dID, dest, transition] # type:ignore 1371 ) 1372 1373 return info.get('consequence', []) 1374 1375 def addConsequence( 1376 self, 1377 decision: base.AnyDecisionSpecifier, 1378 transition: base.Transition, 1379 consequence: base.Consequence 1380 ) -> Tuple[int, int]: 1381 """ 1382 Adds the given `Consequence` to the consequence list for the 1383 specified transition, extending that list at the end. Note that 1384 this does NOT make a copy of the consequence, so it should not 1385 be used to copy consequences from one transition to another 1386 without making a deep copy first. 1387 1388 A `MissingDecisionError` or a `MissingTransitionError` is raised 1389 if the specified decision/transition combination doesn't exist. 1390 1391 Returns a pair of integers indicating the minimum and maximum 1392 depth-first-traversal-indices of the added consequence part(s). 1393 The outer consequence list itself (index 0) is not counted. 1394 1395 >>> d = DecisionGraph() 1396 >>> d.addDecision('A') 1397 0 1398 >>> d.addDecision('B') 1399 1 1400 >>> d.addTransition('A', 'fwd', 'B', 'rev') 1401 >>> d.addConsequence('A', 'fwd', [base.effect(gain='sword')]) 1402 (1, 1) 1403 >>> d.addConsequence('B', 'rev', [base.effect(lose='sword')]) 1404 (1, 1) 1405 >>> ef = d.getConsequence('A', 'fwd') 1406 >>> er = d.getConsequence('B', 'rev') 1407 >>> ef == [base.effect(gain='sword')] 1408 True 1409 >>> er == [base.effect(lose='sword')] 1410 True 1411 >>> d.addConsequence('A', 'fwd', [base.effect(deactivate=True)]) 1412 (2, 2) 1413 >>> ef = d.getConsequence('A', 'fwd') 1414 >>> ef == [base.effect(gain='sword'), base.effect(deactivate=True)] 1415 True 1416 >>> d.addConsequence( 1417 ... 'A', 1418 ... 'fwd', # adding to consequence with 3 parts already 1419 ... [ # outer list not counted because it merges 1420 ... base.challenge( # 1 part 1421 ... None, 1422 ... 0, 1423 ... [base.effect(gain=('flowers', 3))], # 2 parts 1424 ... [base.effect(gain=('flowers', 1))] # 2 parts 1425 ... ) 1426 ... ] 1427 ... ) # note indices below are inclusive; indices are 3, 4, 5, 6, 7 1428 (3, 7) 1429 """ 1430 dID = self.resolveDecision(decision) 1431 1432 dest = self.destination(dID, transition) 1433 1434 info = cast( 1435 TransitionProperties, 1436 self.edges[dID, dest, transition] # type:ignore 1437 ) 1438 1439 existing = info.setdefault('consequence', []) 1440 startIndex = base.countParts(existing) 1441 existing.extend(consequence) 1442 endIndex = base.countParts(existing) - 1 1443 return (startIndex, endIndex) 1444 1445 def setConsequence( 1446 self, 1447 decision: base.AnyDecisionSpecifier, 1448 transition: base.Transition, 1449 consequence: base.Consequence 1450 ) -> None: 1451 """ 1452 Replaces the transition consequence for the given transition at 1453 the given decision. Any previous consequence is discarded. See 1454 `Consequence` for the structure of these. Note that this does 1455 NOT make a copy of the consequence, do that first to avoid 1456 effect-entanglement if you're copying a consequence. 1457 1458 A `MissingDecisionError` or a `MissingTransitionError` is raised 1459 if the specified decision/transition combination doesn't exist. 1460 """ 1461 dID = self.resolveDecision(decision) 1462 1463 dest = self.destination(dID, transition) 1464 1465 info = cast( 1466 TransitionProperties, 1467 self.edges[dID, dest, transition] # type:ignore 1468 ) 1469 1470 info['consequence'] = consequence 1471 1472 def addEquivalence( 1473 self, 1474 requirement: base.Requirement, 1475 capabilityOrMechanismState: Union[ 1476 base.Capability, 1477 Tuple[base.MechanismID, base.MechanismState] 1478 ] 1479 ) -> None: 1480 """ 1481 Adds the given requirement as an equivalence for the given 1482 capability or the given mechanism state. Note that having a 1483 capability via an equivalence does not count as actually having 1484 that capability; it only counts for the purpose of satisfying 1485 `Requirement`s. 1486 1487 Note also that because a mechanism-based requirement looks up 1488 the specific mechanism locally based on a name, an equivalence 1489 defined in one location may affect mechanism requirements in 1490 other locations unless the mechanism name in the requirement is 1491 zone-qualified to be specific. But in such situations the base 1492 mechanism would have caused issues in any case. 1493 """ 1494 self.equivalences.setdefault( 1495 capabilityOrMechanismState, 1496 set() 1497 ).add(requirement) 1498 1499 def removeEquivalence( 1500 self, 1501 requirement: base.Requirement, 1502 capabilityOrMechanismState: Union[ 1503 base.Capability, 1504 Tuple[base.MechanismID, base.MechanismState] 1505 ] 1506 ) -> None: 1507 """ 1508 Removes an equivalence. Raises a `KeyError` if no such 1509 equivalence existed. 1510 """ 1511 self.equivalences[capabilityOrMechanismState].remove(requirement) 1512 1513 def hasAnyEquivalents( 1514 self, 1515 capabilityOrMechanismState: Union[ 1516 base.Capability, 1517 Tuple[base.MechanismID, base.MechanismState] 1518 ] 1519 ) -> bool: 1520 """ 1521 Returns `True` if the given capability or mechanism state has at 1522 least one equivalence. 1523 """ 1524 return capabilityOrMechanismState in self.equivalences 1525 1526 def allEquivalents( 1527 self, 1528 capabilityOrMechanismState: Union[ 1529 base.Capability, 1530 Tuple[base.MechanismID, base.MechanismState] 1531 ] 1532 ) -> Set[base.Requirement]: 1533 """ 1534 Returns the set of equivalences for the given capability. This is 1535 a live set which may be modified (it's probably better to use 1536 `addEquivalence` and `removeEquivalence` instead...). 1537 """ 1538 return self.equivalences.setdefault( 1539 capabilityOrMechanismState, 1540 set() 1541 ) 1542 1543 def reversionType(self, name: str, equivalentTo: Set[str]) -> None: 1544 """ 1545 Specifies a new reversion type, so that when used in a reversion 1546 aspects set with a colon before the name, all items in the 1547 `equivalentTo` value will be added to that set. These may 1548 include other custom reversion type names (with the colon) but 1549 take care not to create an equivalence loop which would result 1550 in a crash. 1551 1552 If you re-use the same name, it will override the old equivalence 1553 for that name. 1554 """ 1555 self.reversionTypes[name] = equivalentTo 1556 1557 def addAction( 1558 self, 1559 decision: base.AnyDecisionSpecifier, 1560 action: base.Transition, 1561 requires: Optional[base.Requirement] = None, 1562 consequence: Optional[base.Consequence] = None, 1563 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 1564 annotations: Optional[List[base.Annotation]] = None, 1565 ) -> None: 1566 """ 1567 Adds the given action as a possibility at the given decision. An 1568 action is just a self-edge, which can have requirements like any 1569 edge, and which can have consequences like any edge. 1570 The optional arguments are given to `setTransitionRequirement` 1571 and `setConsequence`; see those functions for descriptions 1572 of what they mean. 1573 1574 Raises a `KeyError` if a transition with the given name already 1575 exists at the given decision. 1576 """ 1577 if tags is None: 1578 tags = {} 1579 if annotations is None: 1580 annotations = [] 1581 1582 dID = self.resolveDecision(decision) 1583 1584 self.add_edge( 1585 dID, 1586 dID, 1587 key=action, 1588 tags=tags, 1589 annotations=annotations 1590 ) 1591 self.setTransitionRequirement(dID, action, requires) 1592 if consequence is not None: 1593 self.setConsequence(dID, action, consequence) 1594 1595 def tagDecision( 1596 self, 1597 decision: base.AnyDecisionSpecifier, 1598 tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]], 1599 tagValue: Union[ 1600 base.TagValue, 1601 type[base.NoTagValue] 1602 ] = base.NoTagValue 1603 ) -> None: 1604 """ 1605 Adds a tag (or many tags from a dictionary of tags) to a 1606 decision, using `1` as the value if no value is provided. It's 1607 a `ValueError` to provide a value when a dictionary of tags is 1608 provided to set multiple tags at once. 1609 1610 Note that certain tags have special meanings: 1611 1612 - 'unconfirmed' is used for decisions that represent unconfirmed 1613 parts of the graph (this is separate from the 'unknown' 1614 and/or 'hypothesized' exploration statuses, which are only 1615 tracked in a `DiscreteExploration`, not in a `DecisionGraph`). 1616 Various methods require this tag and many also add or remove 1617 it. 1618 """ 1619 if isinstance(tagOrTags, base.Tag): 1620 if tagValue is base.NoTagValue: 1621 tagValue = 1 1622 1623 # Not sure why this cast is necessary given the `if` above... 1624 tagValue = cast(base.TagValue, tagValue) 1625 1626 tagOrTags = {tagOrTags: tagValue} 1627 1628 elif tagValue is not base.NoTagValue: 1629 raise ValueError( 1630 "Provided a dictionary to update multiple tags, but" 1631 " also a tag value." 1632 ) 1633 1634 dID = self.resolveDecision(decision) 1635 1636 tagsAlready = self.nodes[dID].setdefault('tags', {}) 1637 tagsAlready.update(tagOrTags) 1638 1639 def untagDecision( 1640 self, 1641 decision: base.AnyDecisionSpecifier, 1642 tag: base.Tag 1643 ) -> Union[base.TagValue, type[base.NoTagValue]]: 1644 """ 1645 Removes a tag from a decision. Returns the tag's old value if 1646 the tag was present and got removed, or `NoTagValue` if the tag 1647 wasn't present. 1648 """ 1649 dID = self.resolveDecision(decision) 1650 1651 target = self.nodes[dID]['tags'] 1652 try: 1653 return target.pop(tag) 1654 except KeyError: 1655 return base.NoTagValue 1656 1657 def decisionTags( 1658 self, 1659 decision: base.AnyDecisionSpecifier 1660 ) -> Dict[base.Tag, base.TagValue]: 1661 """ 1662 Returns the dictionary of tags for a decision. Edits to the 1663 returned value will be applied to the graph. 1664 """ 1665 dID = self.resolveDecision(decision) 1666 1667 return self.nodes[dID]['tags'] 1668 1669 def annotateDecision( 1670 self, 1671 decision: base.AnyDecisionSpecifier, 1672 annotationOrAnnotations: Union[ 1673 base.Annotation, 1674 Sequence[base.Annotation] 1675 ] 1676 ) -> None: 1677 """ 1678 Adds an annotation to a decision's annotations list. 1679 """ 1680 dID = self.resolveDecision(decision) 1681 1682 if isinstance(annotationOrAnnotations, base.Annotation): 1683 annotationOrAnnotations = [annotationOrAnnotations] 1684 self.nodes[dID]['annotations'].extend(annotationOrAnnotations) 1685 1686 def decisionAnnotations( 1687 self, 1688 decision: base.AnyDecisionSpecifier 1689 ) -> List[base.Annotation]: 1690 """ 1691 Returns the list of annotations for the specified decision. 1692 Modifying the list affects the graph. 1693 """ 1694 dID = self.resolveDecision(decision) 1695 1696 return self.nodes[dID]['annotations'] 1697 1698 def tagTransition( 1699 self, 1700 decision: base.AnyDecisionSpecifier, 1701 transition: base.Transition, 1702 tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]], 1703 tagValue: Union[ 1704 base.TagValue, 1705 type[base.NoTagValue] 1706 ] = base.NoTagValue 1707 ) -> None: 1708 """ 1709 Adds a tag (or each tag from a dictionary) to a transition 1710 coming out of a specific decision. `1` will be used as the 1711 default value if a single tag is supplied; supplying a tag value 1712 when providing a dictionary of multiple tags to update is a 1713 `ValueError`. 1714 1715 Note that certain transition tags have special meanings: 1716 - 'trigger' causes any actions (but not normal transitions) that 1717 it applies to to be automatically triggered when 1718 `advanceSituation` is called and the decision they're 1719 attached to is active in the new situation (as long as the 1720 action's requirements are met). This happens once per 1721 situation; use 'wait' steps to re-apply triggers. 1722 """ 1723 dID = self.resolveDecision(decision) 1724 1725 dest = self.destination(dID, transition) 1726 if isinstance(tagOrTags, base.Tag): 1727 if tagValue is base.NoTagValue: 1728 tagValue = 1 1729 1730 # Not sure why this is necessary given the `if` above... 1731 tagValue = cast(base.TagValue, tagValue) 1732 1733 tagOrTags = {tagOrTags: tagValue} 1734 elif tagValue is not base.NoTagValue: 1735 raise ValueError( 1736 "Provided a dictionary to update multiple tags, but" 1737 " also a tag value." 1738 ) 1739 1740 info = cast( 1741 TransitionProperties, 1742 self.edges[dID, dest, transition] # type:ignore 1743 ) 1744 1745 info.setdefault('tags', {}).update(tagOrTags) 1746 1747 def untagTransition( 1748 self, 1749 decision: base.AnyDecisionSpecifier, 1750 transition: base.Transition, 1751 tagOrTags: Union[base.Tag, Set[base.Tag]] 1752 ) -> None: 1753 """ 1754 Removes a tag (or each tag in a set) from a transition coming out 1755 of a specific decision. Raises a `KeyError` if (one of) the 1756 specified tag(s) is not currently applied to the specified 1757 transition. 1758 """ 1759 dID = self.resolveDecision(decision) 1760 1761 dest = self.destination(dID, transition) 1762 if isinstance(tagOrTags, base.Tag): 1763 tagOrTags = {tagOrTags} 1764 1765 info = cast( 1766 TransitionProperties, 1767 self.edges[dID, dest, transition] # type:ignore 1768 ) 1769 tagsAlready = info.setdefault('tags', {}) 1770 1771 for tag in tagOrTags: 1772 tagsAlready.pop(tag) 1773 1774 def transitionTags( 1775 self, 1776 decision: base.AnyDecisionSpecifier, 1777 transition: base.Transition 1778 ) -> Dict[base.Tag, base.TagValue]: 1779 """ 1780 Returns the dictionary of tags for a transition. Edits to the 1781 returned dictionary will be applied to the graph. 1782 """ 1783 dID = self.resolveDecision(decision) 1784 1785 dest = self.destination(dID, transition) 1786 info = cast( 1787 TransitionProperties, 1788 self.edges[dID, dest, transition] # type:ignore 1789 ) 1790 return info.setdefault('tags', {}) 1791 1792 def annotateTransition( 1793 self, 1794 decision: base.AnyDecisionSpecifier, 1795 transition: base.Transition, 1796 annotations: Union[base.Annotation, Sequence[base.Annotation]] 1797 ) -> None: 1798 """ 1799 Adds an annotation (or a sequence of annotations) to a 1800 transition's annotations list. 1801 """ 1802 dID = self.resolveDecision(decision) 1803 1804 dest = self.destination(dID, transition) 1805 if isinstance(annotations, base.Annotation): 1806 annotations = [annotations] 1807 info = cast( 1808 TransitionProperties, 1809 self.edges[dID, dest, transition] # type:ignore 1810 ) 1811 info['annotations'].extend(annotations) 1812 1813 def transitionAnnotations( 1814 self, 1815 decision: base.AnyDecisionSpecifier, 1816 transition: base.Transition 1817 ) -> List[base.Annotation]: 1818 """ 1819 Returns the annotation list for a specific transition at a 1820 specific decision. Editing the list affects the graph. 1821 """ 1822 dID = self.resolveDecision(decision) 1823 1824 dest = self.destination(dID, transition) 1825 info = cast( 1826 TransitionProperties, 1827 self.edges[dID, dest, transition] # type:ignore 1828 ) 1829 return info['annotations'] 1830 1831 def annotateZone( 1832 self, 1833 zone: base.Zone, 1834 annotations: Union[base.Annotation, Sequence[base.Annotation]] 1835 ) -> None: 1836 """ 1837 Adds an annotation (or many annotations from a sequence) to a 1838 zone. 1839 1840 Raises a `MissingZoneError` if the specified zone does not exist. 1841 """ 1842 if zone not in self.zones: 1843 raise MissingZoneError( 1844 f"Can't add annotation(s) to zone {zone!r} because that" 1845 f" zone doesn't exist yet." 1846 ) 1847 1848 if isinstance(annotations, base.Annotation): 1849 annotations = [ annotations ] 1850 1851 self.zones[zone].annotations.extend(annotations) 1852 1853 def zoneAnnotations(self, zone: base.Zone) -> List[base.Annotation]: 1854 """ 1855 Returns the list of annotations for the specified zone (empty if 1856 none have been added yet). 1857 """ 1858 return self.zones[zone].annotations 1859 1860 def tagZone( 1861 self, 1862 zone: base.Zone, 1863 tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]], 1864 tagValue: Union[ 1865 base.TagValue, 1866 type[base.NoTagValue] 1867 ] = base.NoTagValue 1868 ) -> None: 1869 """ 1870 Adds a tag (or many tags from a dictionary of tags) to a 1871 zone, using `1` as the value if no value is provided. It's 1872 a `ValueError` to provide a value when a dictionary of tags is 1873 provided to set multiple tags at once. 1874 1875 Raises a `MissingZoneError` if the specified zone does not exist. 1876 """ 1877 if zone not in self.zones: 1878 raise MissingZoneError( 1879 f"Can't add tag(s) to zone {zone!r} because that zone" 1880 f" doesn't exist yet." 1881 ) 1882 1883 if isinstance(tagOrTags, base.Tag): 1884 if tagValue is base.NoTagValue: 1885 tagValue = 1 1886 1887 # Not sure why this cast is necessary given the `if` above... 1888 tagValue = cast(base.TagValue, tagValue) 1889 1890 tagOrTags = {tagOrTags: tagValue} 1891 1892 elif tagValue is not base.NoTagValue: 1893 raise ValueError( 1894 "Provided a dictionary to update multiple tags, but" 1895 " also a tag value." 1896 ) 1897 1898 tagsAlready = self.zones[zone].tags 1899 tagsAlready.update(tagOrTags) 1900 1901 def untagZone( 1902 self, 1903 zone: base.Zone, 1904 tag: base.Tag 1905 ) -> Union[base.TagValue, type[base.NoTagValue]]: 1906 """ 1907 Removes a tag from a zone. Returns the tag's old value if the 1908 tag was present and got removed, or `NoTagValue` if the tag 1909 wasn't present. 1910 1911 Raises a `MissingZoneError` if the specified zone does not exist. 1912 """ 1913 if zone not in self.zones: 1914 raise MissingZoneError( 1915 f"Can't remove tag {tag!r} from zone {zone!r} because" 1916 f" that zone doesn't exist yet." 1917 ) 1918 target = self.zones[zone].tags 1919 try: 1920 return target.pop(tag) 1921 except KeyError: 1922 return base.NoTagValue 1923 1924 def zoneTags( 1925 self, 1926 zone: base.Zone 1927 ) -> Dict[base.Tag, base.TagValue]: 1928 """ 1929 Returns the dictionary of tags for a zone. Edits to the returned 1930 value will be applied to the graph. Returns an empty tags 1931 dictionary if called on a zone that didn't have any tags 1932 previously, but raises a `MissingZoneError` if attempting to get 1933 tags for a zone which does not exist. 1934 1935 For example: 1936 1937 >>> g = DecisionGraph() 1938 >>> g.addDecision('A') 1939 0 1940 >>> g.addDecision('B') 1941 1 1942 >>> g.createZone('Zone') 1943 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1944 annotations=[]) 1945 >>> g.tagZone('Zone', 'color', 'blue') 1946 >>> g.tagZone( 1947 ... 'Zone', 1948 ... {'shape': 'square', 'color': 'red', 'sound': 'loud'} 1949 ... ) 1950 >>> g.untagZone('Zone', 'sound') 1951 'loud' 1952 >>> g.zoneTags('Zone') 1953 {'color': 'red', 'shape': 'square'} 1954 """ 1955 if zone in self.zones: 1956 return self.zones[zone].tags 1957 else: 1958 raise MissingZoneError( 1959 f"Tags for zone {zone!r} don't exist because that" 1960 f" zone has not been created yet." 1961 ) 1962 1963 def createZone(self, zone: base.Zone, level: int = 0) -> base.ZoneInfo: 1964 """ 1965 Creates an empty zone with the given name at the given level 1966 (default 0). Raises a `ZoneCollisionError` if that zone name is 1967 already in use (at any level), including if it's in use by a 1968 decision. 1969 1970 Raises an `InvalidLevelError` if the level value is less than 0. 1971 1972 Returns the `ZoneInfo` for the new blank zone. 1973 1974 For example: 1975 1976 >>> d = DecisionGraph() 1977 >>> d.createZone('Z', 0) 1978 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1979 annotations=[]) 1980 >>> d.getZoneInfo('Z') 1981 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1982 annotations=[]) 1983 >>> d.createZone('Z2', 0) 1984 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1985 annotations=[]) 1986 >>> d.createZone('Z3', -1) # level -1 is not valid (must be >= 0) 1987 Traceback (most recent call last): 1988 ... 1989 exploration.core.InvalidLevelError... 1990 >>> d.createZone('Z2') # Name Z2 is already in use 1991 Traceback (most recent call last): 1992 ... 1993 exploration.core.ZoneCollisionError... 1994 """ 1995 if level < 0: 1996 raise InvalidLevelError( 1997 "Cannot create a zone with a negative level." 1998 ) 1999 if zone in self.zones: 2000 raise ZoneCollisionError(f"Zone {zone!r} already exists.") 2001 if zone in self: 2002 raise ZoneCollisionError( 2003 f"A decision named {zone!r} already exists, so a zone" 2004 f" with that name cannot be created." 2005 ) 2006 info: base.ZoneInfo = base.ZoneInfo( 2007 level=level, 2008 parents=set(), 2009 contents=set(), 2010 tags={}, 2011 annotations=[] 2012 ) 2013 self.zones[zone] = info 2014 return info 2015 2016 def getZoneInfo(self, zone: base.Zone) -> Optional[base.ZoneInfo]: 2017 """ 2018 Returns the `ZoneInfo` (level, parents, and contents) for the 2019 specified zone, or `None` if that zone does not exist. 2020 2021 For example: 2022 2023 >>> d = DecisionGraph() 2024 >>> d.createZone('Z', 0) 2025 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2026 annotations=[]) 2027 >>> d.getZoneInfo('Z') 2028 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2029 annotations=[]) 2030 >>> d.createZone('Z2', 0) 2031 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2032 annotations=[]) 2033 >>> d.getZoneInfo('Z2') 2034 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2035 annotations=[]) 2036 """ 2037 return self.zones.get(zone) 2038 2039 def deleteZone(self, zone: base.Zone) -> base.ZoneInfo: 2040 """ 2041 Deletes the specified zone, returning a `ZoneInfo` object with 2042 the information on the level, parents, and contents of that zone. 2043 2044 Raises a `MissingZoneError` if the zone in question does not 2045 exist. 2046 2047 For example: 2048 2049 >>> d = DecisionGraph() 2050 >>> d.createZone('Z', 0) 2051 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2052 annotations=[]) 2053 >>> d.getZoneInfo('Z') 2054 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2055 annotations=[]) 2056 >>> d.deleteZone('Z') 2057 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2058 annotations=[]) 2059 >>> d.getZoneInfo('Z') is None # no info any more 2060 True 2061 >>> d.deleteZone('Z') # can't re-delete 2062 Traceback (most recent call last): 2063 ... 2064 exploration.core.MissingZoneError... 2065 """ 2066 info = self.getZoneInfo(zone) 2067 if info is None: 2068 raise MissingZoneError( 2069 f"Cannot delete zone {zone!r}: it does not exist." 2070 ) 2071 for sub in info.contents: 2072 if 'zones' in self.nodes[sub]: 2073 try: 2074 self.nodes[sub]['zones'].remove(zone) 2075 except KeyError: 2076 pass 2077 del self.zones[zone] 2078 return info 2079 2080 def addDecisionToZone( 2081 self, 2082 decision: base.AnyDecisionSpecifier, 2083 zone: base.Zone 2084 ) -> None: 2085 """ 2086 Adds a decision directly to a zone. Should normally only be used 2087 with level-0 zones. Raises a `MissingZoneError` if the specified 2088 zone did not already exist. 2089 2090 For example: 2091 2092 >>> d = DecisionGraph() 2093 >>> d.addDecision('A') 2094 0 2095 >>> d.addDecision('B') 2096 1 2097 >>> d.addDecision('C') 2098 2 2099 >>> d.createZone('Z', 0) 2100 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2101 annotations=[]) 2102 >>> d.addDecisionToZone('A', 'Z') 2103 >>> d.getZoneInfo('Z') 2104 ZoneInfo(level=0, parents=set(), contents={0}, tags={},\ 2105 annotations=[]) 2106 >>> d.addDecisionToZone('B', 'Z') 2107 >>> d.getZoneInfo('Z') 2108 ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\ 2109 annotations=[]) 2110 """ 2111 dID = self.resolveDecision(decision) 2112 2113 if zone not in self.zones: 2114 raise MissingZoneError(f"Zone {zone!r} does not exist.") 2115 2116 self.zones[zone].contents.add(dID) 2117 self.nodes[dID].setdefault('zones', set()).add(zone) 2118 2119 def removeDecisionFromZone( 2120 self, 2121 decision: base.AnyDecisionSpecifier, 2122 zone: base.Zone 2123 ) -> bool: 2124 """ 2125 Removes a decision from a zone if it had been in it, returning 2126 True if that decision had been in that zone, and False if it was 2127 not in that zone, including if that zone didn't exist. 2128 2129 Note that this only removes a decision from direct zone 2130 membership. If the decision is a member of one or more zones 2131 which are (directly or indirectly) sub-zones of the target zone, 2132 the decision will remain in those zones, and will still be 2133 indirectly part of the target zone afterwards. 2134 2135 Examples: 2136 2137 >>> g = DecisionGraph() 2138 >>> g.addDecision('A') 2139 0 2140 >>> g.addDecision('B') 2141 1 2142 >>> g.createZone('level0', 0) 2143 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2144 annotations=[]) 2145 >>> g.createZone('level1', 1) 2146 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2147 annotations=[]) 2148 >>> g.createZone('level2', 2) 2149 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2150 annotations=[]) 2151 >>> g.createZone('level3', 3) 2152 ZoneInfo(level=3, parents=set(), contents=set(), tags={},\ 2153 annotations=[]) 2154 >>> g.addDecisionToZone('A', 'level0') 2155 >>> g.addDecisionToZone('B', 'level0') 2156 >>> g.addZoneToZone('level0', 'level1') 2157 >>> g.addZoneToZone('level1', 'level2') 2158 >>> g.addZoneToZone('level2', 'level3') 2159 >>> g.addDecisionToZone('B', 'level2') # Direct w/ skips 2160 >>> g.removeDecisionFromZone('A', 'level1') 2161 False 2162 >>> g.zoneParents(0) 2163 {'level0'} 2164 >>> g.removeDecisionFromZone('A', 'level0') 2165 True 2166 >>> g.zoneParents(0) 2167 set() 2168 >>> g.removeDecisionFromZone('A', 'level0') 2169 False 2170 >>> g.removeDecisionFromZone('B', 'level0') 2171 True 2172 >>> g.zoneParents(1) 2173 {'level2'} 2174 >>> g.removeDecisionFromZone('B', 'level0') 2175 False 2176 >>> g.removeDecisionFromZone('B', 'level2') 2177 True 2178 >>> g.zoneParents(1) 2179 set() 2180 """ 2181 dID = self.resolveDecision(decision) 2182 2183 if zone not in self.zones: 2184 return False 2185 2186 info = self.zones[zone] 2187 if dID not in info.contents: 2188 return False 2189 else: 2190 info.contents.remove(dID) 2191 try: 2192 self.nodes[dID]['zones'].remove(zone) 2193 except KeyError: 2194 pass 2195 return True 2196 2197 def addZoneToZone( 2198 self, 2199 addIt: base.Zone, 2200 addTo: base.Zone 2201 ) -> None: 2202 """ 2203 Adds a zone to another zone. The `addIt` one must be at a 2204 strictly lower level than the `addTo` zone, or an 2205 `InvalidLevelError` will be raised. 2206 2207 If the zone to be added didn't already exist, it is created at 2208 one level below the target zone. Similarly, if the zone being 2209 added to didn't already exist, it is created at one level above 2210 the target zone. If neither existed, a `MissingZoneError` will 2211 be raised. 2212 2213 For example: 2214 2215 >>> d = DecisionGraph() 2216 >>> d.addDecision('A') 2217 0 2218 >>> d.addDecision('B') 2219 1 2220 >>> d.addDecision('C') 2221 2 2222 >>> d.createZone('Z', 0) 2223 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2224 annotations=[]) 2225 >>> d.addDecisionToZone('A', 'Z') 2226 >>> d.addDecisionToZone('B', 'Z') 2227 >>> d.getZoneInfo('Z') 2228 ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\ 2229 annotations=[]) 2230 >>> d.createZone('Z2', 0) 2231 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2232 annotations=[]) 2233 >>> d.addDecisionToZone('B', 'Z2') 2234 >>> d.addDecisionToZone('C', 'Z2') 2235 >>> d.getZoneInfo('Z2') 2236 ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\ 2237 annotations=[]) 2238 >>> d.createZone('l1Z', 1) 2239 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2240 annotations=[]) 2241 >>> d.createZone('l2Z', 2) 2242 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2243 annotations=[]) 2244 >>> d.addZoneToZone('Z', 'l1Z') 2245 >>> d.getZoneInfo('Z') 2246 ZoneInfo(level=0, parents={'l1Z'}, contents={0, 1}, tags={},\ 2247 annotations=[]) 2248 >>> d.getZoneInfo('l1Z') 2249 ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\ 2250 annotations=[]) 2251 >>> d.addZoneToZone('l1Z', 'l2Z') 2252 >>> d.getZoneInfo('l1Z') 2253 ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={},\ 2254 annotations=[]) 2255 >>> d.getZoneInfo('l2Z') 2256 ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={},\ 2257 annotations=[]) 2258 >>> d.addZoneToZone('Z2', 'l2Z') 2259 >>> d.getZoneInfo('Z2') 2260 ZoneInfo(level=0, parents={'l2Z'}, contents={1, 2}, tags={},\ 2261 annotations=[]) 2262 >>> l2i = d.getZoneInfo('l2Z') 2263 >>> l2i.level 2264 2 2265 >>> l2i.parents 2266 set() 2267 >>> sorted(l2i.contents) 2268 ['Z2', 'l1Z'] 2269 >>> d.addZoneToZone('NZ', 'NZ2') 2270 Traceback (most recent call last): 2271 ... 2272 exploration.core.MissingZoneError... 2273 >>> d.addZoneToZone('Z', 'l1Z2') 2274 >>> zi = d.getZoneInfo('Z') 2275 >>> zi.level 2276 0 2277 >>> sorted(zi.parents) 2278 ['l1Z', 'l1Z2'] 2279 >>> sorted(zi.contents) 2280 [0, 1] 2281 >>> d.getZoneInfo('l1Z2') 2282 ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\ 2283 annotations=[]) 2284 >>> d.addZoneToZone('NZ', 'l1Z') 2285 >>> d.getZoneInfo('NZ') 2286 ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={},\ 2287 annotations=[]) 2288 >>> zi = d.getZoneInfo('l1Z') 2289 >>> zi.level 2290 1 2291 >>> zi.parents 2292 {'l2Z'} 2293 >>> sorted(zi.contents) 2294 ['NZ', 'Z'] 2295 """ 2296 # Create one or the other (but not both) if they're missing 2297 addInfo = self.getZoneInfo(addIt) 2298 toInfo = self.getZoneInfo(addTo) 2299 if addInfo is None and toInfo is None: 2300 raise MissingZoneError( 2301 f"Cannot add zone {addIt!r} to zone {addTo!r}: neither" 2302 f" exists already." 2303 ) 2304 2305 # Create missing addIt 2306 elif addInfo is None: 2307 toInfo = cast(base.ZoneInfo, toInfo) 2308 newLevel = toInfo.level - 1 2309 if newLevel < 0: 2310 raise InvalidLevelError( 2311 f"Zone {addTo!r} is at level {toInfo.level} and so" 2312 f" a new zone cannot be added underneath it." 2313 ) 2314 addInfo = self.createZone(addIt, newLevel) 2315 2316 # Create missing addTo 2317 elif toInfo is None: 2318 addInfo = cast(base.ZoneInfo, addInfo) 2319 newLevel = addInfo.level + 1 2320 if newLevel < 0: 2321 raise InvalidLevelError( 2322 f"Zone {addIt!r} is at level {addInfo.level} (!!!)" 2323 f" and so a new zone cannot be added above it." 2324 ) 2325 toInfo = self.createZone(addTo, newLevel) 2326 2327 # Now both addInfo and toInfo are defined 2328 if addInfo.level >= toInfo.level: 2329 raise InvalidLevelError( 2330 f"Cannot add zone {addIt!r} at level {addInfo.level}" 2331 f" to zone {addTo!r} at level {toInfo.level}: zones can" 2332 f" only contain zones of lower levels." 2333 ) 2334 2335 # Now both addInfo and toInfo are defined 2336 toInfo.contents.add(addIt) 2337 addInfo.parents.add(addTo) 2338 2339 def removeZoneFromZone( 2340 self, 2341 removeIt: base.Zone, 2342 removeFrom: base.Zone 2343 ) -> bool: 2344 """ 2345 Removes a zone from a zone if it had been in it, returning True 2346 if that zone had been in that zone, and False if it was not in 2347 that zone, including if either zone did not exist. 2348 2349 For example: 2350 2351 >>> d = DecisionGraph() 2352 >>> d.createZone('Z', 0) 2353 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2354 annotations=[]) 2355 >>> d.createZone('Z2', 0) 2356 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2357 annotations=[]) 2358 >>> d.createZone('l1Z', 1) 2359 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2360 annotations=[]) 2361 >>> d.createZone('l2Z', 2) 2362 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2363 annotations=[]) 2364 >>> d.addZoneToZone('Z', 'l1Z') 2365 >>> d.addZoneToZone('l1Z', 'l2Z') 2366 >>> d.getZoneInfo('Z') 2367 ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={},\ 2368 annotations=[]) 2369 >>> d.getZoneInfo('l1Z') 2370 ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={},\ 2371 annotations=[]) 2372 >>> d.getZoneInfo('l2Z') 2373 ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={},\ 2374 annotations=[]) 2375 >>> d.removeZoneFromZone('l1Z', 'l2Z') 2376 True 2377 >>> d.getZoneInfo('l1Z') 2378 ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\ 2379 annotations=[]) 2380 >>> d.getZoneInfo('l2Z') 2381 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2382 annotations=[]) 2383 >>> d.removeZoneFromZone('Z', 'l1Z') 2384 True 2385 >>> d.getZoneInfo('Z') 2386 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2387 annotations=[]) 2388 >>> d.getZoneInfo('l1Z') 2389 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2390 annotations=[]) 2391 >>> d.removeZoneFromZone('Z', 'l1Z') 2392 False 2393 >>> d.removeZoneFromZone('Z', 'madeup') 2394 False 2395 >>> d.removeZoneFromZone('nope', 'madeup') 2396 False 2397 >>> d.removeZoneFromZone('nope', 'l1Z') 2398 False 2399 """ 2400 remInfo = self.getZoneInfo(removeIt) 2401 fromInfo = self.getZoneInfo(removeFrom) 2402 2403 if remInfo is None or fromInfo is None: 2404 return False 2405 2406 if removeIt not in fromInfo.contents: 2407 return False 2408 2409 remInfo.parents.remove(removeFrom) 2410 fromInfo.contents.remove(removeIt) 2411 return True 2412 2413 def decisionsInZone(self, zone: base.Zone) -> Set[base.DecisionID]: 2414 """ 2415 Returns a set of all decisions included directly in the given 2416 zone, not counting decisions included via intermediate 2417 sub-zones (see `allDecisionsInZone` to include those). 2418 2419 Raises a `MissingZoneError` if the specified zone does not 2420 exist. 2421 2422 The returned set is a copy, not a live editable set. 2423 2424 For example: 2425 2426 >>> d = DecisionGraph() 2427 >>> d.addDecision('A') 2428 0 2429 >>> d.addDecision('B') 2430 1 2431 >>> d.addDecision('C') 2432 2 2433 >>> d.createZone('Z', 0) 2434 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2435 annotations=[]) 2436 >>> d.addDecisionToZone('A', 'Z') 2437 >>> d.addDecisionToZone('B', 'Z') 2438 >>> d.getZoneInfo('Z') 2439 ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\ 2440 annotations=[]) 2441 >>> d.decisionsInZone('Z') 2442 {0, 1} 2443 >>> d.createZone('Z2', 0) 2444 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2445 annotations=[]) 2446 >>> d.addDecisionToZone('B', 'Z2') 2447 >>> d.addDecisionToZone('C', 'Z2') 2448 >>> d.getZoneInfo('Z2') 2449 ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\ 2450 annotations=[]) 2451 >>> d.decisionsInZone('Z') 2452 {0, 1} 2453 >>> d.decisionsInZone('Z2') 2454 {1, 2} 2455 >>> d.createZone('l1Z', 1) 2456 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2457 annotations=[]) 2458 >>> d.addZoneToZone('Z', 'l1Z') 2459 >>> d.decisionsInZone('Z') 2460 {0, 1} 2461 >>> d.decisionsInZone('l1Z') 2462 set() 2463 >>> d.decisionsInZone('madeup') 2464 Traceback (most recent call last): 2465 ... 2466 exploration.core.MissingZoneError... 2467 >>> zDec = d.decisionsInZone('Z') 2468 >>> zDec.add(2) # won't affect the zone 2469 >>> zDec 2470 {0, 1, 2} 2471 >>> d.decisionsInZone('Z') 2472 {0, 1} 2473 """ 2474 info = self.getZoneInfo(zone) 2475 if info is None: 2476 raise MissingZoneError(f"Zone {zone!r} does not exist.") 2477 2478 # Everything that's not a zone must be a decision 2479 return { 2480 item 2481 for item in info.contents 2482 if isinstance(item, base.DecisionID) 2483 } 2484 2485 def subZones(self, zone: base.Zone) -> Set[base.Zone]: 2486 """ 2487 Returns the set of all immediate sub-zones of the given zone. 2488 Will be an empty set if there are no sub-zones; raises a 2489 `MissingZoneError` if the specified zone does not exit. 2490 2491 The returned set is a copy, not a live editable set. 2492 2493 For example: 2494 2495 >>> d = DecisionGraph() 2496 >>> d.createZone('Z', 0) 2497 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2498 annotations=[]) 2499 >>> d.subZones('Z') 2500 set() 2501 >>> d.createZone('l1Z', 1) 2502 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2503 annotations=[]) 2504 >>> d.addZoneToZone('Z', 'l1Z') 2505 >>> d.subZones('Z') 2506 set() 2507 >>> d.subZones('l1Z') 2508 {'Z'} 2509 >>> s = d.subZones('l1Z') 2510 >>> s.add('Q') # doesn't affect the zone 2511 >>> sorted(s) 2512 ['Q', 'Z'] 2513 >>> d.subZones('l1Z') 2514 {'Z'} 2515 >>> d.subZones('madeup') 2516 Traceback (most recent call last): 2517 ... 2518 exploration.core.MissingZoneError... 2519 """ 2520 info = self.getZoneInfo(zone) 2521 if info is None: 2522 raise MissingZoneError(f"Zone {zone!r} does not exist.") 2523 2524 # Sub-zones will appear in self.zones 2525 return { 2526 item 2527 for item in info.contents 2528 if isinstance(item, base.Zone) 2529 } 2530 2531 def allDecisionsInZone(self, zone: base.Zone) -> Set[base.DecisionID]: 2532 """ 2533 Returns a set containing all decisions in the given zone, 2534 including those included via sub-zones. 2535 2536 Raises a `MissingZoneError` if the specified zone does not 2537 exist.` 2538 2539 For example: 2540 2541 >>> d = DecisionGraph() 2542 >>> d.addDecision('A') 2543 0 2544 >>> d.addDecision('B') 2545 1 2546 >>> d.addDecision('C') 2547 2 2548 >>> d.createZone('Z', 0) 2549 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2550 annotations=[]) 2551 >>> d.addDecisionToZone('A', 'Z') 2552 >>> d.addDecisionToZone('B', 'Z') 2553 >>> d.getZoneInfo('Z') 2554 ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\ 2555 annotations=[]) 2556 >>> d.decisionsInZone('Z') 2557 {0, 1} 2558 >>> d.allDecisionsInZone('Z') 2559 {0, 1} 2560 >>> d.createZone('Z2', 0) 2561 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2562 annotations=[]) 2563 >>> d.addDecisionToZone('B', 'Z2') 2564 >>> d.addDecisionToZone('C', 'Z2') 2565 >>> d.getZoneInfo('Z2') 2566 ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\ 2567 annotations=[]) 2568 >>> d.decisionsInZone('Z') 2569 {0, 1} 2570 >>> d.decisionsInZone('Z2') 2571 {1, 2} 2572 >>> d.allDecisionsInZone('Z2') 2573 {1, 2} 2574 >>> d.createZone('l1Z', 1) 2575 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2576 annotations=[]) 2577 >>> d.createZone('l2Z', 2) 2578 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2579 annotations=[]) 2580 >>> d.addZoneToZone('Z', 'l1Z') 2581 >>> d.addZoneToZone('l1Z', 'l2Z') 2582 >>> d.addZoneToZone('Z2', 'l2Z') 2583 >>> d.decisionsInZone('Z') 2584 {0, 1} 2585 >>> d.decisionsInZone('Z2') 2586 {1, 2} 2587 >>> d.decisionsInZone('l1Z') 2588 set() 2589 >>> d.allDecisionsInZone('l1Z') 2590 {0, 1} 2591 >>> d.allDecisionsInZone('l2Z') 2592 {0, 1, 2} 2593 """ 2594 result: Set[base.DecisionID] = set() 2595 info = self.getZoneInfo(zone) 2596 if info is None: 2597 raise MissingZoneError(f"Zone {zone!r} does not exist.") 2598 2599 for item in info.contents: 2600 if isinstance(item, base.Zone): 2601 # This can't be an error because of the condition above 2602 result |= self.allDecisionsInZone(item) 2603 else: # it's a decision 2604 result.add(item) 2605 2606 return result 2607 2608 def zoneHierarchyLevel(self, zone: base.Zone) -> int: 2609 """ 2610 Returns the hierarchy level of the given zone, as stored in its 2611 zone info. 2612 2613 Raises a `MissingZoneError` if the specified zone does not 2614 exist. 2615 2616 For example: 2617 2618 >>> d = DecisionGraph() 2619 >>> d.createZone('Z', 0) 2620 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2621 annotations=[]) 2622 >>> d.createZone('l1Z', 1) 2623 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2624 annotations=[]) 2625 >>> d.createZone('l5Z', 5) 2626 ZoneInfo(level=5, parents=set(), contents=set(), tags={},\ 2627 annotations=[]) 2628 >>> d.zoneHierarchyLevel('Z') 2629 0 2630 >>> d.zoneHierarchyLevel('l1Z') 2631 1 2632 >>> d.zoneHierarchyLevel('l5Z') 2633 5 2634 >>> d.zoneHierarchyLevel('madeup') 2635 Traceback (most recent call last): 2636 ... 2637 exploration.core.MissingZoneError... 2638 """ 2639 info = self.getZoneInfo(zone) 2640 if info is None: 2641 raise MissingZoneError(f"Zone {zone!r} dose not exist.") 2642 2643 return info.level 2644 2645 def zoneParents( 2646 self, 2647 zoneOrDecision: Union[base.Zone, base.DecisionID] 2648 ) -> Set[base.Zone]: 2649 """ 2650 Returns the set of all zones which directly contain the target 2651 zone or decision. 2652 2653 Raises a `MissingDecisionError` if the target is neither a valid 2654 zone nor a valid decision. 2655 2656 Returns a copy, not a live editable set. 2657 2658 Example: 2659 2660 >>> g = DecisionGraph() 2661 >>> g.addDecision('A') 2662 0 2663 >>> g.addDecision('B') 2664 1 2665 >>> g.createZone('level0', 0) 2666 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2667 annotations=[]) 2668 >>> g.createZone('level1', 1) 2669 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2670 annotations=[]) 2671 >>> g.createZone('level2', 2) 2672 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2673 annotations=[]) 2674 >>> g.createZone('level3', 3) 2675 ZoneInfo(level=3, parents=set(), contents=set(), tags={},\ 2676 annotations=[]) 2677 >>> g.addDecisionToZone('A', 'level0') 2678 >>> g.addDecisionToZone('B', 'level0') 2679 >>> g.addZoneToZone('level0', 'level1') 2680 >>> g.addZoneToZone('level1', 'level2') 2681 >>> g.addZoneToZone('level2', 'level3') 2682 >>> g.addDecisionToZone('B', 'level2') # Direct w/ skips 2683 >>> sorted(g.zoneParents(0)) 2684 ['level0'] 2685 >>> sorted(g.zoneParents(1)) 2686 ['level0', 'level2'] 2687 """ 2688 if zoneOrDecision in self.zones: 2689 zoneOrDecision = cast(base.Zone, zoneOrDecision) 2690 info = cast(base.ZoneInfo, self.getZoneInfo(zoneOrDecision)) 2691 return copy.copy(info.parents) 2692 elif zoneOrDecision in self: 2693 return self.nodes[zoneOrDecision].get('zones', set()) 2694 else: 2695 raise MissingDecisionError( 2696 f"Name {zoneOrDecision!r} is neither a valid zone nor a" 2697 f" valid decision." 2698 ) 2699 2700 def zoneAncestors( 2701 self, 2702 zoneOrDecision: Union[base.Zone, base.DecisionID], 2703 exclude: Set[base.Zone] = set() 2704 ) -> Set[base.Zone]: 2705 """ 2706 Returns the set of zones which contain the target zone or 2707 decision, either directly or indirectly. The target is not 2708 included in the set. 2709 2710 Any ones listed in the `exclude` set are also excluded, as are 2711 any of their ancestors which are not also ancestors of the 2712 target zone via another path of inclusion. 2713 2714 Raises a `MissingDecisionError` if the target is nether a valid 2715 zone nor a valid decision. 2716 2717 Example: 2718 2719 >>> g = DecisionGraph() 2720 >>> g.addDecision('A') 2721 0 2722 >>> g.addDecision('B') 2723 1 2724 >>> g.createZone('level0', 0) 2725 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2726 annotations=[]) 2727 >>> g.createZone('level1', 1) 2728 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2729 annotations=[]) 2730 >>> g.createZone('level2', 2) 2731 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2732 annotations=[]) 2733 >>> g.createZone('level3', 3) 2734 ZoneInfo(level=3, parents=set(), contents=set(), tags={},\ 2735 annotations=[]) 2736 >>> g.addDecisionToZone('A', 'level0') 2737 >>> g.addDecisionToZone('B', 'level0') 2738 >>> g.addZoneToZone('level0', 'level1') 2739 >>> g.addZoneToZone('level1', 'level2') 2740 >>> g.addZoneToZone('level2', 'level3') 2741 >>> g.addDecisionToZone('B', 'level2') # Direct w/ skips 2742 >>> sorted(g.zoneAncestors(0)) 2743 ['level0', 'level1', 'level2', 'level3'] 2744 >>> sorted(g.zoneAncestors(1)) 2745 ['level0', 'level1', 'level2', 'level3'] 2746 >>> sorted(g.zoneParents(0)) 2747 ['level0'] 2748 >>> sorted(g.zoneParents(1)) 2749 ['level0', 'level2'] 2750 """ 2751 # Copy is important here! 2752 result = set(self.zoneParents(zoneOrDecision)) 2753 result -= exclude 2754 for parent in copy.copy(result): 2755 # Recursively dig up ancestors, but exclude 2756 # results-so-far to avoid re-enumerating when there are 2757 # multiple braided inclusion paths. 2758 result |= self.zoneAncestors(parent, result | exclude) 2759 2760 return result 2761 2762 def zoneEdges(self, zone: base.Zone) -> Optional[ 2763 Tuple[ 2764 Set[Tuple[base.DecisionID, base.Transition]], 2765 Set[Tuple[base.DecisionID, base.Transition]] 2766 ] 2767 ]: 2768 """ 2769 Given a zone to look at, finds all of the transitions which go 2770 out of and into that zone, ignoring internal transitions between 2771 decisions in the zone. This includes all decisions in sub-zones. 2772 The return value is a pair of sets for outgoing and then 2773 incoming transitions, where each transition is specified as a 2774 (sourceID, transitionName) pair. 2775 2776 Returns `None` if the target zone isn't yet fully defined. 2777 2778 Note that this takes time proportional to *all* edges plus *all* 2779 nodes in the graph no matter how large or small the zone in 2780 question is. 2781 2782 >>> g = DecisionGraph() 2783 >>> g.addDecision('A') 2784 0 2785 >>> g.addDecision('B') 2786 1 2787 >>> g.addDecision('C') 2788 2 2789 >>> g.addDecision('D') 2790 3 2791 >>> g.addTransition('A', 'up', 'B', 'down') 2792 >>> g.addTransition('B', 'right', 'C', 'left') 2793 >>> g.addTransition('C', 'down', 'D', 'up') 2794 >>> g.addTransition('D', 'left', 'A', 'right') 2795 >>> g.addTransition('A', 'tunnel', 'C', 'tunnel') 2796 >>> g.createZone('Z', 0) 2797 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2798 annotations=[]) 2799 >>> g.createZone('ZZ', 1) 2800 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2801 annotations=[]) 2802 >>> g.addZoneToZone('Z', 'ZZ') 2803 >>> g.addDecisionToZone('A', 'Z') 2804 >>> g.addDecisionToZone('B', 'Z') 2805 >>> g.addDecisionToZone('D', 'ZZ') 2806 >>> outgoing, incoming = g.zoneEdges('Z') # TODO: Sort for testing 2807 >>> sorted(outgoing) 2808 [(0, 'right'), (0, 'tunnel'), (1, 'right')] 2809 >>> sorted(incoming) 2810 [(2, 'left'), (2, 'tunnel'), (3, 'left')] 2811 >>> outgoing, incoming = g.zoneEdges('ZZ') 2812 >>> sorted(outgoing) 2813 [(0, 'tunnel'), (1, 'right'), (3, 'up')] 2814 >>> sorted(incoming) 2815 [(2, 'down'), (2, 'left'), (2, 'tunnel')] 2816 >>> g.zoneEdges('madeup') is None 2817 True 2818 """ 2819 # Find the interior nodes 2820 try: 2821 interior = self.allDecisionsInZone(zone) 2822 except MissingZoneError: 2823 return None 2824 2825 # Set up our result 2826 results: Tuple[ 2827 Set[Tuple[base.DecisionID, base.Transition]], 2828 Set[Tuple[base.DecisionID, base.Transition]] 2829 ] = (set(), set()) 2830 2831 # Because finding incoming edges requires searching the entire 2832 # graph anyways, it's more efficient to just consider each edge 2833 # once. 2834 for fromDecision in self: 2835 fromThere = self[fromDecision] 2836 for toDecision in fromThere: 2837 for transition in fromThere[toDecision]: 2838 sourceIn = fromDecision in interior 2839 destIn = toDecision in interior 2840 if sourceIn and not destIn: 2841 results[0].add((fromDecision, transition)) 2842 elif destIn and not sourceIn: 2843 results[1].add((fromDecision, transition)) 2844 2845 return results 2846 2847 def replaceZonesInHierarchy( 2848 self, 2849 target: base.AnyDecisionSpecifier, 2850 zone: base.Zone, 2851 level: int 2852 ) -> None: 2853 """ 2854 This method replaces one or more zones which contain the 2855 specified `target` decision with a specific zone, at a specific 2856 level in the zone hierarchy (see `zoneHierarchyLevel`). If the 2857 named zone doesn't yet exist, it will be created. 2858 2859 To do this, it looks at all zones which contain the target 2860 decision directly or indirectly (see `zoneAncestors`) and which 2861 are at the specified level. 2862 2863 - Any direct children of those zones which are ancestors of the 2864 target decision are removed from those zones and placed into 2865 the new zone instead, regardless of their levels. Indirect 2866 children are not affected (except perhaps indirectly via 2867 their parents' ancestors changing). 2868 - The new zone is placed into every direct parent of those 2869 zones, regardless of their levels (those parents are by 2870 definition all ancestors of the target decision). 2871 - If there were no zones at the target level, every zone at the 2872 next level down which is an ancestor of the target decision 2873 (or just that decision if the level is 0) is placed into the 2874 new zone as a direct child (and is removed from any previous 2875 parents it had). In this case, the new zone will also be 2876 added as a sub-zone to every ancestor of the target decision 2877 at the level above the specified level, if there are any. 2878 * In this case, if there are no zones at the level below the 2879 specified level, the highest level of zones smaller than 2880 that is treated as the level below, down to targeting 2881 the decision itself. 2882 * Similarly, if there are no zones at the level above the 2883 specified level but there are zones at a higher level, 2884 the new zone will be added to each of the zones in the 2885 lowest level above the target level that has zones in it. 2886 2887 A `MissingDecisionError` will be raised if the specified 2888 decision is not valid, or if the decision is left as default but 2889 there is no current decision in the exploration. 2890 2891 An `InvalidLevelError` will be raised if the level is less than 2892 zero. 2893 2894 Example: 2895 2896 >>> g = DecisionGraph() 2897 >>> g.addDecision('decision') 2898 0 2899 >>> g.addDecision('alternate') 2900 1 2901 >>> g.createZone('zone0', 0) 2902 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2903 annotations=[]) 2904 >>> g.createZone('zone1', 1) 2905 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2906 annotations=[]) 2907 >>> g.createZone('zone2.1', 2) 2908 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2909 annotations=[]) 2910 >>> g.createZone('zone2.2', 2) 2911 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2912 annotations=[]) 2913 >>> g.createZone('zone3', 3) 2914 ZoneInfo(level=3, parents=set(), contents=set(), tags={},\ 2915 annotations=[]) 2916 >>> g.addDecisionToZone('decision', 'zone0') 2917 >>> g.addDecisionToZone('alternate', 'zone0') 2918 >>> g.addZoneToZone('zone0', 'zone1') 2919 >>> g.addZoneToZone('zone1', 'zone2.1') 2920 >>> g.addZoneToZone('zone1', 'zone2.2') 2921 >>> g.addZoneToZone('zone2.1', 'zone3') 2922 >>> g.addZoneToZone('zone2.2', 'zone3') 2923 >>> g.zoneHierarchyLevel('zone0') 2924 0 2925 >>> g.zoneHierarchyLevel('zone1') 2926 1 2927 >>> g.zoneHierarchyLevel('zone2.1') 2928 2 2929 >>> g.zoneHierarchyLevel('zone2.2') 2930 2 2931 >>> g.zoneHierarchyLevel('zone3') 2932 3 2933 >>> sorted(g.decisionsInZone('zone0')) 2934 [0, 1] 2935 >>> sorted(g.zoneAncestors('zone0')) 2936 ['zone1', 'zone2.1', 'zone2.2', 'zone3'] 2937 >>> g.subZones('zone1') 2938 {'zone0'} 2939 >>> g.zoneParents('zone0') 2940 {'zone1'} 2941 >>> g.replaceZonesInHierarchy('decision', 'new0', 0) 2942 >>> g.zoneParents('zone0') 2943 {'zone1'} 2944 >>> g.zoneParents('new0') 2945 {'zone1'} 2946 >>> sorted(g.zoneAncestors('zone0')) 2947 ['zone1', 'zone2.1', 'zone2.2', 'zone3'] 2948 >>> sorted(g.zoneAncestors('new0')) 2949 ['zone1', 'zone2.1', 'zone2.2', 'zone3'] 2950 >>> g.decisionsInZone('zone0') 2951 {1} 2952 >>> g.decisionsInZone('new0') 2953 {0} 2954 >>> sorted(g.subZones('zone1')) 2955 ['new0', 'zone0'] 2956 >>> g.zoneParents('new0') 2957 {'zone1'} 2958 >>> g.replaceZonesInHierarchy('decision', 'new1', 1) 2959 >>> sorted(g.zoneAncestors(0)) 2960 ['new0', 'new1', 'zone2.1', 'zone2.2', 'zone3'] 2961 >>> g.subZones('zone1') 2962 {'zone0'} 2963 >>> g.subZones('new1') 2964 {'new0'} 2965 >>> g.zoneParents('new0') 2966 {'new1'} 2967 >>> sorted(g.zoneParents('zone1')) 2968 ['zone2.1', 'zone2.2'] 2969 >>> sorted(g.zoneParents('new1')) 2970 ['zone2.1', 'zone2.2'] 2971 >>> g.zoneParents('zone2.1') 2972 {'zone3'} 2973 >>> g.zoneParents('zone2.2') 2974 {'zone3'} 2975 >>> sorted(g.subZones('zone2.1')) 2976 ['new1', 'zone1'] 2977 >>> sorted(g.subZones('zone2.2')) 2978 ['new1', 'zone1'] 2979 >>> sorted(g.allDecisionsInZone('zone2.1')) 2980 [0, 1] 2981 >>> sorted(g.allDecisionsInZone('zone2.2')) 2982 [0, 1] 2983 >>> g.replaceZonesInHierarchy('decision', 'new2', 2) 2984 >>> g.zoneParents('zone2.1') 2985 {'zone3'} 2986 >>> g.zoneParents('zone2.2') 2987 {'zone3'} 2988 >>> g.subZones('zone2.1') 2989 {'zone1'} 2990 >>> g.subZones('zone2.2') 2991 {'zone1'} 2992 >>> g.subZones('new2') 2993 {'new1'} 2994 >>> g.zoneParents('new2') 2995 {'zone3'} 2996 >>> g.allDecisionsInZone('zone2.1') 2997 {1} 2998 >>> g.allDecisionsInZone('zone2.2') 2999 {1} 3000 >>> g.allDecisionsInZone('new2') 3001 {0} 3002 >>> sorted(g.subZones('zone3')) 3003 ['new2', 'zone2.1', 'zone2.2'] 3004 >>> g.zoneParents('zone3') 3005 set() 3006 >>> sorted(g.allDecisionsInZone('zone3')) 3007 [0, 1] 3008 >>> g.replaceZonesInHierarchy('decision', 'new3', 3) 3009 >>> sorted(g.subZones('zone3')) 3010 ['zone2.1', 'zone2.2'] 3011 >>> g.subZones('new3') 3012 {'new2'} 3013 >>> g.zoneParents('zone3') 3014 set() 3015 >>> g.zoneParents('new3') 3016 set() 3017 >>> g.allDecisionsInZone('zone3') 3018 {1} 3019 >>> g.allDecisionsInZone('new3') 3020 {0} 3021 >>> g.replaceZonesInHierarchy('decision', 'new4', 5) 3022 >>> g.subZones('new4') 3023 {'new3'} 3024 >>> g.zoneHierarchyLevel('new4') 3025 5 3026 3027 Another example of level collapse when trying to replace a zone 3028 at a level above : 3029 3030 >>> g = DecisionGraph() 3031 >>> g.addDecision('A') 3032 0 3033 >>> g.addDecision('B') 3034 1 3035 >>> g.createZone('level0', 0) 3036 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 3037 annotations=[]) 3038 >>> g.createZone('level1', 1) 3039 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 3040 annotations=[]) 3041 >>> g.createZone('level2', 2) 3042 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 3043 annotations=[]) 3044 >>> g.createZone('level3', 3) 3045 ZoneInfo(level=3, parents=set(), contents=set(), tags={},\ 3046 annotations=[]) 3047 >>> g.addDecisionToZone('B', 'level0') 3048 >>> g.addZoneToZone('level0', 'level1') 3049 >>> g.addZoneToZone('level1', 'level2') 3050 >>> g.addZoneToZone('level2', 'level3') 3051 >>> g.addDecisionToZone('A', 'level3') # missing some zone levels 3052 >>> g.zoneHierarchyLevel('level3') 3053 3 3054 >>> g.replaceZonesInHierarchy('A', 'newFirst', 1) 3055 >>> g.zoneHierarchyLevel('newFirst') 3056 1 3057 >>> g.decisionsInZone('newFirst') 3058 {0} 3059 >>> g.decisionsInZone('level3') 3060 set() 3061 >>> sorted(g.allDecisionsInZone('level3')) 3062 [0, 1] 3063 >>> g.subZones('newFirst') 3064 set() 3065 >>> sorted(g.subZones('level3')) 3066 ['level2', 'newFirst'] 3067 >>> g.zoneParents('newFirst') 3068 {'level3'} 3069 >>> g.replaceZonesInHierarchy('A', 'newSecond', 2) 3070 >>> g.zoneHierarchyLevel('newSecond') 3071 2 3072 >>> g.decisionsInZone('newSecond') 3073 set() 3074 >>> g.allDecisionsInZone('newSecond') 3075 {0} 3076 >>> g.subZones('newSecond') 3077 {'newFirst'} 3078 >>> g.zoneParents('newSecond') 3079 {'level3'} 3080 >>> g.zoneParents('newFirst') 3081 {'newSecond'} 3082 >>> sorted(g.subZones('level3')) 3083 ['level2', 'newSecond'] 3084 """ 3085 tID = self.resolveDecision(target) 3086 3087 if level < 0: 3088 raise InvalidLevelError( 3089 f"Target level must be positive (got {level})." 3090 ) 3091 3092 info = self.getZoneInfo(zone) 3093 if info is None: 3094 info = self.createZone(zone, level) 3095 elif level != info.level: 3096 raise InvalidLevelError( 3097 f"Target level ({level}) does not match the level of" 3098 f" the target zone ({zone!r} at level {info.level})." 3099 ) 3100 3101 # Collect both parents & ancestors 3102 parents = self.zoneParents(tID) 3103 ancestors = set(self.zoneAncestors(tID)) 3104 3105 # Map from levels to sets of zones from the ancestors pool 3106 levelMap: Dict[int, Set[base.Zone]] = {} 3107 highest = -1 3108 for ancestor in ancestors: 3109 ancestorLevel = self.zoneHierarchyLevel(ancestor) 3110 levelMap.setdefault(ancestorLevel, set()).add(ancestor) 3111 if ancestorLevel > highest: 3112 highest = ancestorLevel 3113 3114 # Figure out if we have target zones to replace or not 3115 reparentDecision = False 3116 if level in levelMap: 3117 # If there are zones at the target level, 3118 targetZones = levelMap[level] 3119 3120 above = set() 3121 below = set() 3122 3123 for replaced in targetZones: 3124 above |= self.zoneParents(replaced) 3125 below |= self.subZones(replaced) 3126 if replaced in parents: 3127 reparentDecision = True 3128 3129 # Only ancestors should be reparented 3130 below &= ancestors 3131 3132 else: 3133 # Find levels w/ zones in them above + below 3134 levelBelow = level - 1 3135 levelAbove = level + 1 3136 below = levelMap.get(levelBelow, set()) 3137 above = levelMap.get(levelAbove, set()) 3138 3139 while len(below) == 0 and levelBelow > 0: 3140 levelBelow -= 1 3141 below = levelMap.get(levelBelow, set()) 3142 3143 if len(below) == 0: 3144 reparentDecision = True 3145 3146 while len(above) == 0 and levelAbove < highest: 3147 levelAbove += 1 3148 above = levelMap.get(levelAbove, set()) 3149 3150 # Handle re-parenting zones below 3151 for under in below: 3152 for parent in self.zoneParents(under): 3153 if parent in ancestors: 3154 self.removeZoneFromZone(under, parent) 3155 self.addZoneToZone(under, zone) 3156 3157 # Add this zone to each parent 3158 for parent in above: 3159 self.addZoneToZone(zone, parent) 3160 3161 # Re-parent the decision itself if necessary 3162 if reparentDecision: 3163 # (using set() here to avoid size-change-during-iteration) 3164 for parent in set(parents): 3165 self.removeDecisionFromZone(tID, parent) 3166 self.addDecisionToZone(tID, zone) 3167 3168 def getReciprocal( 3169 self, 3170 decision: base.AnyDecisionSpecifier, 3171 transition: base.Transition 3172 ) -> Optional[base.Transition]: 3173 """ 3174 Returns the reciprocal edge for the specified transition from the 3175 specified decision (see `setReciprocal`). Returns 3176 `None` if no reciprocal has been established for that 3177 transition, or if that decision or transition does not exist. 3178 """ 3179 dID = self.resolveDecision(decision) 3180 3181 dest = self.getDestination(dID, transition) 3182 if dest is not None: 3183 info = cast( 3184 TransitionProperties, 3185 self.edges[dID, dest, transition] # type:ignore 3186 ) 3187 recip = info.get("reciprocal") 3188 if recip is not None and not isinstance(recip, base.Transition): 3189 raise ValueError(f"Invalid reciprocal value: {repr(recip)}") 3190 return recip 3191 else: 3192 return None 3193 3194 def setReciprocal( 3195 self, 3196 decision: base.AnyDecisionSpecifier, 3197 transition: base.Transition, 3198 reciprocal: Optional[base.Transition], 3199 setBoth: bool = True, 3200 cleanup: bool = True 3201 ) -> None: 3202 """ 3203 Sets the 'reciprocal' transition for a particular transition from 3204 a particular decision, and removes the reciprocal property from 3205 any old reciprocal transition. 3206 3207 Raises a `MissingDecisionError` or a `MissingTransitionError` if 3208 the specified decision or transition does not exist. 3209 3210 Raises an `InvalidDestinationError` if the reciprocal transition 3211 does not exist, or if it does exist but does not lead back to 3212 the decision the transition came from. 3213 3214 If `setBoth` is True (the default) then the transition which is 3215 being identified as a reciprocal will also have its reciprocal 3216 property set, pointing back to the primary transition being 3217 modified, and any old reciprocal of that transition will have its 3218 reciprocal set to None. If you want to create a situation with 3219 non-exclusive reciprocals, use `setBoth=False`. 3220 3221 If `cleanup` is True (the default) then abandoned reciprocal 3222 transitions (for both edges if `setBoth` was true) have their 3223 reciprocal properties removed. Set `cleanup` to false if you want 3224 to retain them, although this will result in non-exclusive 3225 reciprocal relationships. 3226 3227 If the `reciprocal` value is None, this deletes the reciprocal 3228 value entirely, and if `setBoth` is true, it does this for the 3229 previous reciprocal edge as well. No error is raised in this case 3230 when there was not already a reciprocal to delete. 3231 3232 Note that one should remove a reciprocal relationship before 3233 redirecting either edge of the pair in a way that gives it a new 3234 reciprocal, since otherwise, a later attempt to remove the 3235 reciprocal with `setBoth` set to True (the default) will end up 3236 deleting the reciprocal information from the other edge that was 3237 already modified. There is no way to reliably detect and avoid 3238 this, because two different decisions could (and often do in 3239 practice) have transitions with identical names, meaning that the 3240 reciprocal value will still be the same, but it will indicate a 3241 different edge in virtue of the destination of the edge changing. 3242 3243 ## Example 3244 3245 >>> g = DecisionGraph() 3246 >>> g.addDecision('G') 3247 0 3248 >>> g.addDecision('H') 3249 1 3250 >>> g.addDecision('I') 3251 2 3252 >>> g.addTransition('G', 'up', 'H', 'down') 3253 >>> g.addTransition('G', 'next', 'H', 'prev') 3254 >>> g.addTransition('H', 'next', 'I', 'prev') 3255 >>> g.addTransition('H', 'return', 'G') 3256 >>> g.setReciprocal('G', 'up', 'next') # Error w/ destinations 3257 Traceback (most recent call last): 3258 ... 3259 exploration.core.InvalidDestinationError... 3260 >>> g.setReciprocal('G', 'up', 'none') # Doesn't exist 3261 Traceback (most recent call last): 3262 ... 3263 exploration.core.MissingTransitionError... 3264 >>> g.getReciprocal('G', 'up') 3265 'down' 3266 >>> g.getReciprocal('H', 'down') 3267 'up' 3268 >>> g.getReciprocal('H', 'return') is None 3269 True 3270 >>> g.setReciprocal('G', 'up', 'return') 3271 >>> g.getReciprocal('G', 'up') 3272 'return' 3273 >>> g.getReciprocal('H', 'down') is None 3274 True 3275 >>> g.getReciprocal('H', 'return') 3276 'up' 3277 >>> g.setReciprocal('H', 'return', None) # remove the reciprocal 3278 >>> g.getReciprocal('G', 'up') is None 3279 True 3280 >>> g.getReciprocal('H', 'down') is None 3281 True 3282 >>> g.getReciprocal('H', 'return') is None 3283 True 3284 >>> g.setReciprocal('G', 'up', 'down', setBoth=False) # one-way 3285 >>> g.getReciprocal('G', 'up') 3286 'down' 3287 >>> g.getReciprocal('H', 'down') is None 3288 True 3289 >>> g.getReciprocal('H', 'return') is None 3290 True 3291 >>> g.setReciprocal('H', 'return', 'up', setBoth=False) # non-sym 3292 >>> g.getReciprocal('G', 'up') 3293 'down' 3294 >>> g.getReciprocal('H', 'down') is None 3295 True 3296 >>> g.getReciprocal('H', 'return') 3297 'up' 3298 >>> g.setReciprocal('H', 'down', 'up') # setBoth not needed 3299 >>> g.getReciprocal('G', 'up') 3300 'down' 3301 >>> g.getReciprocal('H', 'down') 3302 'up' 3303 >>> g.getReciprocal('H', 'return') # unchanged 3304 'up' 3305 >>> g.setReciprocal('G', 'up', 'return', cleanup=False) # no cleanup 3306 >>> g.getReciprocal('G', 'up') 3307 'return' 3308 >>> g.getReciprocal('H', 'down') 3309 'up' 3310 >>> g.getReciprocal('H', 'return') # unchanged 3311 'up' 3312 >>> # Cleanup only applies to reciprocal if setBoth is true 3313 >>> g.setReciprocal('H', 'down', 'up', setBoth=False) 3314 >>> g.getReciprocal('G', 'up') 3315 'return' 3316 >>> g.getReciprocal('H', 'down') 3317 'up' 3318 >>> g.getReciprocal('H', 'return') # not cleaned up w/out setBoth 3319 'up' 3320 >>> g.setReciprocal('H', 'down', 'up') # with cleanup and setBoth 3321 >>> g.getReciprocal('G', 'up') 3322 'down' 3323 >>> g.getReciprocal('H', 'down') 3324 'up' 3325 >>> g.getReciprocal('H', 'return') is None # cleaned up 3326 True 3327 """ 3328 dID = self.resolveDecision(decision) 3329 3330 dest = self.destination(dID, transition) # possible KeyError 3331 if reciprocal is None: 3332 rDest = None 3333 else: 3334 rDest = self.getDestination(dest, reciprocal) 3335 3336 # Set or delete reciprocal property 3337 if reciprocal is None: 3338 # Delete the property 3339 info = self.edges[dID, dest, transition] # type:ignore 3340 3341 old = info.pop('reciprocal') 3342 if setBoth: 3343 rDest = self.getDestination(dest, old) 3344 if rDest != dID: 3345 raise RuntimeError( 3346 f"Invalid reciprocal {old!r} for transition" 3347 f" {transition!r} from {self.identityOf(dID)}:" 3348 f" destination is {rDest}." 3349 ) 3350 rInfo = self.edges[dest, dID, old] # type:ignore 3351 if 'reciprocal' in rInfo: 3352 del rInfo['reciprocal'] 3353 else: 3354 # Set the property, checking for errors first 3355 if rDest is None: 3356 raise MissingTransitionError( 3357 f"Reciprocal transition {reciprocal!r} for" 3358 f" transition {transition!r} from decision" 3359 f" {self.identityOf(dID)} does not exist at" 3360 f" decision {self.identityOf(dest)}" 3361 ) 3362 3363 if rDest != dID: 3364 raise InvalidDestinationError( 3365 f"Reciprocal transition {reciprocal!r} from" 3366 f" decision {self.identityOf(dest)} does not lead" 3367 f" back to decision {self.identityOf(dID)}." 3368 ) 3369 3370 eProps = self.edges[dID, dest, transition] # type:ignore [index] 3371 abandoned = eProps.get('reciprocal') 3372 eProps['reciprocal'] = reciprocal 3373 if cleanup and abandoned not in (None, reciprocal): 3374 aProps = self.edges[dest, dID, abandoned] # type:ignore 3375 if 'reciprocal' in aProps: 3376 del aProps['reciprocal'] 3377 3378 if setBoth: 3379 rProps = self.edges[dest, dID, reciprocal] # type:ignore 3380 revAbandoned = rProps.get('reciprocal') 3381 rProps['reciprocal'] = transition 3382 # Sever old reciprocal relationship 3383 if cleanup and revAbandoned not in (None, transition): 3384 raProps = self.edges[ 3385 dID, # type:ignore 3386 dest, 3387 revAbandoned 3388 ] 3389 del raProps['reciprocal'] 3390 3391 def getReciprocalPair( 3392 self, 3393 decision: base.AnyDecisionSpecifier, 3394 transition: base.Transition 3395 ) -> Optional[Tuple[base.DecisionID, base.Transition]]: 3396 """ 3397 Returns a tuple containing both the destination decision ID and 3398 the transition at that decision which is the reciprocal of the 3399 specified destination & transition. Returns `None` if no 3400 reciprocal has been established for that transition, or if that 3401 decision or transition does not exist. 3402 3403 >>> g = DecisionGraph() 3404 >>> g.addDecision('A') 3405 0 3406 >>> g.addDecision('B') 3407 1 3408 >>> g.addDecision('C') 3409 2 3410 >>> g.addTransition('A', 'up', 'B', 'down') 3411 >>> g.addTransition('B', 'right', 'C', 'left') 3412 >>> g.addTransition('A', 'oneway', 'C') 3413 >>> g.getReciprocalPair('A', 'up') 3414 (1, 'down') 3415 >>> g.getReciprocalPair('B', 'down') 3416 (0, 'up') 3417 >>> g.getReciprocalPair('B', 'right') 3418 (2, 'left') 3419 >>> g.getReciprocalPair('C', 'left') 3420 (1, 'right') 3421 >>> g.getReciprocalPair('C', 'up') is None 3422 True 3423 >>> g.getReciprocalPair('Q', 'up') is None 3424 True 3425 >>> g.getReciprocalPair('A', 'tunnel') is None 3426 True 3427 """ 3428 try: 3429 dID = self.resolveDecision(decision) 3430 except MissingDecisionError: 3431 return None 3432 3433 reciprocal = self.getReciprocal(dID, transition) 3434 if reciprocal is None: 3435 return None 3436 else: 3437 destination = self.getDestination(dID, transition) 3438 if destination is None: 3439 return None 3440 else: 3441 return (destination, reciprocal) 3442 3443 def addDecision( 3444 self, 3445 name: base.DecisionName, 3446 domain: Optional[base.Domain] = None, 3447 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 3448 annotations: Optional[List[base.Annotation]] = None 3449 ) -> base.DecisionID: 3450 """ 3451 Adds a decision to the graph, without any transitions yet. Each 3452 decision will be assigned an ID so name collisions are allowed, 3453 but it's usually best to keep names unique at least within each 3454 zone. If no domain is provided, the `DEFAULT_DOMAIN` will be 3455 used for the decision's domain. A dictionary of tags and/or a 3456 list of annotations (strings in both cases) may be provided. 3457 3458 Returns the newly-assigned `DecisionID` for the decision it 3459 created. 3460 3461 Emits a `DecisionCollisionWarning` if a decision with the 3462 provided name already exists and the `WARN_OF_NAME_COLLISIONS` 3463 global variable is set to `True`. 3464 """ 3465 # Defaults 3466 if domain is None: 3467 domain = base.DEFAULT_DOMAIN 3468 if tags is None: 3469 tags = {} 3470 if annotations is None: 3471 annotations = [] 3472 3473 # Error checking 3474 if name in self.nameLookup and WARN_OF_NAME_COLLISIONS: 3475 warnings.warn( 3476 ( 3477 f"Adding decision {name!r}: Another decision with" 3478 f" that name already exists." 3479 ), 3480 DecisionCollisionWarning 3481 ) 3482 3483 dID = self._assignID() 3484 3485 # Add the decision 3486 self.add_node( 3487 dID, 3488 name=name, 3489 domain=domain, 3490 tags=tags, 3491 annotations=annotations 3492 ) 3493 #TODO: Elide tags/annotations if they're empty? 3494 3495 # Track it in our `nameLookup` dictionary 3496 self.nameLookup.setdefault(name, []).append(dID) 3497 3498 return dID 3499 3500 def addIdentifiedDecision( 3501 self, 3502 dID: base.DecisionID, 3503 name: base.DecisionName, 3504 domain: Optional[base.Domain] = None, 3505 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 3506 annotations: Optional[List[base.Annotation]] = None 3507 ) -> None: 3508 """ 3509 Adds a new decision to the graph using a specific decision ID, 3510 rather than automatically assigning a new decision ID like 3511 `addDecision` does. Otherwise works like `addDecision`. 3512 3513 Raises a `MechanismCollisionError` if the specified decision ID 3514 is already in use. 3515 """ 3516 # Defaults 3517 if domain is None: 3518 domain = base.DEFAULT_DOMAIN 3519 if tags is None: 3520 tags = {} 3521 if annotations is None: 3522 annotations = [] 3523 3524 # Error checking 3525 if dID in self.nodes: 3526 raise MechanismCollisionError( 3527 f"Cannot add a node with id {dID} and name {name!r}:" 3528 f" that ID is already used by node {self.identityOf(dID)}" 3529 ) 3530 3531 if name in self.nameLookup and WARN_OF_NAME_COLLISIONS: 3532 warnings.warn( 3533 ( 3534 f"Adding decision {name!r}: Another decision with" 3535 f" that name already exists." 3536 ), 3537 DecisionCollisionWarning 3538 ) 3539 3540 # Add the decision 3541 self.add_node( 3542 dID, 3543 name=name, 3544 domain=domain, 3545 tags=tags, 3546 annotations=annotations 3547 ) 3548 #TODO: Elide tags/annotations if they're empty? 3549 3550 # Track it in our `nameLookup` dictionary 3551 self.nameLookup.setdefault(name, []).append(dID) 3552 3553 def addTransition( 3554 self, 3555 fromDecision: base.AnyDecisionSpecifier, 3556 name: base.Transition, 3557 toDecision: base.AnyDecisionSpecifier, 3558 reciprocal: Optional[base.Transition] = None, 3559 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 3560 annotations: Optional[List[base.Annotation]] = None, 3561 revTags: Optional[Dict[base.Tag, base.TagValue]] = None, 3562 revAnnotations: Optional[List[base.Annotation]] = None, 3563 requires: Optional[base.Requirement] = None, 3564 consequence: Optional[base.Consequence] = None, 3565 revRequires: Optional[base.Requirement] = None, 3566 revConsequece: Optional[base.Consequence] = None 3567 ) -> None: 3568 """ 3569 Adds a transition connecting two decisions. A specifier for each 3570 decision is required, as is a name for the transition. If a 3571 `reciprocal` is provided, a reciprocal edge will be added in the 3572 opposite direction using that name; by default only the specified 3573 edge is added. A `TransitionCollisionError` will be raised if the 3574 `reciprocal` matches the name of an existing edge at the 3575 destination decision. 3576 3577 Both decisions must already exist, or a `MissingDecisionError` 3578 will be raised. 3579 3580 A dictionary of tags and/or a list of annotations may be 3581 provided. Tags and/or annotations for the reverse edge may also 3582 be specified if one is being added. 3583 3584 The `requires`, `consequence`, `revRequires`, and `revConsequece` 3585 arguments specify requirements and/or consequences of the new 3586 outgoing and reciprocal edges. 3587 """ 3588 # Defaults 3589 if tags is None: 3590 tags = {} 3591 if annotations is None: 3592 annotations = [] 3593 if revTags is None: 3594 revTags = {} 3595 if revAnnotations is None: 3596 revAnnotations = [] 3597 3598 # Error checking 3599 fromID = self.resolveDecision(fromDecision) 3600 toID = self.resolveDecision(toDecision) 3601 3602 # Note: have to check this first so we don't add the forward edge 3603 # and then error out after a side effect! 3604 if ( 3605 reciprocal is not None 3606 and self.getDestination(toDecision, reciprocal) is not None 3607 ): 3608 raise TransitionCollisionError( 3609 f"Cannot add a transition from" 3610 f" {self.identityOf(fromDecision)} to" 3611 f" {self.identityOf(toDecision)} with reciprocal edge" 3612 f" {reciprocal!r}: {reciprocal!r} is already used as an" 3613 f" edge name at {self.identityOf(toDecision)}." 3614 ) 3615 3616 # Add the edge 3617 self.add_edge( 3618 fromID, 3619 toID, 3620 key=name, 3621 tags=tags, 3622 annotations=annotations 3623 ) 3624 self.setTransitionRequirement(fromDecision, name, requires) 3625 if consequence is not None: 3626 self.setConsequence(fromDecision, name, consequence) 3627 if reciprocal is not None: 3628 # Add the reciprocal edge 3629 self.add_edge( 3630 toID, 3631 fromID, 3632 key=reciprocal, 3633 tags=revTags, 3634 annotations=revAnnotations 3635 ) 3636 self.setReciprocal(fromID, name, reciprocal) 3637 self.setTransitionRequirement( 3638 toDecision, 3639 reciprocal, 3640 revRequires 3641 ) 3642 if revConsequece is not None: 3643 self.setConsequence(toDecision, reciprocal, revConsequece) 3644 3645 def removeTransition( 3646 self, 3647 fromDecision: base.AnyDecisionSpecifier, 3648 transition: base.Transition, 3649 removeReciprocal=False 3650 ) -> Union[ 3651 TransitionProperties, 3652 Tuple[TransitionProperties, TransitionProperties] 3653 ]: 3654 """ 3655 Removes a transition. If `removeReciprocal` is true (False is the 3656 default) any reciprocal transition will also be removed (but no 3657 error will occur if there wasn't a reciprocal). 3658 3659 For each removed transition, *every* transition that targeted 3660 that transition as its reciprocal will have its reciprocal set to 3661 `None`, to avoid leaving any invalid reciprocal values. 3662 3663 Raises a `KeyError` if either the target decision or the target 3664 transition does not exist. 3665 3666 Returns a transition properties dictionary with the properties 3667 of the removed transition, or if `removeReciprocal` is true, 3668 returns a pair of such dictionaries for the target transition 3669 and its reciprocal. 3670 3671 ## Example 3672 3673 >>> g = DecisionGraph() 3674 >>> g.addDecision('A') 3675 0 3676 >>> g.addDecision('B') 3677 1 3678 >>> g.addTransition('A', 'up', 'B', 'down', tags={'wide'}) 3679 >>> g.addTransition('A', 'in', 'B', 'out') # we won't touch this 3680 >>> g.addTransition('A', 'next', 'B') 3681 >>> g.setReciprocal('A', 'next', 'down', setBoth=False) 3682 >>> p = g.removeTransition('A', 'up') 3683 >>> p['tags'] 3684 {'wide'} 3685 >>> g.destinationsFrom('A') 3686 {'in': 1, 'next': 1} 3687 >>> g.destinationsFrom('B') 3688 {'down': 0, 'out': 0} 3689 >>> g.getReciprocal('B', 'down') is None 3690 True 3691 >>> g.getReciprocal('A', 'next') # Asymmetrical left over 3692 'down' 3693 >>> g.getReciprocal('A', 'in') # not affected 3694 'out' 3695 >>> g.getReciprocal('B', 'out') # not affected 3696 'in' 3697 >>> # Now with removeReciprocal set to True 3698 >>> g.addTransition('A', 'up', 'B') # add this back in 3699 >>> g.setReciprocal('A', 'up', 'down') # sets both 3700 >>> p = g.removeTransition('A', 'up', removeReciprocal=True) 3701 >>> g.destinationsFrom('A') 3702 {'in': 1, 'next': 1} 3703 >>> g.destinationsFrom('B') 3704 {'out': 0} 3705 >>> g.getReciprocal('A', 'next') is None 3706 True 3707 >>> g.getReciprocal('A', 'in') # not affected 3708 'out' 3709 >>> g.getReciprocal('B', 'out') # not affected 3710 'in' 3711 >>> g.removeTransition('A', 'none') 3712 Traceback (most recent call last): 3713 ... 3714 exploration.core.MissingTransitionError... 3715 >>> g.removeTransition('Z', 'nope') 3716 Traceback (most recent call last): 3717 ... 3718 exploration.core.MissingDecisionError... 3719 """ 3720 # Resolve target ID 3721 fromID = self.resolveDecision(fromDecision) 3722 3723 # raises if either is missing: 3724 destination = self.destination(fromID, transition) 3725 reciprocal = self.getReciprocal(fromID, transition) 3726 3727 # Get dictionaries of parallel & antiparallel edges to be 3728 # checked for invalid reciprocals after removing edges 3729 # Note: these will update live as we remove edges 3730 allAntiparallel = self[destination][fromID] 3731 allParallel = self[fromID][destination] 3732 3733 # Remove the target edge 3734 fProps = self.getTransitionProperties(fromID, transition) 3735 self.remove_edge(fromID, destination, transition) 3736 3737 # Clean up any dangling reciprocal values 3738 for tProps in allAntiparallel.values(): 3739 if tProps.get('reciprocal') == transition: 3740 del tProps['reciprocal'] 3741 3742 # Remove the reciprocal if requested 3743 if removeReciprocal and reciprocal is not None: 3744 rProps = self.getTransitionProperties(destination, reciprocal) 3745 self.remove_edge(destination, fromID, reciprocal) 3746 3747 # Clean up any dangling reciprocal values 3748 for tProps in allParallel.values(): 3749 if tProps.get('reciprocal') == reciprocal: 3750 del tProps['reciprocal'] 3751 3752 return (fProps, rProps) 3753 else: 3754 return fProps 3755 3756 def addMechanism( 3757 self, 3758 name: base.MechanismName, 3759 where: Optional[base.AnyDecisionSpecifier] = None 3760 ) -> base.MechanismID: 3761 """ 3762 Creates a new mechanism with the given name at the specified 3763 decision, returning its assigned ID. If `where` is `None`, it 3764 creates a global mechanism. Raises a `MechanismCollisionError` 3765 if a mechanism with the same name already exists at a specified 3766 decision (or already exists as a global mechanism). 3767 3768 Note that if the decision is deleted, the mechanism will be as 3769 well. 3770 3771 Since `MechanismState`s are not tracked by `DecisionGraph`s but 3772 instead are part of a `State`, the mechanism won't be in any 3773 particular state, which means it will be treated as being in the 3774 `base.DEFAULT_MECHANISM_STATE`. 3775 """ 3776 if where is None: 3777 mechs = self.globalMechanisms 3778 dID = None 3779 else: 3780 dID = self.resolveDecision(where) 3781 mechs = self.nodes[dID].setdefault('mechanisms', {}) 3782 3783 if name in mechs: 3784 if dID is None: 3785 raise MechanismCollisionError( 3786 f"A global mechanism named {name!r} already exists." 3787 ) 3788 else: 3789 raise MechanismCollisionError( 3790 f"A mechanism named {name!r} already exists at" 3791 f" decision {self.identityOf(dID)}." 3792 ) 3793 3794 mID = self._assignMechanismID() 3795 mechs[name] = mID 3796 self.mechanisms[mID] = (dID, name) 3797 return mID 3798 3799 def mechanismsAt( 3800 self, 3801 decision: base.AnyDecisionSpecifier 3802 ) -> Dict[base.MechanismName, base.MechanismID]: 3803 """ 3804 Returns a dictionary mapping mechanism names to their IDs for 3805 all mechanisms at the specified decision. 3806 """ 3807 dID = self.resolveDecision(decision) 3808 3809 return self.nodes[dID]['mechanisms'] 3810 3811 def mechanismDetails( 3812 self, 3813 mID: base.MechanismID 3814 ) -> Optional[Tuple[Optional[base.DecisionID], base.MechanismName]]: 3815 """ 3816 Returns a tuple containing the decision ID and mechanism name 3817 for the specified mechanism. Returns `None` if there is no 3818 mechanism with that ID. For global mechanisms, `None` is used in 3819 place of a decision ID. 3820 """ 3821 return self.mechanisms.get(mID) 3822 3823 def deleteMechanism(self, mID: base.MechanismID) -> None: 3824 """ 3825 Deletes the specified mechanism. 3826 """ 3827 name, dID = self.mechanisms.pop(mID) 3828 3829 del self.nodes[dID]['mechanisms'][name] 3830 3831 def localLookup( 3832 self, 3833 startFrom: Union[ 3834 base.AnyDecisionSpecifier, 3835 Collection[base.AnyDecisionSpecifier] 3836 ], 3837 findAmong: Callable[ 3838 ['DecisionGraph', Union[Set[base.DecisionID], str]], 3839 Optional[LookupResult] 3840 ], 3841 fallbackLayerName: Optional[str] = "fallback", 3842 fallbackToAllDecisions: bool = True 3843 ) -> Optional[LookupResult]: 3844 """ 3845 Looks up some kind of result in the graph by starting from a 3846 base set of decisions and widening the search iteratively based 3847 on zones. This first searches for result(s) in the set of 3848 decisions given, then in the set of all decisions which are in 3849 level-0 zones containing those decisions, then in level-1 zones, 3850 etc. When it runs out of relevant zones, it will check all 3851 decisions which are in any domain that a decision from the 3852 initial search set is in, and then if `fallbackLayerName` is a 3853 string, it will provide that string instead of a set of decision 3854 IDs to the `findAmong` function as the next layer to search. 3855 After the `fallbackLayerName` is used, if 3856 `fallbackToAllDecisions` is `True` (the default) a final search 3857 will be run on all decisions in the graph. The provided 3858 `findAmong` function is called on each successive decision ID 3859 set, until it generates a non-`None` result. We stop and return 3860 that non-`None` result as soon as one is generated. But if none 3861 of the decision sets consulted generate non-`None` results, then 3862 the entire result will be `None`. 3863 """ 3864 # Normalize starting decisions to a set 3865 if isinstance(startFrom, (int, str, base.DecisionSpecifier)): 3866 startFrom = set([startFrom]) 3867 3868 # Resolve decision IDs; convert to list 3869 searchArea: Union[Set[base.DecisionID], str] = set( 3870 self.resolveDecision(spec) for spec in startFrom 3871 ) 3872 3873 # Find all ancestor zones & all relevant domains 3874 allAncestors = set() 3875 relevantDomains = set() 3876 for startingDecision in searchArea: 3877 allAncestors |= self.zoneAncestors(startingDecision) 3878 relevantDomains.add(self.domainFor(startingDecision)) 3879 3880 # Build layers dictionary 3881 ancestorLayers: Dict[int, Set[base.Zone]] = {} 3882 for zone in allAncestors: 3883 info = self.getZoneInfo(zone) 3884 assert info is not None 3885 level = info.level 3886 ancestorLayers.setdefault(level, set()).add(zone) 3887 3888 searchLayers: LookupLayersList = ( 3889 cast(LookupLayersList, [None]) 3890 + cast(LookupLayersList, sorted(ancestorLayers.keys())) 3891 + cast(LookupLayersList, ["domains"]) 3892 ) 3893 if fallbackLayerName is not None: 3894 searchLayers.append("fallback") 3895 3896 if fallbackToAllDecisions: 3897 searchLayers.append("all") 3898 3899 # Continue our search through zone layers 3900 for layer in searchLayers: 3901 # Update search area on subsequent iterations 3902 if layer == "domains": 3903 searchArea = set() 3904 for relevant in relevantDomains: 3905 searchArea |= self.allDecisionsInDomain(relevant) 3906 elif layer == "fallback": 3907 assert fallbackLayerName is not None 3908 searchArea = fallbackLayerName 3909 elif layer == "all": 3910 searchArea = set(self.nodes) 3911 elif layer is not None: 3912 layer = cast(int, layer) # must be an integer 3913 searchZones = ancestorLayers[layer] 3914 searchArea = set() 3915 for zone in searchZones: 3916 searchArea |= self.allDecisionsInZone(zone) 3917 # else it's the first iteration and we use the starting 3918 # searchArea 3919 3920 searchResult: Optional[LookupResult] = findAmong( 3921 self, 3922 searchArea 3923 ) 3924 3925 if searchResult is not None: 3926 return searchResult 3927 3928 # Didn't find any non-None results. 3929 return None 3930 3931 @staticmethod 3932 def uniqueMechanismFinder(name: base.MechanismName) -> Callable[ 3933 ['DecisionGraph', Union[Set[base.DecisionID], str]], 3934 Optional[base.MechanismID] 3935 ]: 3936 """ 3937 Returns a search function that looks for the given mechanism ID, 3938 suitable for use with `localLookup`. The finder will raise a 3939 `MechanismCollisionError` if it finds more than one mechanism 3940 with the specified name at the same level of the search. 3941 """ 3942 def namedMechanismFinder( 3943 graph: 'DecisionGraph', 3944 searchIn: Union[Set[base.DecisionID], str] 3945 ) -> Optional[base.MechanismID]: 3946 """ 3947 Generated finder function for `localLookup` to find a unique 3948 mechanism by name. 3949 """ 3950 candidates: List[base.DecisionID] = [] 3951 3952 if searchIn == "fallback": 3953 if name in graph.globalMechanisms: 3954 candidates = [graph.globalMechanisms[name]] 3955 3956 else: 3957 assert isinstance(searchIn, set) 3958 for dID in searchIn: 3959 mechs = graph.nodes[dID].get('mechanisms', {}) 3960 if name in mechs: 3961 candidates.append(mechs[name]) 3962 3963 if len(candidates) > 1: 3964 raise MechanismCollisionError( 3965 f"There are {len(candidates)} mechanisms named {name!r}" 3966 f" in the search area ({len(searchIn)} decisions(s))." 3967 ) 3968 elif len(candidates) == 1: 3969 return candidates[0] 3970 else: 3971 return None 3972 3973 return namedMechanismFinder 3974 3975 def lookupMechanism( 3976 self, 3977 startFrom: Union[ 3978 base.AnyDecisionSpecifier, 3979 Collection[base.AnyDecisionSpecifier] 3980 ], 3981 name: base.MechanismName 3982 ) -> base.MechanismID: 3983 """ 3984 Looks up the mechanism with the given name 'closest' to the 3985 given decision or set of decisions. First it looks for a 3986 mechanism with that name that's at one of those decisions. Then 3987 it starts looking in level-0 zones which contain any of them, 3988 then in level-1 zones, and so on. If it finds two mechanisms 3989 with the target name during the same search pass, it raises a 3990 `MechanismCollisionError`, but if it finds one it returns it. 3991 Raises a `MissingMechanismError` if there is no mechanisms with 3992 that name among global mechanisms (searched after the last 3993 applicable level of zones) or anywhere in the graph (which is the 3994 final level of search after checking global mechanisms). 3995 3996 For example: 3997 3998 >>> d = DecisionGraph() 3999 >>> d.addDecision('A') 4000 0 4001 >>> d.addDecision('B') 4002 1 4003 >>> d.addDecision('C') 4004 2 4005 >>> d.addDecision('D') 4006 3 4007 >>> d.addDecision('E') 4008 4 4009 >>> d.addMechanism('switch', 'A') 4010 0 4011 >>> d.addMechanism('switch', 'B') 4012 1 4013 >>> d.addMechanism('switch', 'C') 4014 2 4015 >>> d.addMechanism('lever', 'D') 4016 3 4017 >>> d.addMechanism('lever', None) # global 4018 4 4019 >>> d.createZone('Z1', 0) 4020 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 4021 annotations=[]) 4022 >>> d.createZone('Z2', 0) 4023 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 4024 annotations=[]) 4025 >>> d.createZone('Zup', 1) 4026 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 4027 annotations=[]) 4028 >>> d.addDecisionToZone('A', 'Z1') 4029 >>> d.addDecisionToZone('B', 'Z1') 4030 >>> d.addDecisionToZone('C', 'Z2') 4031 >>> d.addDecisionToZone('D', 'Z2') 4032 >>> d.addDecisionToZone('E', 'Z1') 4033 >>> d.addZoneToZone('Z1', 'Zup') 4034 >>> d.addZoneToZone('Z2', 'Zup') 4035 >>> d.lookupMechanism(set(), 'switch') # 3x among all decisions 4036 Traceback (most recent call last): 4037 ... 4038 exploration.core.MechanismCollisionError... 4039 >>> d.lookupMechanism(set(), 'lever') # 1x global > 1x all 4040 4 4041 >>> d.lookupMechanism({'D'}, 'lever') # local 4042 3 4043 >>> d.lookupMechanism({'A'}, 'lever') # found at D via Zup 4044 3 4045 >>> d.lookupMechanism({'A', 'D'}, 'lever') # local again 4046 3 4047 >>> d.lookupMechanism({'A'}, 'switch') # local 4048 0 4049 >>> d.lookupMechanism({'B'}, 'switch') # local 4050 1 4051 >>> d.lookupMechanism({'C'}, 'switch') # local 4052 2 4053 >>> d.lookupMechanism({'A', 'B'}, 'switch') # ambiguous 4054 Traceback (most recent call last): 4055 ... 4056 exploration.core.MechanismCollisionError... 4057 >>> d.lookupMechanism({'A', 'B', 'C'}, 'switch') # ambiguous 4058 Traceback (most recent call last): 4059 ... 4060 exploration.core.MechanismCollisionError... 4061 >>> d.lookupMechanism({'B', 'D'}, 'switch') # not ambiguous 4062 1 4063 >>> d.lookupMechanism({'E', 'D'}, 'switch') # ambiguous at L0 zone 4064 Traceback (most recent call last): 4065 ... 4066 exploration.core.MechanismCollisionError... 4067 >>> d.lookupMechanism({'E'}, 'switch') # ambiguous at L0 zone 4068 Traceback (most recent call last): 4069 ... 4070 exploration.core.MechanismCollisionError... 4071 >>> d.lookupMechanism({'D'}, 'switch') # found at L0 zone 4072 2 4073 """ 4074 result = self.localLookup( 4075 startFrom, 4076 DecisionGraph.uniqueMechanismFinder(name) 4077 ) 4078 if result is None: 4079 raise MissingMechanismError( 4080 f"No mechanism named {name!r}" 4081 ) 4082 else: 4083 return result 4084 4085 def resolveMechanism( 4086 self, 4087 specifier: base.AnyMechanismSpecifier, 4088 startFrom: Union[ 4089 None, 4090 base.AnyDecisionSpecifier, 4091 Collection[base.AnyDecisionSpecifier] 4092 ] = None 4093 ) -> base.MechanismID: 4094 """ 4095 Works like `lookupMechanism`, except it accepts a 4096 `base.AnyMechanismSpecifier` which may have position information 4097 baked in, and so the `startFrom` information is optional. If 4098 position information isn't specified in the mechanism specifier 4099 and startFrom is not provided, the mechanism is searched for at 4100 the global scope and then in the entire graph. On the other 4101 hand, if the specifier includes any position information, the 4102 startFrom value provided here will be ignored. 4103 """ 4104 if isinstance(specifier, base.MechanismID): 4105 return specifier 4106 4107 elif isinstance(specifier, base.MechanismName): 4108 if startFrom is None: 4109 startFrom = set() 4110 return self.lookupMechanism(startFrom, specifier) 4111 4112 elif isinstance(specifier, tuple) and len(specifier) == 4: 4113 domain, zone, decision, mechanism = specifier 4114 if domain is None and zone is None and decision is None: 4115 if startFrom is None: 4116 startFrom = set() 4117 return self.lookupMechanism(startFrom, mechanism) 4118 4119 elif decision is not None: 4120 startFrom = { 4121 self.resolveDecision( 4122 base.DecisionSpecifier(domain, zone, decision) 4123 ) 4124 } 4125 return self.lookupMechanism(startFrom, mechanism) 4126 4127 else: # decision is None but domain and/or zone aren't 4128 startFrom = set() 4129 if zone is not None: 4130 baseStart = self.allDecisionsInZone(zone) 4131 else: 4132 baseStart = set(self) 4133 4134 if domain is None: 4135 startFrom = baseStart 4136 else: 4137 for dID in baseStart: 4138 if self.domainFor(dID) == domain: 4139 startFrom.add(dID) 4140 return self.lookupMechanism(startFrom, mechanism) 4141 4142 else: 4143 raise TypeError( 4144 f"Invalid mechanism specifier: {repr(specifier)}" 4145 f"\n(Must be a mechanism ID, mechanism name, or" 4146 f" mechanism specifier tuple)" 4147 ) 4148 4149 def walkConsequenceMechanisms( 4150 self, 4151 consequence: base.Consequence, 4152 searchFrom: Set[base.DecisionID] 4153 ) -> Generator[base.MechanismID, None, None]: 4154 """ 4155 Yields each requirement in the given `base.Consequence`, 4156 including those in `base.Condition`s, `base.ConditionalSkill`s 4157 within `base.Challenge`s, and those set or toggled by 4158 `base.Effect`s. The `searchFrom` argument specifies where to 4159 start searching for mechanisms, since requirements include them 4160 by name, not by ID. 4161 """ 4162 for part in base.walkParts(consequence): 4163 if isinstance(part, dict): 4164 if 'skills' in part: # a Challenge 4165 for cSkill in part['skills'].walk(): 4166 if isinstance(cSkill, base.ConditionalSkill): 4167 yield from self.walkRequirementMechanisms( 4168 cSkill.requirement, 4169 searchFrom 4170 ) 4171 elif 'condition' in part: # a Condition 4172 yield from self.walkRequirementMechanisms( 4173 part['condition'], 4174 searchFrom 4175 ) 4176 elif 'value' in part: # an Effect 4177 val = part['value'] 4178 if part['type'] == 'set': 4179 if ( 4180 isinstance(val, tuple) 4181 and len(val) == 2 4182 and isinstance(val[1], base.State) 4183 ): 4184 yield from self.walkRequirementMechanisms( 4185 base.ReqMechanism(val[0], val[1]), 4186 searchFrom 4187 ) 4188 elif part['type'] == 'toggle': 4189 if isinstance(val, tuple): 4190 assert len(val) == 2 4191 yield from self.walkRequirementMechanisms( 4192 base.ReqMechanism(val[0], '_'), 4193 # state part is ignored here 4194 searchFrom 4195 ) 4196 4197 def walkRequirementMechanisms( 4198 self, 4199 req: base.Requirement, 4200 searchFrom: Set[base.DecisionID] 4201 ) -> Generator[base.MechanismID, None, None]: 4202 """ 4203 Given a requirement, yields any mechanisms mentioned in that 4204 requirement, in depth-first traversal order. 4205 """ 4206 for part in req.walk(): 4207 if isinstance(part, base.ReqMechanism): 4208 mech = part.mechanism 4209 yield self.resolveMechanism( 4210 mech, 4211 startFrom=searchFrom 4212 ) 4213 4214 def addUnexploredEdge( 4215 self, 4216 fromDecision: base.AnyDecisionSpecifier, 4217 name: base.Transition, 4218 destinationName: Optional[base.DecisionName] = None, 4219 reciprocal: Optional[base.Transition] = 'return', 4220 toDomain: Optional[base.Domain] = None, 4221 placeInZone: Union[base.Zone, type[base.DefaultZone], None] = None, 4222 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 4223 annotations: Optional[List[base.Annotation]] = None, 4224 revTags: Optional[Dict[base.Tag, base.TagValue]] = None, 4225 revAnnotations: Optional[List[base.Annotation]] = None, 4226 requires: Optional[base.Requirement] = None, 4227 consequence: Optional[base.Consequence] = None, 4228 revRequires: Optional[base.Requirement] = None, 4229 revConsequece: Optional[base.Consequence] = None 4230 ) -> base.DecisionID: 4231 """ 4232 Adds a transition connecting to a new decision named `'_u.-n-'` 4233 where '-n-' is the number of unknown decisions (named or not) 4234 that have ever been created in this graph (or using the 4235 specified destination name if one is provided). This represents 4236 a transition to an unknown destination. The destination node 4237 gets tagged 'unconfirmed'. 4238 4239 This also adds a reciprocal transition in the reverse direction, 4240 unless `reciprocal` is set to `None`. The reciprocal will use 4241 the provided name (default is 'return'). The new decision will 4242 be in the same domain as the decision it's connected to, unless 4243 `toDecision` is specified, in which case it will be in that 4244 domain. 4245 4246 The new decision will not be placed into any zones, unless 4247 `placeInZone` is specified, in which case it will be placed into 4248 that zone. If that zone needs to be created, it will be created 4249 at level 0; in that case that zone will be added to any 4250 grandparent zones of the decision we're branching off of. If 4251 `placeInZone` is set to `base.DefaultZone`, then the new 4252 decision will be placed into each parent zone of the decision 4253 we're branching off of, as long as the new decision is in the 4254 same domain as the decision we're branching from (otherwise only 4255 an explicit `placeInZone` would apply). 4256 4257 The ID of the decision that was created is returned. 4258 4259 A `MissingDecisionError` will be raised if the starting decision 4260 does not exist, a `TransitionCollisionError` will be raised if 4261 it exists but already has a transition with the given name, and a 4262 `DecisionCollisionWarning` will be issued if a decision with the 4263 specified destination name already exists (won't happen when 4264 using an automatic name). 4265 4266 Lists of tags and/or annotations (strings in both cases) may be 4267 provided. These may also be provided for the reciprocal edge. 4268 4269 Similarly, requirements and/or consequences for either edge may 4270 be provided. 4271 4272 ## Example 4273 4274 >>> g = DecisionGraph() 4275 >>> g.addDecision('A') 4276 0 4277 >>> g.addUnexploredEdge('A', 'up') 4278 1 4279 >>> g.nameFor(1) 4280 '_u.0' 4281 >>> g.decisionTags(1) 4282 {'unconfirmed': 1} 4283 >>> g.addUnexploredEdge('A', 'right', 'B') 4284 2 4285 >>> g.nameFor(2) 4286 'B' 4287 >>> g.decisionTags(2) 4288 {'unconfirmed': 1} 4289 >>> g.addUnexploredEdge('A', 'down', None, 'up') 4290 3 4291 >>> g.nameFor(3) 4292 '_u.2' 4293 >>> g.addUnexploredEdge( 4294 ... '_u.0', 4295 ... 'beyond', 4296 ... toDomain='otherDomain', 4297 ... tags={'fast':1}, 4298 ... revTags={'slow':1}, 4299 ... annotations=['comment'], 4300 ... revAnnotations=['one', 'two'], 4301 ... requires=base.ReqCapability('dash'), 4302 ... revRequires=base.ReqCapability('super dash'), 4303 ... consequence=[base.effect(gain='super dash')], 4304 ... revConsequece=[base.effect(lose='super dash')] 4305 ... ) 4306 4 4307 >>> g.nameFor(4) 4308 '_u.3' 4309 >>> g.domainFor(4) 4310 'otherDomain' 4311 >>> g.transitionTags('_u.0', 'beyond') 4312 {'fast': 1} 4313 >>> g.transitionAnnotations('_u.0', 'beyond') 4314 ['comment'] 4315 >>> g.getTransitionRequirement('_u.0', 'beyond') 4316 ReqCapability('dash') 4317 >>> e = g.getConsequence('_u.0', 'beyond') 4318 >>> e == [base.effect(gain='super dash')] 4319 True 4320 >>> g.transitionTags('_u.3', 'return') 4321 {'slow': 1} 4322 >>> g.transitionAnnotations('_u.3', 'return') 4323 ['one', 'two'] 4324 >>> g.getTransitionRequirement('_u.3', 'return') 4325 ReqCapability('super dash') 4326 >>> e = g.getConsequence('_u.3', 'return') 4327 >>> e == [base.effect(lose='super dash')] 4328 True 4329 """ 4330 # Defaults 4331 if tags is None: 4332 tags = {} 4333 if annotations is None: 4334 annotations = [] 4335 if revTags is None: 4336 revTags = {} 4337 if revAnnotations is None: 4338 revAnnotations = [] 4339 4340 # Resolve ID 4341 fromID = self.resolveDecision(fromDecision) 4342 if toDomain is None: 4343 toDomain = self.domainFor(fromID) 4344 4345 if name in self.destinationsFrom(fromID): 4346 raise TransitionCollisionError( 4347 f"Cannot add a new edge {name!r}:" 4348 f" {self.identityOf(fromDecision)} already has an" 4349 f" outgoing edge with that name." 4350 ) 4351 4352 if destinationName in self.nameLookup and WARN_OF_NAME_COLLISIONS: 4353 warnings.warn( 4354 ( 4355 f"Cannot add a new unexplored node" 4356 f" {destinationName!r}: A decision with that name" 4357 f" already exists.\n(Leave destinationName as None" 4358 f" to use an automatic name.)" 4359 ), 4360 DecisionCollisionWarning 4361 ) 4362 4363 # Create the new unexplored decision and add the edge 4364 if destinationName is None: 4365 toName = '_u.' + str(self.unknownCount) 4366 else: 4367 toName = destinationName 4368 self.unknownCount += 1 4369 newID = self.addDecision(toName, domain=toDomain) 4370 self.addTransition( 4371 fromID, 4372 name, 4373 newID, 4374 tags=tags, 4375 annotations=annotations 4376 ) 4377 self.setTransitionRequirement(fromID, name, requires) 4378 if consequence is not None: 4379 self.setConsequence(fromID, name, consequence) 4380 4381 # Add it to a zone if requested 4382 if ( 4383 placeInZone is base.DefaultZone 4384 and toDomain == self.domainFor(fromID) 4385 ): 4386 # Add to each parent of the from decision 4387 for parent in self.zoneParents(fromID): 4388 self.addDecisionToZone(newID, parent) 4389 elif placeInZone is not None: 4390 # Otherwise add it to one specific zone, creating that zone 4391 # at level 0 if necessary 4392 assert isinstance(placeInZone, base.Zone) 4393 if self.getZoneInfo(placeInZone) is None: 4394 self.createZone(placeInZone, 0) 4395 # Add new zone to each grandparent of the from decision 4396 for parent in self.zoneParents(fromID): 4397 for grandparent in self.zoneParents(parent): 4398 self.addZoneToZone(placeInZone, grandparent) 4399 self.addDecisionToZone(newID, placeInZone) 4400 4401 # Create the reciprocal edge 4402 if reciprocal is not None: 4403 self.addTransition( 4404 newID, 4405 reciprocal, 4406 fromID, 4407 tags=revTags, 4408 annotations=revAnnotations 4409 ) 4410 self.setTransitionRequirement(newID, reciprocal, revRequires) 4411 if revConsequece is not None: 4412 self.setConsequence(newID, reciprocal, revConsequece) 4413 # Set as a reciprocal 4414 self.setReciprocal(fromID, name, reciprocal) 4415 4416 # Tag the destination as 'unconfirmed' 4417 self.tagDecision(newID, 'unconfirmed') 4418 4419 # Return ID of new destination 4420 return newID 4421 4422 def retargetTransition( 4423 self, 4424 fromDecision: base.AnyDecisionSpecifier, 4425 transition: base.Transition, 4426 newDestination: base.AnyDecisionSpecifier, 4427 swapReciprocal=True, 4428 errorOnNameColision=True 4429 ) -> Optional[base.Transition]: 4430 """ 4431 Given a particular decision and a transition at that decision, 4432 changes that transition so that it goes to the specified new 4433 destination instead of wherever it was connected to before. If 4434 the new destination is the same as the old one, no changes are 4435 made. 4436 4437 If `swapReciprocal` is set to True (the default) then any 4438 reciprocal edge at the old destination will be deleted, and a 4439 new reciprocal edge from the new destination with equivalent 4440 properties to the original reciprocal will be created, pointing 4441 to the origin of the specified transition. If `swapReciprocal` 4442 is set to False, then the reciprocal relationship with any old 4443 reciprocal edge will be removed, but the old reciprocal edge 4444 will not be changed. 4445 4446 Note that if `errorOnNameColision` is True (the default), then 4447 if the reciprocal transition has the same name as a transition 4448 which already exists at the new destination node, a 4449 `TransitionCollisionError` will be thrown. However, if it is set 4450 to False, the reciprocal transition will be renamed with a suffix 4451 to avoid any possible name collisions. Either way, the name of 4452 the reciprocal transition (possibly just changed) will be 4453 returned, or None if there was no reciprocal transition. 4454 4455 ## Example 4456 4457 >>> g = DecisionGraph() 4458 >>> for fr, to, nm in [ 4459 ... ('A', 'B', 'up'), 4460 ... ('A', 'B', 'up2'), 4461 ... ('B', 'A', 'down'), 4462 ... ('B', 'B', 'self'), 4463 ... ('B', 'C', 'next'), 4464 ... ('C', 'B', 'prev') 4465 ... ]: 4466 ... if g.getDecision(fr) is None: 4467 ... g.addDecision(fr) 4468 ... if g.getDecision(to) is None: 4469 ... g.addDecision(to) 4470 ... g.addTransition(fr, nm, to) 4471 0 4472 1 4473 2 4474 >>> g.setReciprocal('A', 'up', 'down') 4475 >>> g.setReciprocal('B', 'next', 'prev') 4476 >>> g.destination('A', 'up') 4477 1 4478 >>> g.destination('B', 'down') 4479 0 4480 >>> g.retargetTransition('A', 'up', 'C') 4481 'down' 4482 >>> g.destination('A', 'up') 4483 2 4484 >>> g.getDestination('B', 'down') is None 4485 True 4486 >>> g.destination('C', 'down') 4487 0 4488 >>> g.addTransition('A', 'next', 'B') 4489 >>> g.addTransition('B', 'prev', 'A') 4490 >>> g.setReciprocal('A', 'next', 'prev') 4491 >>> # Can't swap a reciprocal in a way that would collide names 4492 >>> g.getReciprocal('C', 'prev') 4493 'next' 4494 >>> g.retargetTransition('C', 'prev', 'A') 4495 Traceback (most recent call last): 4496 ... 4497 exploration.core.TransitionCollisionError... 4498 >>> g.retargetTransition('C', 'prev', 'A', swapReciprocal=False) 4499 'next' 4500 >>> g.destination('C', 'prev') 4501 0 4502 >>> g.destination('A', 'next') # not changed 4503 1 4504 >>> # Reciprocal relationship is severed: 4505 >>> g.getReciprocal('C', 'prev') is None 4506 True 4507 >>> g.getReciprocal('B', 'next') is None 4508 True 4509 >>> # Swap back so we can do another demo 4510 >>> g.retargetTransition('C', 'prev', 'B', swapReciprocal=False) 4511 >>> # Note return value was None here because there was no reciprocal 4512 >>> g.setReciprocal('C', 'prev', 'next') 4513 >>> # Swap reciprocal by renaming it 4514 >>> g.retargetTransition('C', 'prev', 'A', errorOnNameColision=False) 4515 'next.1' 4516 >>> g.getReciprocal('C', 'prev') 4517 'next.1' 4518 >>> g.destination('C', 'prev') 4519 0 4520 >>> g.destination('A', 'next.1') 4521 2 4522 >>> g.destination('A', 'next') 4523 1 4524 >>> # Note names are the same but these are from different nodes 4525 >>> g.getReciprocal('A', 'next') 4526 'prev' 4527 >>> g.getReciprocal('A', 'next.1') 4528 'prev' 4529 """ 4530 fromID = self.resolveDecision(fromDecision) 4531 newDestID = self.resolveDecision(newDestination) 4532 4533 # Figure out the old destination of the transition we're swapping 4534 oldDestID = self.destination(fromID, transition) 4535 reciprocal = self.getReciprocal(fromID, transition) 4536 4537 # If thew new destination is the same, we don't do anything! 4538 if oldDestID == newDestID: 4539 return reciprocal 4540 4541 # First figure out reciprocal business so we can error out 4542 # without making changes if we need to 4543 if swapReciprocal and reciprocal is not None: 4544 reciprocal = self.rebaseTransition( 4545 oldDestID, 4546 reciprocal, 4547 newDestID, 4548 swapReciprocal=False, 4549 errorOnNameColision=errorOnNameColision 4550 ) 4551 4552 # Handle the forward transition... 4553 # Find the transition properties 4554 tProps = self.getTransitionProperties(fromID, transition) 4555 4556 # Delete the edge 4557 self.removeEdgeByKey(fromID, transition) 4558 4559 # Add the new edge 4560 self.addTransition(fromID, transition, newDestID) 4561 4562 # Reapply the transition properties 4563 self.setTransitionProperties(fromID, transition, **tProps) 4564 4565 # Handle the reciprocal transition if there is one... 4566 if reciprocal is not None: 4567 if not swapReciprocal: 4568 # Then sever the relationship, but only if that edge 4569 # still exists (we might be in the middle of a rebase) 4570 check = self.getDestination(oldDestID, reciprocal) 4571 if check is not None: 4572 self.setReciprocal( 4573 oldDestID, 4574 reciprocal, 4575 None, 4576 setBoth=False # Other transition was deleted already 4577 ) 4578 else: 4579 # Establish new reciprocal relationship 4580 self.setReciprocal( 4581 fromID, 4582 transition, 4583 reciprocal 4584 ) 4585 4586 return reciprocal 4587 4588 def rebaseTransition( 4589 self, 4590 fromDecision: base.AnyDecisionSpecifier, 4591 transition: base.Transition, 4592 newBase: base.AnyDecisionSpecifier, 4593 swapReciprocal=True, 4594 errorOnNameColision=True 4595 ) -> base.Transition: 4596 """ 4597 Given a particular destination and a transition at that 4598 destination, changes that transition's origin to a new base 4599 decision. If the new source is the same as the old one, no 4600 changes are made. 4601 4602 If `swapReciprocal` is set to True (the default) then any 4603 reciprocal edge at the destination will be retargeted to point 4604 to the new source so that it can remain a reciprocal. If 4605 `swapReciprocal` is set to False, then the reciprocal 4606 relationship with any old reciprocal edge will be removed, but 4607 the old reciprocal edge will not be otherwise changed. 4608 4609 Note that if `errorOnNameColision` is True (the default), then 4610 if the transition has the same name as a transition which 4611 already exists at the new source node, a 4612 `TransitionCollisionError` will be raised. However, if it is set 4613 to False, the transition will be renamed with a suffix to avoid 4614 any possible name collisions. Either way, the (possibly new) name 4615 of the transition that was rebased will be returned. 4616 4617 ## Example 4618 4619 >>> g = DecisionGraph() 4620 >>> for fr, to, nm in [ 4621 ... ('A', 'B', 'up'), 4622 ... ('A', 'B', 'up2'), 4623 ... ('B', 'A', 'down'), 4624 ... ('B', 'B', 'self'), 4625 ... ('B', 'C', 'next'), 4626 ... ('C', 'B', 'prev') 4627 ... ]: 4628 ... if g.getDecision(fr) is None: 4629 ... g.addDecision(fr) 4630 ... if g.getDecision(to) is None: 4631 ... g.addDecision(to) 4632 ... g.addTransition(fr, nm, to) 4633 0 4634 1 4635 2 4636 >>> g.setReciprocal('A', 'up', 'down') 4637 >>> g.setReciprocal('B', 'next', 'prev') 4638 >>> g.destination('A', 'up') 4639 1 4640 >>> g.destination('B', 'down') 4641 0 4642 >>> g.rebaseTransition('B', 'down', 'C') 4643 'down' 4644 >>> g.destination('A', 'up') 4645 2 4646 >>> g.getDestination('B', 'down') is None 4647 True 4648 >>> g.destination('C', 'down') 4649 0 4650 >>> g.addTransition('A', 'next', 'B') 4651 >>> g.addTransition('B', 'prev', 'A') 4652 >>> g.setReciprocal('A', 'next', 'prev') 4653 >>> # Can't rebase in a way that would collide names 4654 >>> g.rebaseTransition('B', 'next', 'A') 4655 Traceback (most recent call last): 4656 ... 4657 exploration.core.TransitionCollisionError... 4658 >>> g.rebaseTransition('B', 'next', 'A', errorOnNameColision=False) 4659 'next.1' 4660 >>> g.destination('C', 'prev') 4661 0 4662 >>> g.destination('A', 'next') # not changed 4663 1 4664 >>> # Collision is avoided by renaming 4665 >>> g.destination('A', 'next.1') 4666 2 4667 >>> # Swap without reciprocal 4668 >>> g.getReciprocal('A', 'next.1') 4669 'prev' 4670 >>> g.getReciprocal('C', 'prev') 4671 'next.1' 4672 >>> g.rebaseTransition('A', 'next.1', 'B', swapReciprocal=False) 4673 'next.1' 4674 >>> g.getReciprocal('C', 'prev') is None 4675 True 4676 >>> g.destination('C', 'prev') 4677 0 4678 >>> g.getDestination('A', 'next.1') is None 4679 True 4680 >>> g.destination('A', 'next') 4681 1 4682 >>> g.destination('B', 'next.1') 4683 2 4684 >>> g.getReciprocal('B', 'next.1') is None 4685 True 4686 >>> # Rebase in a way that creates a self-edge 4687 >>> g.rebaseTransition('A', 'next', 'B') 4688 'next' 4689 >>> g.getDestination('A', 'next') is None 4690 True 4691 >>> g.destination('B', 'next') 4692 1 4693 >>> g.destination('B', 'prev') # swapped as a reciprocal 4694 1 4695 >>> g.getReciprocal('B', 'next') # still reciprocals 4696 'prev' 4697 >>> g.getReciprocal('B', 'prev') 4698 'next' 4699 >>> # And rebasing of a self-edge also works 4700 >>> g.rebaseTransition('B', 'prev', 'A') 4701 'prev' 4702 >>> g.destination('A', 'prev') 4703 1 4704 >>> g.destination('B', 'next') 4705 0 4706 >>> g.getReciprocal('B', 'next') # still reciprocals 4707 'prev' 4708 >>> g.getReciprocal('A', 'prev') 4709 'next' 4710 >>> # We've effectively reversed this edge/reciprocal pair 4711 >>> # by rebasing twice 4712 """ 4713 fromID = self.resolveDecision(fromDecision) 4714 newBaseID = self.resolveDecision(newBase) 4715 4716 # If thew new base is the same, we don't do anything! 4717 if newBaseID == fromID: 4718 return transition 4719 4720 # First figure out reciprocal business so we can swap it later 4721 # without making changes if we need to 4722 destination = self.destination(fromID, transition) 4723 reciprocal = self.getReciprocal(fromID, transition) 4724 # Check for an already-deleted reciprocal 4725 if ( 4726 reciprocal is not None 4727 and self.getDestination(destination, reciprocal) is None 4728 ): 4729 reciprocal = None 4730 4731 # Handle the base swap... 4732 # Find the transition properties 4733 tProps = self.getTransitionProperties(fromID, transition) 4734 4735 # Check for a collision 4736 targetDestinations = self.destinationsFrom(newBaseID) 4737 if transition in targetDestinations: 4738 if errorOnNameColision: 4739 raise TransitionCollisionError( 4740 f"Cannot rebase transition {transition!r} from" 4741 f" {self.identityOf(fromDecision)}: it would be a" 4742 f" duplicate transition name at the new base" 4743 f" decision {self.identityOf(newBase)}." 4744 ) 4745 else: 4746 # Figure out a good fresh name 4747 newName = utils.uniqueName( 4748 transition, 4749 targetDestinations 4750 ) 4751 else: 4752 newName = transition 4753 4754 # Delete the edge 4755 self.removeEdgeByKey(fromID, transition) 4756 4757 # Add the new edge 4758 self.addTransition(newBaseID, newName, destination) 4759 4760 # Reapply the transition properties 4761 self.setTransitionProperties(newBaseID, newName, **tProps) 4762 4763 # Handle the reciprocal transition if there is one... 4764 if reciprocal is not None: 4765 if not swapReciprocal: 4766 # Then sever the relationship 4767 self.setReciprocal( 4768 destination, 4769 reciprocal, 4770 None, 4771 setBoth=False # Other transition was deleted already 4772 ) 4773 else: 4774 # Otherwise swap the reciprocal edge 4775 self.retargetTransition( 4776 destination, 4777 reciprocal, 4778 newBaseID, 4779 swapReciprocal=False 4780 ) 4781 4782 # And establish a new reciprocal relationship 4783 self.setReciprocal( 4784 newBaseID, 4785 newName, 4786 reciprocal 4787 ) 4788 4789 # Return the new name in case it was changed 4790 return newName 4791 4792 # TODO: zone merging! 4793 4794 # TODO: Double-check that exploration vars get updated when this is 4795 # called! 4796 def mergeDecisions( 4797 self, 4798 merge: base.AnyDecisionSpecifier, 4799 mergeInto: base.AnyDecisionSpecifier, 4800 errorOnNameColision=True 4801 ) -> Dict[base.Transition, base.Transition]: 4802 """ 4803 Merges two decisions, deleting the first after transferring all 4804 of its incoming and outgoing edges to target the second one, 4805 whose name is retained. The second decision will be added to any 4806 zones that the first decision was a member of. If either decision 4807 does not exist, a `MissingDecisionError` will be raised. If 4808 `merge` and `mergeInto` are the same, then nothing will be 4809 changed. 4810 4811 Unless `errorOnNameColision` is set to False, a 4812 `TransitionCollisionError` will be raised if the two decisions 4813 have outgoing transitions with the same name. If 4814 `errorOnNameColision` is set to False, then such edges will be 4815 renamed using a suffix to avoid name collisions, with edges 4816 connected to the second decision retaining their original names 4817 and edges that were connected to the first decision getting 4818 renamed. 4819 4820 Any mechanisms located at the first decision will be moved to the 4821 merged decision. 4822 4823 The tags and annotations of the merged decision are added to the 4824 tags and annotations of the merge target. If there are shared 4825 tags, the values from the merge target will override those of 4826 the merged decision. If this is undesired behavior, clear/edit 4827 the tags/annotations of the merged decision before the merge. 4828 4829 The 'unconfirmed' tag is treated specially: if both decisions have 4830 it it will be retained, but otherwise it will be dropped even if 4831 one of the situations had it before. 4832 4833 The domain of the second decision is retained. 4834 4835 Returns a dictionary mapping each original transition name to 4836 its new name in cases where transitions get renamed; this will 4837 be empty when no re-naming occurs, including when 4838 `errorOnNameColision` is True. If there were any transitions 4839 connecting the nodes that were merged, these become self-edges 4840 of the merged node (and may be renamed if necessary). 4841 Note that all renamed transitions were originally based on the 4842 first (merged) node, since transitions of the second (merge 4843 target) node are not renamed. 4844 4845 ## Example 4846 4847 >>> g = DecisionGraph() 4848 >>> for fr, to, nm in [ 4849 ... ('A', 'B', 'up'), 4850 ... ('A', 'B', 'up2'), 4851 ... ('B', 'A', 'down'), 4852 ... ('B', 'B', 'self'), 4853 ... ('B', 'C', 'next'), 4854 ... ('C', 'B', 'prev'), 4855 ... ('A', 'C', 'right') 4856 ... ]: 4857 ... if g.getDecision(fr) is None: 4858 ... g.addDecision(fr) 4859 ... if g.getDecision(to) is None: 4860 ... g.addDecision(to) 4861 ... g.addTransition(fr, nm, to) 4862 0 4863 1 4864 2 4865 >>> g.getDestination('A', 'up') 4866 1 4867 >>> g.getDestination('B', 'down') 4868 0 4869 >>> sorted(g) 4870 [0, 1, 2] 4871 >>> g.setReciprocal('A', 'up', 'down') 4872 >>> g.setReciprocal('B', 'next', 'prev') 4873 >>> g.mergeDecisions('C', 'B') 4874 {} 4875 >>> g.destinationsFrom('A') 4876 {'up': 1, 'up2': 1, 'right': 1} 4877 >>> g.destinationsFrom('B') 4878 {'down': 0, 'self': 1, 'prev': 1, 'next': 1} 4879 >>> 'C' in g 4880 False 4881 >>> g.mergeDecisions('A', 'A') # does nothing 4882 {} 4883 >>> # Can't merge non-existent decision 4884 >>> g.mergeDecisions('A', 'Z') 4885 Traceback (most recent call last): 4886 ... 4887 exploration.core.MissingDecisionError... 4888 >>> g.mergeDecisions('Z', 'A') 4889 Traceback (most recent call last): 4890 ... 4891 exploration.core.MissingDecisionError... 4892 >>> # Can't merge decisions w/ shared edge names 4893 >>> g.addDecision('D') 4894 3 4895 >>> g.addTransition('D', 'next', 'A') 4896 >>> g.addTransition('A', 'prev', 'D') 4897 >>> g.setReciprocal('D', 'next', 'prev') 4898 >>> g.mergeDecisions('D', 'B') # both have a 'next' transition 4899 Traceback (most recent call last): 4900 ... 4901 exploration.core.TransitionCollisionError... 4902 >>> # Auto-rename colliding edges 4903 >>> g.mergeDecisions('D', 'B', errorOnNameColision=False) 4904 {'next': 'next.1'} 4905 >>> g.destination('B', 'next') # merge target unchanged 4906 1 4907 >>> g.destination('B', 'next.1') # merged decision name changed 4908 0 4909 >>> g.destination('B', 'prev') # name unchanged (no collision) 4910 1 4911 >>> g.getReciprocal('B', 'next') # unchanged (from B) 4912 'prev' 4913 >>> g.getReciprocal('B', 'next.1') # from A 4914 'prev' 4915 >>> g.getReciprocal('A', 'prev') # from B 4916 'next.1' 4917 4918 ## Folding four nodes into a 2-node loop 4919 4920 >>> g = DecisionGraph() 4921 >>> g.addDecision('X') 4922 0 4923 >>> g.addDecision('Y') 4924 1 4925 >>> g.addTransition('X', 'next', 'Y', 'prev') 4926 >>> g.addDecision('preX') 4927 2 4928 >>> g.addDecision('postY') 4929 3 4930 >>> g.addTransition('preX', 'next', 'X', 'prev') 4931 >>> g.addTransition('Y', 'next', 'postY', 'prev') 4932 >>> g.mergeDecisions('preX', 'Y', errorOnNameColision=False) 4933 {'next': 'next.1'} 4934 >>> g.destinationsFrom('X') 4935 {'next': 1, 'prev': 1} 4936 >>> g.destinationsFrom('Y') 4937 {'prev': 0, 'next': 3, 'next.1': 0} 4938 >>> 2 in g 4939 False 4940 >>> g.destinationsFrom('postY') 4941 {'prev': 1} 4942 >>> g.mergeDecisions('postY', 'X', errorOnNameColision=False) 4943 {'prev': 'prev.1'} 4944 >>> g.destinationsFrom('X') 4945 {'next': 1, 'prev': 1, 'prev.1': 1} 4946 >>> g.destinationsFrom('Y') # order 'cause of 'next' re-target 4947 {'prev': 0, 'next.1': 0, 'next': 0} 4948 >>> 2 in g 4949 False 4950 >>> 3 in g 4951 False 4952 >>> # Reciprocals are tangled... 4953 >>> g.getReciprocal(0, 'prev') 4954 'next.1' 4955 >>> g.getReciprocal(0, 'prev.1') 4956 'next' 4957 >>> g.getReciprocal(1, 'next') 4958 'prev.1' 4959 >>> g.getReciprocal(1, 'next.1') 4960 'prev' 4961 >>> # Note: one merge cannot handle both extra transitions 4962 >>> # because their reciprocals are crossed (e.g., prev.1 <-> next) 4963 >>> # (It would merge both edges but the result would retain 4964 >>> # 'next.1' instead of retaining 'next'.) 4965 >>> g.mergeTransitions('X', 'prev.1', 'prev', mergeReciprocal=False) 4966 >>> g.mergeTransitions('Y', 'next.1', 'next', mergeReciprocal=True) 4967 >>> g.destinationsFrom('X') 4968 {'next': 1, 'prev': 1} 4969 >>> g.destinationsFrom('Y') 4970 {'prev': 0, 'next': 0} 4971 >>> # Reciprocals were salvaged in second merger 4972 >>> g.getReciprocal('X', 'prev') 4973 'next' 4974 >>> g.getReciprocal('Y', 'next') 4975 'prev' 4976 4977 ## Merging with tags/requirements/annotations/consequences 4978 4979 >>> g = DecisionGraph() 4980 >>> g.addDecision('X') 4981 0 4982 >>> g.addDecision('Y') 4983 1 4984 >>> g.addDecision('Z') 4985 2 4986 >>> g.addTransition('X', 'next', 'Y', 'prev') 4987 >>> g.addTransition('X', 'down', 'Z', 'up') 4988 >>> g.tagDecision('X', 'tag0', 1) 4989 >>> g.tagDecision('Y', 'tag1', 10) 4990 >>> g.tagDecision('Y', 'unconfirmed') 4991 >>> g.tagDecision('Z', 'tag1', 20) 4992 >>> g.tagDecision('Z', 'tag2', 30) 4993 >>> g.tagTransition('X', 'next', 'ttag1', 11) 4994 >>> g.tagTransition('Y', 'prev', 'ttag2', 22) 4995 >>> g.tagTransition('X', 'down', 'ttag3', 33) 4996 >>> g.tagTransition('Z', 'up', 'ttag4', 44) 4997 >>> g.annotateDecision('Y', 'annotation 1') 4998 >>> g.annotateDecision('Z', 'annotation 2') 4999 >>> g.annotateDecision('Z', 'annotation 3') 5000 >>> g.annotateTransition('Y', 'prev', 'trans annotation 1') 5001 >>> g.annotateTransition('Y', 'prev', 'trans annotation 2') 5002 >>> g.annotateTransition('Z', 'up', 'trans annotation 3') 5003 >>> g.setTransitionRequirement( 5004 ... 'X', 5005 ... 'next', 5006 ... base.ReqCapability('power') 5007 ... ) 5008 >>> g.setTransitionRequirement( 5009 ... 'Y', 5010 ... 'prev', 5011 ... base.ReqTokens('token', 1) 5012 ... ) 5013 >>> g.setTransitionRequirement( 5014 ... 'X', 5015 ... 'down', 5016 ... base.ReqCapability('power2') 5017 ... ) 5018 >>> g.setTransitionRequirement( 5019 ... 'Z', 5020 ... 'up', 5021 ... base.ReqTokens('token2', 2) 5022 ... ) 5023 >>> g.setConsequence( 5024 ... 'Y', 5025 ... 'prev', 5026 ... [base.effect(gain="power2")] 5027 ... ) 5028 >>> g.mergeDecisions('Y', 'Z') 5029 {} 5030 >>> g.destination('X', 'next') 5031 2 5032 >>> g.destination('X', 'down') 5033 2 5034 >>> g.destination('Z', 'prev') 5035 0 5036 >>> g.destination('Z', 'up') 5037 0 5038 >>> g.decisionTags('X') 5039 {'tag0': 1} 5040 >>> g.decisionTags('Z') # note that 'unconfirmed' is removed 5041 {'tag1': 20, 'tag2': 30} 5042 >>> g.transitionTags('X', 'next') 5043 {'ttag1': 11} 5044 >>> g.transitionTags('X', 'down') 5045 {'ttag3': 33} 5046 >>> g.transitionTags('Z', 'prev') 5047 {'ttag2': 22} 5048 >>> g.transitionTags('Z', 'up') 5049 {'ttag4': 44} 5050 >>> g.decisionAnnotations('Z') 5051 ['annotation 2', 'annotation 3', 'annotation 1'] 5052 >>> g.transitionAnnotations('Z', 'prev') 5053 ['trans annotation 1', 'trans annotation 2'] 5054 >>> g.transitionAnnotations('Z', 'up') 5055 ['trans annotation 3'] 5056 >>> g.getTransitionRequirement('X', 'next') 5057 ReqCapability('power') 5058 >>> g.getTransitionRequirement('Z', 'prev') 5059 ReqTokens('token', 1) 5060 >>> g.getTransitionRequirement('X', 'down') 5061 ReqCapability('power2') 5062 >>> g.getTransitionRequirement('Z', 'up') 5063 ReqTokens('token2', 2) 5064 >>> g.getConsequence('Z', 'prev') == [ 5065 ... { 5066 ... 'type': 'gain', 5067 ... 'applyTo': 'active', 5068 ... 'value': 'power2', 5069 ... 'charges': None, 5070 ... 'delay': None, 5071 ... 'hidden': False 5072 ... } 5073 ... ] 5074 True 5075 5076 ## Merging into node without tags 5077 5078 >>> g = DecisionGraph() 5079 >>> g.addDecision('X') 5080 0 5081 >>> g.addDecision('Y') 5082 1 5083 >>> g.tagDecision('Y', 'unconfirmed') # special handling 5084 >>> g.tagDecision('Y', 'tag', 'value') 5085 >>> g.mergeDecisions('Y', 'X') 5086 {} 5087 >>> g.decisionTags('X') 5088 {'tag': 'value'} 5089 >>> 0 in g # Second argument remains 5090 True 5091 >>> 1 in g # First argument is deleted 5092 False 5093 """ 5094 # Resolve IDs 5095 mergeID = self.resolveDecision(merge) 5096 mergeIntoID = self.resolveDecision(mergeInto) 5097 5098 # Create our result as an empty dictionary 5099 result: Dict[base.Transition, base.Transition] = {} 5100 5101 # Short-circuit if the two decisions are the same 5102 if mergeID == mergeIntoID: 5103 return result 5104 5105 # MissingDecisionErrors from here if either doesn't exist 5106 allNewOutgoing = set(self.destinationsFrom(mergeID)) 5107 allOldOutgoing = set(self.destinationsFrom(mergeIntoID)) 5108 # Find colliding transition names 5109 collisions = allNewOutgoing & allOldOutgoing 5110 if len(collisions) > 0 and errorOnNameColision: 5111 raise TransitionCollisionError( 5112 f"Cannot merge decision {self.identityOf(merge)} into" 5113 f" decision {self.identityOf(mergeInto)}: the decisions" 5114 f" share {len(collisions)} transition names:" 5115 f" {collisions}\n(Note that errorOnNameColision was set" 5116 f" to True, set it to False to allow the operation by" 5117 f" renaming half of those transitions.)" 5118 ) 5119 5120 # Record zones that will have to change after the merge 5121 zoneParents = self.zoneParents(mergeID) 5122 5123 # First, swap all incoming edges, along with their reciprocals 5124 # This will include self-edges, which will be retargeted and 5125 # whose reciprocals will be rebased in the process, leading to 5126 # the possibility of a missing edge during the loop 5127 for source, incoming in self.allEdgesTo(mergeID): 5128 # Skip this edge if it was already swapped away because it's 5129 # a self-loop with a reciprocal whose reciprocal was 5130 # processed earlier in the loop 5131 if incoming not in self.destinationsFrom(source): 5132 continue 5133 5134 # Find corresponding outgoing edge 5135 outgoing = self.getReciprocal(source, incoming) 5136 5137 # Swap both edges to new destination 5138 newOutgoing = self.retargetTransition( 5139 source, 5140 incoming, 5141 mergeIntoID, 5142 swapReciprocal=True, 5143 errorOnNameColision=False # collisions were detected above 5144 ) 5145 # Add to our result if the name of the reciprocal was 5146 # changed 5147 if ( 5148 outgoing is not None 5149 and newOutgoing is not None 5150 and outgoing != newOutgoing 5151 ): 5152 result[outgoing] = newOutgoing 5153 5154 # Next, swap any remaining outgoing edges (which didn't have 5155 # reciprocals, or they'd already be swapped, unless they were 5156 # self-edges previously). Note that in this loop, there can't be 5157 # any self-edges remaining, although there might be connections 5158 # between the merging nodes that need to become self-edges 5159 # because they used to be a self-edge that was half-retargeted 5160 # by the previous loop. 5161 # Note: a copy is used here to avoid iterating over a changing 5162 # dictionary 5163 for stillOutgoing in copy.copy(self.destinationsFrom(mergeID)): 5164 newOutgoing = self.rebaseTransition( 5165 mergeID, 5166 stillOutgoing, 5167 mergeIntoID, 5168 swapReciprocal=True, 5169 errorOnNameColision=False # collisions were detected above 5170 ) 5171 if stillOutgoing != newOutgoing: 5172 result[stillOutgoing] = newOutgoing 5173 5174 # At this point, there shouldn't be any remaining incoming or 5175 # outgoing edges! 5176 assert self.degree(mergeID) == 0 5177 5178 # Merge tags & annotations 5179 # Note that these operations affect the underlying graph 5180 destTags = self.decisionTags(mergeIntoID) 5181 destUnvisited = 'unconfirmed' in destTags 5182 sourceTags = self.decisionTags(mergeID) 5183 sourceUnvisited = 'unconfirmed' in sourceTags 5184 # Copy over only new tags, leaving existing tags alone 5185 for key in sourceTags: 5186 if key not in destTags: 5187 destTags[key] = sourceTags[key] 5188 5189 if int(destUnvisited) + int(sourceUnvisited) == 1: 5190 del destTags['unconfirmed'] 5191 5192 self.decisionAnnotations(mergeIntoID).extend( 5193 self.decisionAnnotations(mergeID) 5194 ) 5195 5196 # Transfer zones 5197 for zone in zoneParents: 5198 self.addDecisionToZone(mergeIntoID, zone) 5199 5200 # Delete the old node 5201 self.removeDecision(mergeID) 5202 5203 return result 5204 5205 def removeDecision(self, decision: base.AnyDecisionSpecifier) -> None: 5206 """ 5207 Deletes the specified decision from the graph, updating 5208 attendant structures like zones. Note that the ID of the deleted 5209 node will NOT be reused, unless it's specifically provided to 5210 `addIdentifiedDecision`. 5211 5212 For example: 5213 5214 >>> dg = DecisionGraph() 5215 >>> dg.addDecision('A') 5216 0 5217 >>> dg.addDecision('B') 5218 1 5219 >>> list(dg) 5220 [0, 1] 5221 >>> 1 in dg 5222 True 5223 >>> 'B' in dg.nameLookup 5224 True 5225 >>> dg.removeDecision('B') 5226 >>> 1 in dg 5227 False 5228 >>> list(dg) 5229 [0] 5230 >>> 'B' in dg.nameLookup 5231 False 5232 >>> dg.addDecision('C') # doesn't re-use ID 5233 2 5234 """ 5235 dID = self.resolveDecision(decision) 5236 5237 # Remove the target from all zones: 5238 for zone in self.zones: 5239 self.removeDecisionFromZone(dID, zone) 5240 5241 # Remove the node but record the current name 5242 name = self.nodes[dID]['name'] 5243 self.remove_node(dID) 5244 5245 # Clean up the nameLookup entry 5246 luInfo = self.nameLookup[name] 5247 luInfo.remove(dID) 5248 if len(luInfo) == 0: 5249 self.nameLookup.pop(name) 5250 5251 # TODO: Clean up edges? 5252 5253 def renameDecision( 5254 self, 5255 decision: base.AnyDecisionSpecifier, 5256 newName: base.DecisionName 5257 ): 5258 """ 5259 Renames a decision. The decision retains its old ID. 5260 5261 Generates a `DecisionCollisionWarning` if a decision using the new 5262 name already exists and `WARN_OF_NAME_COLLISIONS` is enabled. 5263 5264 Example: 5265 5266 >>> g = DecisionGraph() 5267 >>> g.addDecision('one') 5268 0 5269 >>> g.addDecision('three') 5270 1 5271 >>> g.addTransition('one', '>', 'three') 5272 >>> g.addTransition('three', '<', 'one') 5273 >>> g.tagDecision('three', 'hi') 5274 >>> g.annotateDecision('three', 'note') 5275 >>> g.destination('one', '>') 5276 1 5277 >>> g.destination('three', '<') 5278 0 5279 >>> g.renameDecision('three', 'two') 5280 >>> g.resolveDecision('one') 5281 0 5282 >>> g.resolveDecision('two') 5283 1 5284 >>> g.resolveDecision('three') 5285 Traceback (most recent call last): 5286 ... 5287 exploration.core.MissingDecisionError... 5288 >>> g.destination('one', '>') 5289 1 5290 >>> g.nameFor(1) 5291 'two' 5292 >>> g.getDecision('three') is None 5293 True 5294 >>> g.destination('two', '<') 5295 0 5296 >>> g.decisionTags('two') 5297 {'hi': 1} 5298 >>> g.decisionAnnotations('two') 5299 ['note'] 5300 """ 5301 dID = self.resolveDecision(decision) 5302 5303 if newName in self.nameLookup and WARN_OF_NAME_COLLISIONS: 5304 warnings.warn( 5305 ( 5306 f"Can't rename {self.identityOf(decision)} as" 5307 f" {newName!r} because a decision with that name" 5308 f" already exists." 5309 ), 5310 DecisionCollisionWarning 5311 ) 5312 5313 # Update name in node 5314 oldName = self.nodes[dID]['name'] 5315 self.nodes[dID]['name'] = newName 5316 5317 # Update nameLookup entries 5318 oldNL = self.nameLookup[oldName] 5319 oldNL.remove(dID) 5320 if len(oldNL) == 0: 5321 self.nameLookup.pop(oldName) 5322 self.nameLookup.setdefault(newName, []).append(dID) 5323 5324 def mergeTransitions( 5325 self, 5326 fromDecision: base.AnyDecisionSpecifier, 5327 merge: base.Transition, 5328 mergeInto: base.Transition, 5329 mergeReciprocal=True 5330 ) -> None: 5331 """ 5332 Given a decision and two transitions that start at that decision, 5333 merges the first transition into the second transition, combining 5334 their transition properties (using `mergeProperties`) and 5335 deleting the first transition. By default any reciprocal of the 5336 first transition is also merged into the reciprocal of the 5337 second, although you can set `mergeReciprocal` to `False` to 5338 disable this in which case the old reciprocal will lose its 5339 reciprocal relationship, even if the transition that was merged 5340 into does not have a reciprocal. 5341 5342 If the two names provided are the same, nothing will happen. 5343 5344 If the two transitions do not share the same destination, they 5345 cannot be merged, and an `InvalidDestinationError` will result. 5346 Use `retargetTransition` beforehand to ensure that they do if you 5347 want to merge transitions with different destinations. 5348 5349 A `MissingDecisionError` or `MissingTransitionError` will result 5350 if the decision or either transition does not exist. 5351 5352 If merging reciprocal properties was requested and the first 5353 transition does not have a reciprocal, then no reciprocal 5354 properties change. However, if the second transition does not 5355 have a reciprocal and the first does, the first transition's 5356 reciprocal will be set to the reciprocal of the second 5357 transition, and that transition will not be deleted as usual. 5358 5359 ## Example 5360 5361 >>> g = DecisionGraph() 5362 >>> g.addDecision('A') 5363 0 5364 >>> g.addDecision('B') 5365 1 5366 >>> g.addTransition('A', 'up', 'B') 5367 >>> g.addTransition('B', 'down', 'A') 5368 >>> g.setReciprocal('A', 'up', 'down') 5369 >>> # Merging a transition with no reciprocal 5370 >>> g.addTransition('A', 'up2', 'B') 5371 >>> g.mergeTransitions('A', 'up2', 'up') 5372 >>> g.getDestination('A', 'up2') is None 5373 True 5374 >>> g.getDestination('A', 'up') 5375 1 5376 >>> # Merging a transition with a reciprocal & tags 5377 >>> g.addTransition('A', 'up2', 'B') 5378 >>> g.addTransition('B', 'down2', 'A') 5379 >>> g.setReciprocal('A', 'up2', 'down2') 5380 >>> g.tagTransition('A', 'up2', 'one') 5381 >>> g.tagTransition('B', 'down2', 'two') 5382 >>> g.mergeTransitions('B', 'down2', 'down') 5383 >>> g.getDestination('A', 'up2') is None 5384 True 5385 >>> g.getDestination('A', 'up') 5386 1 5387 >>> g.getDestination('B', 'down2') is None 5388 True 5389 >>> g.getDestination('B', 'down') 5390 0 5391 >>> # Merging requirements uses ReqAll (i.e., 'and' logic) 5392 >>> g.addTransition('A', 'up2', 'B') 5393 >>> g.setTransitionProperties( 5394 ... 'A', 5395 ... 'up2', 5396 ... requirement=base.ReqCapability('dash') 5397 ... ) 5398 >>> g.setTransitionProperties('A', 'up', 5399 ... requirement=base.ReqCapability('slide')) 5400 >>> g.mergeTransitions('A', 'up2', 'up') 5401 >>> g.getDestination('A', 'up2') is None 5402 True 5403 >>> repr(g.getTransitionRequirement('A', 'up')) 5404 "ReqAll([ReqCapability('dash'), ReqCapability('slide')])" 5405 >>> # Errors if destinations differ, or if something is missing 5406 >>> g.mergeTransitions('A', 'down', 'up') 5407 Traceback (most recent call last): 5408 ... 5409 exploration.core.MissingTransitionError... 5410 >>> g.mergeTransitions('Z', 'one', 'two') 5411 Traceback (most recent call last): 5412 ... 5413 exploration.core.MissingDecisionError... 5414 >>> g.addDecision('C') 5415 2 5416 >>> g.addTransition('A', 'down', 'C') 5417 >>> g.mergeTransitions('A', 'down', 'up') 5418 Traceback (most recent call last): 5419 ... 5420 exploration.core.InvalidDestinationError... 5421 >>> # Merging a reciprocal onto an edge that doesn't have one 5422 >>> g.addTransition('A', 'down2', 'C') 5423 >>> g.addTransition('C', 'up2', 'A') 5424 >>> g.setReciprocal('A', 'down2', 'up2') 5425 >>> g.tagTransition('C', 'up2', 'narrow') 5426 >>> g.getReciprocal('A', 'down') is None 5427 True 5428 >>> g.mergeTransitions('A', 'down2', 'down') 5429 >>> g.getDestination('A', 'down2') is None 5430 True 5431 >>> g.getDestination('A', 'down') 5432 2 5433 >>> g.getDestination('C', 'up2') 5434 0 5435 >>> g.getReciprocal('A', 'down') 5436 'up2' 5437 >>> g.getReciprocal('C', 'up2') 5438 'down' 5439 >>> g.transitionTags('C', 'up2') 5440 {'narrow': 1} 5441 >>> # Merging without a reciprocal 5442 >>> g.addTransition('C', 'up', 'A') 5443 >>> g.mergeTransitions('C', 'up2', 'up', mergeReciprocal=False) 5444 >>> g.getDestination('C', 'up2') is None 5445 True 5446 >>> g.getDestination('C', 'up') 5447 0 5448 >>> g.transitionTags('C', 'up') # tag gets merged 5449 {'narrow': 1} 5450 >>> g.getDestination('A', 'down') 5451 2 5452 >>> g.getReciprocal('A', 'down') is None 5453 True 5454 >>> g.getReciprocal('C', 'up') is None 5455 True 5456 >>> # Merging w/ normal reciprocals 5457 >>> g.addDecision('D') 5458 3 5459 >>> g.addDecision('E') 5460 4 5461 >>> g.addTransition('D', 'up', 'E', 'return') 5462 >>> g.addTransition('E', 'down', 'D') 5463 >>> g.mergeTransitions('E', 'return', 'down') 5464 >>> g.getDestination('D', 'up') 5465 4 5466 >>> g.getDestination('E', 'down') 5467 3 5468 >>> g.getDestination('E', 'return') is None 5469 True 5470 >>> g.getReciprocal('D', 'up') 5471 'down' 5472 >>> g.getReciprocal('E', 'down') 5473 'up' 5474 >>> # Merging w/ weird reciprocals 5475 >>> g.addTransition('E', 'return', 'D') 5476 >>> g.setReciprocal('E', 'return', 'up', setBoth=False) 5477 >>> g.getReciprocal('D', 'up') 5478 'down' 5479 >>> g.getReciprocal('E', 'down') 5480 'up' 5481 >>> g.getReciprocal('E', 'return') # shared 5482 'up' 5483 >>> g.mergeTransitions('E', 'return', 'down') 5484 >>> g.getDestination('D', 'up') 5485 4 5486 >>> g.getDestination('E', 'down') 5487 3 5488 >>> g.getDestination('E', 'return') is None 5489 True 5490 >>> g.getReciprocal('D', 'up') 5491 'down' 5492 >>> g.getReciprocal('E', 'down') 5493 'up' 5494 """ 5495 fromID = self.resolveDecision(fromDecision) 5496 5497 # Short-circuit in the no-op case 5498 if merge == mergeInto: 5499 return 5500 5501 # These lines will raise a MissingDecisionError or 5502 # MissingTransitionError if needed 5503 dest1 = self.destination(fromID, merge) 5504 dest2 = self.destination(fromID, mergeInto) 5505 5506 if dest1 != dest2: 5507 raise InvalidDestinationError( 5508 f"Cannot merge transition {merge!r} into transition" 5509 f" {mergeInto!r} from decision" 5510 f" {self.identityOf(fromDecision)} because their" 5511 f" destinations are different ({self.identityOf(dest1)}" 5512 f" and {self.identityOf(dest2)}).\nNote: you can use" 5513 f" `retargetTransition` to change the destination of a" 5514 f" transition." 5515 ) 5516 5517 # Find and the transition properties 5518 props1 = self.getTransitionProperties(fromID, merge) 5519 props2 = self.getTransitionProperties(fromID, mergeInto) 5520 merged = mergeProperties(props1, props2) 5521 # Note that this doesn't change the reciprocal: 5522 self.setTransitionProperties(fromID, mergeInto, **merged) 5523 5524 # Merge the reciprocal properties if requested 5525 # Get reciprocal to merge into 5526 reciprocal = self.getReciprocal(fromID, mergeInto) 5527 # Get reciprocal that needs cleaning up 5528 altReciprocal = self.getReciprocal(fromID, merge) 5529 # If the reciprocal to be merged actually already was the 5530 # reciprocal to merge into, there's nothing to do here 5531 if altReciprocal != reciprocal: 5532 if not mergeReciprocal: 5533 # In this case, we sever the reciprocal relationship if 5534 # there is a reciprocal 5535 if altReciprocal is not None: 5536 self.setReciprocal(dest1, altReciprocal, None) 5537 # By default setBoth takes care of the other half 5538 else: 5539 # In this case, we try to merge reciprocals 5540 # If altReciprocal is None, we don't need to do anything 5541 if altReciprocal is not None: 5542 # Was there already a reciprocal or not? 5543 if reciprocal is None: 5544 # altReciprocal becomes the new reciprocal and is 5545 # not deleted 5546 self.setReciprocal( 5547 fromID, 5548 mergeInto, 5549 altReciprocal 5550 ) 5551 else: 5552 # merge reciprocal properties 5553 props1 = self.getTransitionProperties( 5554 dest1, 5555 altReciprocal 5556 ) 5557 props2 = self.getTransitionProperties( 5558 dest2, 5559 reciprocal 5560 ) 5561 merged = mergeProperties(props1, props2) 5562 self.setTransitionProperties( 5563 dest1, 5564 reciprocal, 5565 **merged 5566 ) 5567 5568 # delete the old reciprocal transition 5569 self.remove_edge(dest1, fromID, altReciprocal) 5570 5571 # Delete the old transition (reciprocal deletion/severance is 5572 # handled above if necessary) 5573 self.remove_edge(fromID, dest1, merge) 5574 5575 def isConfirmed(self, decision: base.AnyDecisionSpecifier) -> bool: 5576 """ 5577 Returns `True` or `False` depending on whether or not the 5578 specified decision has been confirmed. Uses the presence or 5579 absence of the 'unconfirmed' tag to determine this. 5580 5581 Note: 'unconfirmed' is used instead of 'confirmed' so that large 5582 graphs with many confirmed nodes will be smaller when saved. 5583 """ 5584 dID = self.resolveDecision(decision) 5585 5586 return 'unconfirmed' not in self.nodes[dID]['tags'] 5587 5588 def replaceUnconfirmed( 5589 self, 5590 fromDecision: base.AnyDecisionSpecifier, 5591 transition: base.Transition, 5592 connectTo: Optional[base.AnyDecisionSpecifier] = None, 5593 reciprocal: Optional[base.Transition] = None, 5594 requirement: Optional[base.Requirement] = None, 5595 applyConsequence: Optional[base.Consequence] = None, 5596 placeInZone: Union[type[base.DefaultZone], base.Zone, None] = None, 5597 forceNew: bool = False, 5598 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 5599 annotations: Optional[List[base.Annotation]] = None, 5600 revRequires: Optional[base.Requirement] = None, 5601 revConsequence: Optional[base.Consequence] = None, 5602 revTags: Optional[Dict[base.Tag, base.TagValue]] = None, 5603 revAnnotations: Optional[List[base.Annotation]] = None, 5604 decisionTags: Optional[Dict[base.Tag, base.TagValue]] = None, 5605 decisionAnnotations: Optional[List[base.Annotation]] = None 5606 ) -> Tuple[ 5607 Dict[base.Transition, base.Transition], 5608 Dict[base.Transition, base.Transition] 5609 ]: 5610 """ 5611 Given a decision and an edge name in that decision, where the 5612 named edge leads to a decision with an unconfirmed exploration 5613 state (see `isConfirmed`), renames the unexplored decision on 5614 the other end of that edge using the given `connectTo` name, or 5615 if a decision using that name already exists, merges the 5616 unexplored decision into that decision. If `connectTo` is a 5617 `DecisionSpecifier` whose target doesn't exist, it will be 5618 treated as just a name, but if it's an ID and it doesn't exist, 5619 you'll get a `MissingDecisionError`. If a `reciprocal` is provided, 5620 a reciprocal edge will be added using that name connecting the 5621 `connectTo` decision back to the original decision. If this 5622 transition already exists, it must also point to a node which is 5623 also unexplored, and which will also be merged into the 5624 `fromDecision` node. 5625 5626 If `connectTo` is not given (or is set to `None` explicitly) 5627 then the name of the unexplored decision will not be changed, 5628 unless that name has the form `'_u.-n-'` where `-n-` is a positive 5629 integer (i.e., the form given to automatically-named unknown 5630 nodes). In that case, the name will be changed to `'_x.-n-'` using 5631 the same number, or a higher number if that name is already taken. 5632 5633 If the destination is being renamed or if the destination's 5634 exploration state counts as unexplored, the exploration state of 5635 the destination will be set to 'exploring'. 5636 5637 If a `placeInZone` is specified, the destination will be placed 5638 directly into that zone (even if it already existed and has zone 5639 information), and it will be removed from any other zones it had 5640 been a direct member of. If `placeInZone` is set to 5641 `DefaultZone`, then the destination will be placed into each zone 5642 which is a direct parent of the origin, but only if the 5643 destination is not an already-explored existing decision AND 5644 it is not already in any zones (in those cases no zone changes 5645 are made). This will also remove it from any previous zones it 5646 had been a part of. If `placeInZone` is left as `None` (the 5647 default) no zone changes are made. 5648 5649 If `placeInZone` is specified and that zone didn't already exist, 5650 it will be created as a new level-0 zone and will be added as a 5651 sub-zone of each zone that's a direct parent of any level-0 zone 5652 that the origin is a member of. 5653 5654 If `forceNew` is specified, then the destination will just be 5655 renamed, even if another decision with the same name already 5656 exists. It's an error to use `forceNew` with a decision ID as 5657 the destination. 5658 5659 Any additional edges pointing to or from the unknown node(s) 5660 being replaced will also be re-targeted at the now-discovered 5661 known destination(s) if necessary. These edges will retain their 5662 reciprocal names, or if this would cause a name clash, they will 5663 be renamed with a suffix (see `retargetTransition`). 5664 5665 The return value is a pair of dictionaries mapping old names to 5666 new ones that just includes the names which were changed. The 5667 first dictionary contains renamed transitions that are outgoing 5668 from the new destination node (which used to be outgoing from 5669 the unexplored node). The second dictionary contains renamed 5670 transitions that are outgoing from the source node (which used 5671 to be outgoing from the unexplored node attached to the 5672 reciprocal transition; if there was no reciprocal transition 5673 specified then this will always be an empty dictionary). 5674 5675 An `ExplorationStatusError` will be raised if the destination 5676 of the specified transition counts as visited (see 5677 `hasBeenVisited`). An `ExplorationStatusError` will also be 5678 raised if the `connectTo`'s `reciprocal` transition does not lead 5679 to an unconfirmed decision (it's okay if this second transition 5680 doesn't exist). A `TransitionCollisionError` will be raised if 5681 the unconfirmed destination decision already has an outgoing 5682 transition with the specified `reciprocal` which does not lead 5683 back to the `fromDecision`. 5684 5685 The transition properties (requirement, consequences, tags, 5686 and/or annotations) of the replaced transition will be copied 5687 over to the new transition. Transition properties from the 5688 reciprocal transition will also be copied for the newly created 5689 reciprocal edge. Properties for any additional edges to/from the 5690 unknown node will also be copied. 5691 5692 Also, any transition properties on existing forward or reciprocal 5693 edges from the destination node with the indicated reverse name 5694 will be merged with those from the target transition. Note that 5695 this merging process may introduce corruption of complex 5696 transition consequences. TODO: Fix that! 5697 5698 Any tags and annotations are added to copied tags/annotations, 5699 but specified requirements, and/or consequences will replace 5700 previous requirements/consequences, rather than being added to 5701 them. 5702 5703 ## Example 5704 5705 >>> g = DecisionGraph() 5706 >>> g.addDecision('A') 5707 0 5708 >>> g.addUnexploredEdge('A', 'up') 5709 1 5710 >>> g.destination('A', 'up') 5711 1 5712 >>> g.destination('_u.0', 'return') 5713 0 5714 >>> g.replaceUnconfirmed('A', 'up', 'B', 'down') 5715 ({}, {}) 5716 >>> g.destination('A', 'up') 5717 1 5718 >>> g.nameFor(1) 5719 'B' 5720 >>> g.destination('B', 'down') 5721 0 5722 >>> g.getDestination('B', 'return') is None 5723 True 5724 >>> '_u.0' in g.nameLookup 5725 False 5726 >>> g.getReciprocal('A', 'up') 5727 'down' 5728 >>> g.getReciprocal('B', 'down') 5729 'up' 5730 >>> # Two unexplored edges to the same node: 5731 >>> g.addDecision('C') 5732 2 5733 >>> g.addTransition('B', 'next', 'C') 5734 >>> g.addTransition('C', 'prev', 'B') 5735 >>> g.setReciprocal('B', 'next', 'prev') 5736 >>> g.addUnexploredEdge('A', 'next', 'D', 'prev') 5737 3 5738 >>> g.addTransition('C', 'down', 'D') 5739 >>> g.addTransition('D', 'up', 'C') 5740 >>> g.setReciprocal('C', 'down', 'up') 5741 >>> g.replaceUnconfirmed('C', 'down') 5742 ({}, {}) 5743 >>> g.destination('C', 'down') 5744 3 5745 >>> g.destination('A', 'next') 5746 3 5747 >>> g.destinationsFrom('D') 5748 {'prev': 0, 'up': 2} 5749 >>> g.decisionTags('D') 5750 {} 5751 >>> # An unexplored transition which turns out to connect to a 5752 >>> # known decision, with name collisions 5753 >>> g.addUnexploredEdge('D', 'next', reciprocal='prev') 5754 4 5755 >>> g.tagDecision('_u.2', 'wet') 5756 >>> g.addUnexploredEdge('B', 'next', reciprocal='prev') # edge taken 5757 Traceback (most recent call last): 5758 ... 5759 exploration.core.TransitionCollisionError... 5760 >>> g.addUnexploredEdge('A', 'prev', reciprocal='next') 5761 5 5762 >>> g.tagDecision('_u.3', 'dry') 5763 >>> # Add transitions that will collide when merged 5764 >>> g.addUnexploredEdge('_u.2', 'up') # collides with A/up 5765 6 5766 >>> g.addUnexploredEdge('_u.3', 'prev') # collides with D/prev 5767 7 5768 >>> g.getReciprocal('A', 'prev') 5769 'next' 5770 >>> g.replaceUnconfirmed('A', 'prev', 'D', 'next') # two gone 5771 ({'prev': 'prev.1'}, {'up': 'up.1'}) 5772 >>> g.destination('A', 'prev') 5773 3 5774 >>> g.destination('D', 'next') 5775 0 5776 >>> g.getReciprocal('A', 'prev') 5777 'next' 5778 >>> g.getReciprocal('D', 'next') 5779 'prev' 5780 >>> # Note that further unexplored structures are NOT merged 5781 >>> # even if they match against existing structures... 5782 >>> g.destination('A', 'up.1') 5783 6 5784 >>> g.destination('D', 'prev.1') 5785 7 5786 >>> '_u.2' in g.nameLookup 5787 False 5788 >>> '_u.3' in g.nameLookup 5789 False 5790 >>> g.decisionTags('D') # tags are merged 5791 {'dry': 1} 5792 >>> g.decisionTags('A') 5793 {'wet': 1} 5794 >>> # Auto-renaming an anonymous unexplored node 5795 >>> g.addUnexploredEdge('B', 'out') 5796 8 5797 >>> g.replaceUnconfirmed('B', 'out') 5798 ({}, {}) 5799 >>> '_u.6' in g 5800 False 5801 >>> g.destination('B', 'out') 5802 8 5803 >>> g.nameFor(8) 5804 '_x.6' 5805 >>> g.destination('_x.6', 'return') 5806 1 5807 >>> # Placing a node into a zone 5808 >>> g.addUnexploredEdge('B', 'through') 5809 9 5810 >>> g.getDecision('E') is None 5811 True 5812 >>> g.replaceUnconfirmed( 5813 ... 'B', 5814 ... 'through', 5815 ... 'E', 5816 ... 'back', 5817 ... placeInZone='Zone' 5818 ... ) 5819 ({}, {}) 5820 >>> g.getDecision('E') 5821 9 5822 >>> g.destination('B', 'through') 5823 9 5824 >>> g.destination('E', 'back') 5825 1 5826 >>> g.zoneParents(9) 5827 {'Zone'} 5828 >>> g.addUnexploredEdge('E', 'farther') 5829 10 5830 >>> g.replaceUnconfirmed( 5831 ... 'E', 5832 ... 'farther', 5833 ... 'F', 5834 ... 'closer', 5835 ... placeInZone=base.DefaultZone 5836 ... ) 5837 ({}, {}) 5838 >>> g.destination('E', 'farther') 5839 10 5840 >>> g.destination('F', 'closer') 5841 9 5842 >>> g.zoneParents(10) 5843 {'Zone'} 5844 >>> g.addUnexploredEdge('F', 'backwards', placeInZone='Enoz') 5845 11 5846 >>> g.replaceUnconfirmed( 5847 ... 'F', 5848 ... 'backwards', 5849 ... 'G', 5850 ... 'forwards', 5851 ... placeInZone=base.DefaultZone 5852 ... ) 5853 ({}, {}) 5854 >>> g.destination('F', 'backwards') 5855 11 5856 >>> g.destination('G', 'forwards') 5857 10 5858 >>> g.zoneParents(11) # not changed since it already had a zone 5859 {'Enoz'} 5860 >>> # TODO: forceNew example 5861 """ 5862 5863 # Defaults 5864 if tags is None: 5865 tags = {} 5866 if annotations is None: 5867 annotations = [] 5868 if revTags is None: 5869 revTags = {} 5870 if revAnnotations is None: 5871 revAnnotations = [] 5872 if decisionTags is None: 5873 decisionTags = {} 5874 if decisionAnnotations is None: 5875 decisionAnnotations = [] 5876 5877 # Resolve source 5878 fromID = self.resolveDecision(fromDecision) 5879 5880 # Figure out destination decision 5881 oldUnexplored = self.destination(fromID, transition) 5882 if self.isConfirmed(oldUnexplored): 5883 raise ExplorationStatusError( 5884 f"Transition {transition!r} from" 5885 f" {self.identityOf(fromDecision)} does not lead to an" 5886 f" unconfirmed decision (it leads to" 5887 f" {self.identityOf(oldUnexplored)} which is not tagged" 5888 f" 'unconfirmed')." 5889 ) 5890 5891 # Resolve destination 5892 newName: Optional[base.DecisionName] = None 5893 connectID: Optional[base.DecisionID] = None 5894 if forceNew: 5895 if isinstance(connectTo, base.DecisionID): 5896 raise TypeError( 5897 f"connectTo cannot be a decision ID when forceNew" 5898 f" is True. Got: {self.identityOf(connectTo)}" 5899 ) 5900 elif isinstance(connectTo, base.DecisionSpecifier): 5901 newName = connectTo.name 5902 elif isinstance(connectTo, base.DecisionName): 5903 newName = connectTo 5904 elif connectTo is None: 5905 oldName = self.nameFor(oldUnexplored) 5906 if ( 5907 oldName.startswith('_u.') 5908 and oldName[3:].isdigit() 5909 ): 5910 newName = utils.uniqueName('_x.' + oldName[3:], self) 5911 else: 5912 newName = oldName 5913 else: 5914 raise TypeError( 5915 f"Invalid connectTo value: {connectTo!r}" 5916 ) 5917 elif connectTo is not None: 5918 try: 5919 connectID = self.resolveDecision(connectTo) 5920 # leave newName as None 5921 except MissingDecisionError: 5922 if isinstance(connectTo, int): 5923 raise 5924 elif isinstance(connectTo, base.DecisionSpecifier): 5925 newName = connectTo.name 5926 # The domain & zone are ignored here 5927 else: # Must just be a string 5928 assert isinstance(connectTo, str) 5929 newName = connectTo 5930 else: 5931 # If connectTo name wasn't specified, use current name of 5932 # unknown node unless it's a default name 5933 oldName = self.nameFor(oldUnexplored) 5934 if ( 5935 oldName.startswith('_u.') 5936 and oldName[3:].isdigit() 5937 ): 5938 newName = utils.uniqueName('_x.' + oldName[3:], self) 5939 else: 5940 newName = oldName 5941 5942 # One or the other should be valid at this point 5943 assert connectID is not None or newName is not None 5944 5945 # Check that the old unknown doesn't have a reciprocal edge that 5946 # would collide with the specified return edge 5947 if reciprocal is not None: 5948 revFromUnknown = self.getDestination(oldUnexplored, reciprocal) 5949 if revFromUnknown not in (None, fromID): 5950 raise TransitionCollisionError( 5951 f"Transition {reciprocal!r} from" 5952 f" {self.identityOf(oldUnexplored)} exists and does" 5953 f" not lead back to {self.identityOf(fromDecision)}" 5954 f" (it leads to {self.identityOf(revFromUnknown)})." 5955 ) 5956 5957 # Remember old reciprocal edge for future merging in case 5958 # it's not reciprocal 5959 oldReciprocal = self.getReciprocal(fromID, transition) 5960 5961 # Apply any new tags or annotations, or create a new node 5962 needsZoneInfo = False 5963 if connectID is not None: 5964 # Before applying tags, check if we need to error out 5965 # because of a reciprocal edge that points to a known 5966 # destination: 5967 if reciprocal is not None: 5968 otherOldUnknown: Optional[ 5969 base.DecisionID 5970 ] = self.getDestination( 5971 connectID, 5972 reciprocal 5973 ) 5974 if ( 5975 otherOldUnknown is not None 5976 and self.isConfirmed(otherOldUnknown) 5977 ): 5978 raise ExplorationStatusError( 5979 f"Reciprocal transition {reciprocal!r} from" 5980 f" {self.identityOf(connectTo)} does not lead" 5981 f" to an unconfirmed decision (it leads to" 5982 f" {self.identityOf(otherOldUnknown)})." 5983 ) 5984 self.tagDecision(connectID, decisionTags) 5985 self.annotateDecision(connectID, decisionAnnotations) 5986 # Still needs zone info if the place we're connecting to was 5987 # unconfirmed up until now, since unconfirmed nodes don't 5988 # normally get zone info when they're created. 5989 if not self.isConfirmed(connectID): 5990 needsZoneInfo = True 5991 5992 # First, merge the old unknown with the connectTo node... 5993 destRenames = self.mergeDecisions( 5994 oldUnexplored, 5995 connectID, 5996 errorOnNameColision=False 5997 ) 5998 else: 5999 needsZoneInfo = True 6000 if len(self.zoneParents(oldUnexplored)) > 0: 6001 needsZoneInfo = False 6002 assert newName is not None 6003 self.renameDecision(oldUnexplored, newName) 6004 connectID = oldUnexplored 6005 # In this case there can't be an other old unknown 6006 otherOldUnknown = None 6007 destRenames = {} # empty 6008 6009 # Check for domain mismatch to stifle zone updates: 6010 fromDomain = self.domainFor(fromID) 6011 if connectID is None: 6012 destDomain = self.domainFor(oldUnexplored) 6013 else: 6014 destDomain = self.domainFor(connectID) 6015 6016 # Stifle zone updates if there's a mismatch 6017 if fromDomain != destDomain: 6018 needsZoneInfo = False 6019 6020 # Records renames that happen at the source (from node) 6021 sourceRenames = {} # empty for now 6022 6023 assert connectID is not None 6024 6025 # Apply the new zone if there is one 6026 if placeInZone is not None: 6027 if placeInZone is base.DefaultZone: 6028 # When using DefaultZone, changes are only made for new 6029 # destinations which don't already have any zones and 6030 # which are in the same domain as the departing node: 6031 # they get placed into each zone parent of the source 6032 # decision. 6033 if needsZoneInfo: 6034 # Remove destination from all current parents 6035 removeFrom = set(self.zoneParents(connectID)) # copy 6036 for parent in removeFrom: 6037 self.removeDecisionFromZone(connectID, parent) 6038 # Add it to parents of origin 6039 for parent in self.zoneParents(fromID): 6040 self.addDecisionToZone(connectID, parent) 6041 else: 6042 placeInZone = cast(base.Zone, placeInZone) 6043 # Create the zone if it doesn't already exist 6044 if self.getZoneInfo(placeInZone) is None: 6045 self.createZone(placeInZone, 0) 6046 # Add it to each grandparent of the from decision 6047 for parent in self.zoneParents(fromID): 6048 for grandparent in self.zoneParents(parent): 6049 self.addZoneToZone(placeInZone, grandparent) 6050 # Remove destination from all current parents 6051 for parent in set(self.zoneParents(connectID)): 6052 self.removeDecisionFromZone(connectID, parent) 6053 # Add it to the specified zone 6054 self.addDecisionToZone(connectID, placeInZone) 6055 6056 # Next, if there is a reciprocal name specified, we do more... 6057 if reciprocal is not None: 6058 # Figure out what kind of merging needs to happen 6059 if otherOldUnknown is None: 6060 if revFromUnknown is None: 6061 # Just create the desired reciprocal transition, which 6062 # we know does not already exist 6063 self.addTransition(connectID, reciprocal, fromID) 6064 otherOldReciprocal = None 6065 else: 6066 # Reciprocal exists, as revFromUnknown 6067 otherOldReciprocal = None 6068 else: 6069 otherOldReciprocal = self.getReciprocal( 6070 connectID, 6071 reciprocal 6072 ) 6073 # we need to merge otherOldUnknown into our fromDecision 6074 sourceRenames = self.mergeDecisions( 6075 otherOldUnknown, 6076 fromID, 6077 errorOnNameColision=False 6078 ) 6079 # Unvisited tag after merge only if both were 6080 6081 # No matter what happened we ensure the reciprocal 6082 # relationship is set up: 6083 self.setReciprocal(fromID, transition, reciprocal) 6084 6085 # Now we might need to merge some transitions: 6086 # - Any reciprocal of the target transition should be merged 6087 # with reciprocal (if it was already reciprocal, that's a 6088 # no-op). 6089 # - Any reciprocal of the reciprocal transition from the target 6090 # node (leading to otherOldUnknown) should be merged with 6091 # the target transition, even if it shared a name and was 6092 # renamed as a result. 6093 # - If reciprocal was renamed during the initial merge, those 6094 # transitions should be merged. 6095 6096 # Merge old reciprocal into reciprocal 6097 if oldReciprocal is not None: 6098 oldRev = destRenames.get(oldReciprocal, oldReciprocal) 6099 if self.getDestination(connectID, oldRev) is not None: 6100 # Note that we don't want to auto-merge the reciprocal, 6101 # which is the target transition 6102 self.mergeTransitions( 6103 connectID, 6104 oldRev, 6105 reciprocal, 6106 mergeReciprocal=False 6107 ) 6108 # Remove it from the renames map 6109 if oldReciprocal in destRenames: 6110 del destRenames[oldReciprocal] 6111 6112 # Merge reciprocal reciprocal from otherOldUnknown 6113 if otherOldReciprocal is not None: 6114 otherOldRev = sourceRenames.get( 6115 otherOldReciprocal, 6116 otherOldReciprocal 6117 ) 6118 # Note that the reciprocal is reciprocal, which we don't 6119 # need to merge 6120 self.mergeTransitions( 6121 fromID, 6122 otherOldRev, 6123 transition, 6124 mergeReciprocal=False 6125 ) 6126 # Remove it from the renames map 6127 if otherOldReciprocal in sourceRenames: 6128 del sourceRenames[otherOldReciprocal] 6129 6130 # Merge any renamed reciprocal onto reciprocal 6131 if reciprocal in destRenames: 6132 extraRev = destRenames[reciprocal] 6133 self.mergeTransitions( 6134 connectID, 6135 extraRev, 6136 reciprocal, 6137 mergeReciprocal=False 6138 ) 6139 # Remove it from the renames map 6140 del destRenames[reciprocal] 6141 6142 # Accumulate new tags & annotations for the transitions 6143 self.tagTransition(fromID, transition, tags) 6144 self.annotateTransition(fromID, transition, annotations) 6145 6146 if reciprocal is not None: 6147 self.tagTransition(connectID, reciprocal, revTags) 6148 self.annotateTransition(connectID, reciprocal, revAnnotations) 6149 6150 # Override copied requirement/consequences for the transitions 6151 if requirement is not None: 6152 self.setTransitionRequirement( 6153 fromID, 6154 transition, 6155 requirement 6156 ) 6157 if applyConsequence is not None: 6158 self.setConsequence( 6159 fromID, 6160 transition, 6161 applyConsequence 6162 ) 6163 6164 if reciprocal is not None: 6165 if revRequires is not None: 6166 self.setTransitionRequirement( 6167 connectID, 6168 reciprocal, 6169 revRequires 6170 ) 6171 if revConsequence is not None: 6172 self.setConsequence( 6173 connectID, 6174 reciprocal, 6175 revConsequence 6176 ) 6177 6178 # Remove 'unconfirmed' tag if it was present 6179 self.untagDecision(connectID, 'unconfirmed') 6180 6181 # Final checks 6182 assert self.getDestination(fromDecision, transition) == connectID 6183 useConnect: base.AnyDecisionSpecifier 6184 useRev: Optional[str] 6185 if connectTo is None: 6186 useConnect = connectID 6187 else: 6188 useConnect = connectTo 6189 if reciprocal is None: 6190 useRev = self.getReciprocal(fromDecision, transition) 6191 else: 6192 useRev = reciprocal 6193 if useRev is not None: 6194 try: 6195 assert self.getDestination(useConnect, useRev) == fromID 6196 except AmbiguousDecisionSpecifierError: 6197 assert self.getDestination(connectID, useRev) == fromID 6198 6199 # Return our final rename dictionaries 6200 return (destRenames, sourceRenames) 6201 6202 def endingID(self, name: base.DecisionName) -> base.DecisionID: 6203 """ 6204 Returns the decision ID for the ending with the specified name. 6205 Endings are disconnected decisions in the `ENDINGS_DOMAIN`; they 6206 don't normally include any zone information. If no ending with 6207 the specified name already existed, then a new ending with that 6208 name will be created and its Decision ID will be returned. 6209 6210 If a new decision is created, it will be tagged as unconfirmed. 6211 6212 Note that endings mostly aren't special: they're normal 6213 decisions in a separate singular-focalized domain. However, some 6214 parts of the exploration and journal machinery treat them 6215 differently (in particular, taking certain actions via 6216 `advanceSituation` while any decision in the `ENDINGS_DOMAIN` is 6217 active is an error. 6218 """ 6219 # Create our new ending decision if we need to 6220 try: 6221 endID = self.resolveDecision( 6222 base.DecisionSpecifier(ENDINGS_DOMAIN, None, name) 6223 ) 6224 except MissingDecisionError: 6225 # Create a new decision for the ending 6226 endID = self.addDecision(name, domain=ENDINGS_DOMAIN) 6227 # Tag it as unconfirmed 6228 self.tagDecision(endID, 'unconfirmed') 6229 6230 return endID 6231 6232 def triggerGroupID(self, name: base.DecisionName) -> base.DecisionID: 6233 """ 6234 Given the name of a trigger group, returns the ID of the special 6235 node representing that trigger group in the `TRIGGERS_DOMAIN`. 6236 If the specified group didn't already exist, it will be created. 6237 6238 Trigger group decisions are not special: they just exist in a 6239 separate spreading-focalized domain and have a few API methods to 6240 access them, but all the normal decision-related API methods 6241 still work. Their intended use is for sets of global triggers, 6242 by attaching actions with the 'trigger' tag to them and then 6243 activating or deactivating them as needed. 6244 """ 6245 result = self.getDecision( 6246 base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name) 6247 ) 6248 if result is None: 6249 return self.addDecision(name, domain=TRIGGERS_DOMAIN) 6250 else: 6251 return result 6252 6253 @staticmethod 6254 def example(which: Literal['simple', 'abc']) -> 'DecisionGraph': 6255 """ 6256 Returns one of a number of example decision graphs, depending on 6257 the string given. It returns a fresh copy each time. The graphs 6258 are: 6259 6260 - 'simple': Three nodes named 'A', 'B', and 'C' with IDs 0, 1, 6261 and 2, each connected to the next in the sequence by a 6262 'next' transition with reciprocal 'prev'. In other words, a 6263 simple little triangle. There are no tags, annotations, 6264 requirements, consequences, mechanisms, or equivalences. 6265 - 'abc': A more complicated 3-node setup that introduces a 6266 little bit of everything. In this graph, we have the same 6267 three nodes, but different transitions: 6268 6269 * From A you can go 'left' to B with reciprocal 'right'. 6270 * From A you can also go 'up_left' to B with reciprocal 6271 'up_right'. These transitions both require the 6272 'grate' mechanism (which is at decision A) to be in 6273 state 'open'. 6274 * From A you can go 'down' to C with reciprocal 'up'. 6275 6276 (In this graph, B and C are not directly connected to each 6277 other.) 6278 6279 The graph has two level-0 zones 'zoneA' and 'zoneB', along 6280 with a level-1 zone 'upZone'. Decisions A and C are in 6281 zoneA while B is in zoneB; zoneA is in upZone, but zoneB is 6282 not. 6283 6284 The decision A has annotation: 6285 6286 'This is a multi-word "annotation."' 6287 6288 The transition 'down' from A has annotation: 6289 6290 "Transition 'annotation.'" 6291 6292 Decision B has tags 'b' with value 1 and 'tag2' with value 6293 '"value"'. 6294 6295 Decision C has tag 'aw"ful' with value "ha'ha'". 6296 6297 Transition 'up' from C has tag 'fast' with value 1. 6298 6299 At decision C there are actions 'grab_helmet' and 6300 'pull_lever'. 6301 6302 The 'grab_helmet' transition requires that you don't have 6303 the 'helmet' capability, and gives you that capability, 6304 deactivating with delay 3. 6305 6306 The 'pull_lever' transition requires that you do have the 6307 'helmet' capability, and takes away that capability, but it 6308 also gives you 1 token, and if you have 2 tokens (before 6309 getting the one extra), it sets the 'grate' mechanism (which 6310 is a decision A) to state 'open' and deactivates. 6311 6312 The graph has an equivalence: having the 'helmet' capability 6313 satisfies requirements for the 'grate' mechanism to be in the 6314 'open' state. 6315 6316 """ 6317 result = DecisionGraph() 6318 if which == 'simple': 6319 result.addDecision('A') # id 0 6320 result.addDecision('B') # id 1 6321 result.addDecision('C') # id 2 6322 result.addTransition('A', 'next', 'B', 'prev') 6323 result.addTransition('B', 'next', 'C', 'prev') 6324 result.addTransition('C', 'next', 'A', 'prev') 6325 elif which == 'abc': 6326 result.addDecision('A') # id 0 6327 result.addDecision('B') # id 1 6328 result.addDecision('C') # id 2 6329 result.createZone('zoneA', 0) 6330 result.createZone('zoneB', 0) 6331 result.createZone('upZone', 1) 6332 result.addZoneToZone('zoneA', 'upZone') 6333 result.addDecisionToZone('A', 'zoneA') 6334 result.addDecisionToZone('B', 'zoneB') 6335 result.addDecisionToZone('C', 'zoneA') 6336 result.addTransition('A', 'left', 'B', 'right') 6337 result.addTransition('A', 'up_left', 'B', 'up_right') 6338 result.addTransition('A', 'down', 'C', 'up') 6339 result.setTransitionRequirement( 6340 'A', 6341 'up_left', 6342 base.ReqMechanism('grate', 'open') 6343 ) 6344 result.setTransitionRequirement( 6345 'B', 6346 'up_right', 6347 base.ReqMechanism('grate', 'open') 6348 ) 6349 result.annotateDecision('A', 'This is a multi-word "annotation."') 6350 result.annotateTransition('A', 'down', "Transition 'annotation.'") 6351 result.tagDecision('B', 'b') 6352 result.tagDecision('B', 'tag2', '"value"') 6353 result.tagDecision('C', 'aw"ful', "ha'ha") 6354 result.tagTransition('C', 'up', 'fast') 6355 result.addMechanism('grate', 'A') 6356 result.addAction( 6357 'C', 6358 'grab_helmet', 6359 base.ReqNot(base.ReqCapability('helmet')), 6360 [ 6361 base.effect(gain='helmet'), 6362 base.effect(deactivate=True, delay=3) 6363 ] 6364 ) 6365 result.addAction( 6366 'C', 6367 'pull_lever', 6368 base.ReqCapability('helmet'), 6369 [ 6370 base.effect(lose='helmet'), 6371 base.effect(gain=('token', 1)), 6372 base.condition( 6373 base.ReqTokens('token', 2), 6374 [ 6375 base.effect(set=('grate', 'open')), 6376 base.effect(deactivate=True) 6377 ] 6378 ) 6379 ] 6380 ) 6381 result.addEquivalence( 6382 base.ReqCapability('helmet'), 6383 (0, 'open') 6384 ) 6385 else: 6386 raise ValueError(f"Invalid example name: {which!r}") 6387 6388 return result
Represents a view of the world as a topological graph at a moment in
time. It derives from networkx.MultiDiGraph
.
Each node (a Decision
) represents a place in the world where there
are multiple opportunities for travel/action, or a dead end where
you must turn around and go back; typically this is a single room in
a game, but sometimes one room has multiple decision points. Edges
(Transition
s) represent choices that can be made to travel to
other decision points (e.g., taking the left door), or when they are
self-edges, they represent actions that can be taken within a
location that affect the world or the game state.
Each Transition
includes a Effects
dictionary
indicating the effects that it has. Other effects of the transition
that are not simple enough to be included in this format may be
represented in an DiscreteExploration
by changing the graph in the
next step to reflect further effects of a transition.
In addition to normal transitions between decisions, a
DecisionGraph
can represent potential transitions which lead to
unknown destinations. These are represented by adding decisions with
the 'unconfirmed'
tag (whose names where not specified begin with
'_u.'
) with a separate unconfirmed decision for each transition
(although where it's known that two transitions lead to the same
unconfirmed decision, this can be represented as well).
Both nodes and edges can have Annotation
s associated with them that
include extra details about the explorer's perception of the
situation. They can also have Tag
s, which represent specific
categories a transition or decision falls into.
Nodes can also be part of one or more Zones
, and zones can also be
part of other zones, allowing for a hierarchical description of the
underlying space.
Equivalences can be specified to mark that some combination of capabilities can stand in for another capability.
420 def __init__(self) -> None: 421 super().__init__() 422 423 self.zones: Dict[base.Zone, base.ZoneInfo] = {} 424 """ 425 Mapping from zone names to zone info 426 """ 427 428 self.unknownCount: int = 0 429 """ 430 Number of unknown decisions that have been created (not number 431 of current unknown decisions, which is likely lower) 432 """ 433 434 self.equivalences: base.Equivalences = {} 435 """ 436 See `base.Equivalences`. Determines what capabilities and/or 437 mechanism states can count as active based on alternate 438 requirements. 439 """ 440 441 self.reversionTypes: Dict[str, Set[str]] = {} 442 """ 443 This tracks shorthand reversion types. See `base.revertedState` 444 for how these are applied. Keys are custom names and values are 445 reversion type strings that `base.revertedState` could access. 446 """ 447 448 self.nextID: base.DecisionID = 0 449 """ 450 The ID to use for the next new decision we create. 451 """ 452 453 self.nextMechanismID: base.MechanismID = 0 454 """ 455 ID for the next mechanism. 456 """ 457 458 self.mechanisms: Dict[ 459 base.MechanismID, 460 Tuple[Optional[base.DecisionID], base.MechanismName] 461 ] = {} 462 """ 463 Mapping from `MechanismID`s to (`DecisionID`, `MechanismName`) 464 pairs. For global mechanisms, the `DecisionID` is None. 465 """ 466 467 self.globalMechanisms: Dict[ 468 base.MechanismName, 469 base.MechanismID 470 ] = {} 471 """ 472 Global mechanisms 473 """ 474 475 self.nameLookup: Dict[base.DecisionName, List[base.DecisionID]] = {} 476 """ 477 A cache for name -> ID lookups 478 """
Initialize a graph with edges, name, or graph attributes.
Parameters
incoming_graph_data : input graph Data to initialize graph. If incoming_graph_data=None (default) an empty graph is created. The data can be an edge list, or any NetworkX graph object. If the corresponding optional Python packages are installed the data can also be a 2D NumPy array, a SciPy sparse array, or a PyGraphviz graph.
multigraph_input : bool or None (default None)
Note: Only used when incoming_graph_data
is a dict.
If True, incoming_graph_data
is assumed to be a
dict-of-dict-of-dict-of-dict structure keyed by
node to neighbor to edge keys to edge data for multi-edges.
A NetworkXError is raised if this is not the case.
If False, to_networkx_graph()
is used to try to determine
the dict's graph data structure as either a dict-of-dict-of-dict
keyed by node to neighbor to edge data, or a dict-of-iterable
keyed by node to neighbors.
If None, the treatment for True is tried, but if it fails,
the treatment for False is tried.
attr : keyword arguments, optional (default= no attributes) Attributes to add to graph as key=value pairs.
See Also
convert
Examples
>>> G = nx.Graph() # or DiGraph, MultiGraph, MultiDiGraph, etc
>>> G = nx.Graph(name="my graph")
>>> e = [(1, 2), (2, 3), (3, 4)] # list of edges
>>> G = nx.Graph(e)
Arbitrary graph attribute pairs (key=value) may be assigned
>>> G = nx.Graph(e, day="Friday")
>>> G.graph
{'day': 'Friday'}
Number of unknown decisions that have been created (not number of current unknown decisions, which is likely lower)
See base.Equivalences
. Determines what capabilities and/or
mechanism states can count as active based on alternate
requirements.
This tracks shorthand reversion types. See base.revertedState
for how these are applied. Keys are custom names and values are
reversion type strings that base.revertedState
could access.
Mapping from MechanismID
s to (DecisionID
, MechanismName
)
pairs. For global mechanisms, the DecisionID
is None.
526 def listDifferences( 527 self, 528 other: 'DecisionGraph' 529 ) -> Generator[str, None, None]: 530 """ 531 Generates strings describing differences between this graph and 532 another graph. This does NOT perform graph matching, so it will 533 consider graphs different even if they have identical structures 534 but use different IDs for the nodes in those structures. 535 """ 536 if not isinstance(other, DecisionGraph): 537 yield "other is not a graph" 538 else: 539 suppress = False 540 myNodes = set(self.nodes) 541 theirNodes = set(other.nodes) 542 for n in myNodes: 543 if n not in theirNodes: 544 suppress = True 545 yield ( 546 f"other graph missing node {n}" 547 ) 548 else: 549 if self.nodes[n] != other.nodes[n]: 550 suppress = True 551 yield ( 552 f"other graph has differences at node {n}:" 553 f"\n Ours: {self.nodes[n]}" 554 f"\nTheirs: {other.nodes[n]}" 555 ) 556 myDests = self.destinationsFrom(n) 557 theirDests = other.destinationsFrom(n) 558 for tr in myDests: 559 myTo = myDests[tr] 560 if tr not in theirDests: 561 suppress = True 562 yield ( 563 f"at {self.identityOf(n)}: other graph" 564 f" missing transition {tr!r}" 565 ) 566 else: 567 theirTo = theirDests[tr] 568 if myTo != theirTo: 569 suppress = True 570 yield ( 571 f"at {self.identityOf(n)}: other" 572 f" graph transition {tr!r} leads to" 573 f" {theirTo} instead of {myTo}" 574 ) 575 else: 576 myProps = self.edges[n, myTo, tr] # type:ignore [index] # noqa 577 theirProps = other.edges[n, myTo, tr] # type:ignore [index] # noqa 578 if myProps != theirProps: 579 suppress = True 580 yield ( 581 f"at {self.identityOf(n)}: other" 582 f" graph transition {tr!r} has" 583 f" different properties:" 584 f"\n Ours: {myProps}" 585 f"\nTheirs: {theirProps}" 586 ) 587 for extra in theirNodes - myNodes: 588 suppress = True 589 yield ( 590 f"other graph has extra node {extra}" 591 ) 592 593 # TODO: Fix networkx stubs! 594 if self.graph != other.graph: # type:ignore [attr-defined] 595 suppress = True 596 yield ( 597 " different graph attributes:" # type:ignore [attr-defined] # noqa 598 f"\n Ours: {self.graph}" 599 f"\nTheirs: {other.graph}" 600 ) 601 602 # Checks any other graph data we might have missed 603 if not super().__eq__(other) and not suppress: 604 for attr in dir(self): 605 if attr.startswith('__') and attr.endswith('__'): 606 continue 607 if not hasattr(other, attr): 608 yield f"other graph missing attribute: {attr!r}" 609 else: 610 myVal = getattr(self, attr) 611 theirVal = getattr(other, attr) 612 if ( 613 myVal != theirVal 614 and not ((callable(myVal) and callable(theirVal))) 615 ): 616 yield ( 617 f"other has different val for {attr!r}:" 618 f"\n Ours: {myVal}" 619 f"\nTheirs: {theirVal}" 620 ) 621 for attr in sorted(set(dir(other)) - set(dir(self))): 622 yield f"other has extra attribute: {attr!r}" 623 yield "graph data is different" 624 # TODO: More detail here! 625 626 # Check unknown count 627 if self.unknownCount != other.unknownCount: 628 yield "unknown count is different" 629 630 # Check zones 631 if self.zones != other.zones: 632 yield "zones are different" 633 634 # Check equivalences 635 if self.equivalences != other.equivalences: 636 yield "equivalences are different" 637 638 # Check reversion types 639 if self.reversionTypes != other.reversionTypes: 640 yield "reversionTypes are different" 641 642 # Check mechanisms 643 if self.nextMechanismID != other.nextMechanismID: 644 yield "nextMechanismID is different" 645 646 if self.mechanisms != other.mechanisms: 647 yield "mechanisms are different" 648 649 if self.globalMechanisms != other.globalMechanisms: 650 yield "global mechanisms are different" 651 652 # Check names: 653 if self.nameLookup != other.nameLookup: 654 for name in self.nameLookup: 655 if name not in other.nameLookup: 656 yield ( 657 f"other graph is missing name lookup entry" 658 f" for {name!r}" 659 ) 660 else: 661 mine = self.nameLookup[name] 662 theirs = other.nameLookup[name] 663 if theirs != mine: 664 yield ( 665 f"name lookup for {name!r} is {theirs}" 666 f" instead of {mine}" 667 ) 668 extras = set(other.nameLookup) - set(self.nameLookup) 669 if extras: 670 yield ( 671 f"other graph has extra name lookup entries:" 672 f" {extras}" 673 )
Generates strings describing differences between this graph and another graph. This does NOT perform graph matching, so it will consider graphs different even if they have identical structures but use different IDs for the nodes in those structures.
693 def decisionInfo(self, dID: base.DecisionID) -> DecisionInfo: 694 """ 695 Retrieves the decision info for the specified decision, as a 696 live editable dictionary. 697 698 For example: 699 700 >>> g = DecisionGraph() 701 >>> g.addDecision('A') 702 0 703 >>> g.annotateDecision('A', 'note') 704 >>> g.decisionInfo(0) 705 {'name': 'A', 'domain': 'main', 'tags': {}, 'annotations': ['note']} 706 """ 707 return cast(DecisionInfo, self.nodes[dID])
Retrieves the decision info for the specified decision, as a live editable dictionary.
For example:
>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.annotateDecision('A', 'note')
>>> g.decisionInfo(0)
{'name': 'A', 'domain': 'main', 'tags': {}, 'annotations': ['note']}
709 def resolveDecision( 710 self, 711 spec: base.AnyDecisionSpecifier, 712 zoneHint: Optional[base.Zone] = None, 713 domainHint: Optional[base.Domain] = None 714 ) -> base.DecisionID: 715 """ 716 Given a decision specifier returns the ID associated with that 717 decision, or raises an `AmbiguousDecisionSpecifierError` or a 718 `MissingDecisionError` if the specified decision is either 719 missing or ambiguous. Cannot handle strings that contain domain 720 and/or zone parts; use 721 `parsing.ParseFormat.parseDecisionSpecifier` to turn such 722 strings into `DecisionSpecifier`s if you need to first. 723 724 Examples: 725 726 >>> g = DecisionGraph() 727 >>> g.addDecision('A') 728 0 729 >>> g.addDecision('B') 730 1 731 >>> g.addDecision('C') 732 2 733 >>> g.addDecision('A') 734 3 735 >>> g.addDecision('B', 'menu') 736 4 737 >>> g.createZone('Z', 0) 738 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 739 annotations=[]) 740 >>> g.createZone('Z2', 0) 741 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 742 annotations=[]) 743 >>> g.createZone('Zup', 1) 744 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 745 annotations=[]) 746 >>> g.addDecisionToZone(0, 'Z') 747 >>> g.addDecisionToZone(1, 'Z') 748 >>> g.addDecisionToZone(2, 'Z') 749 >>> g.addDecisionToZone(3, 'Z2') 750 >>> g.addZoneToZone('Z', 'Zup') 751 >>> g.addZoneToZone('Z2', 'Zup') 752 >>> g.resolveDecision(1) 753 1 754 >>> g.resolveDecision('A') 755 Traceback (most recent call last): 756 ... 757 exploration.core.AmbiguousDecisionSpecifierError... 758 >>> g.resolveDecision('B') 759 Traceback (most recent call last): 760 ... 761 exploration.core.AmbiguousDecisionSpecifierError... 762 >>> g.resolveDecision('C') 763 2 764 >>> g.resolveDecision('A', 'Z') 765 0 766 >>> g.resolveDecision('A', zoneHint='Z2') 767 3 768 >>> g.resolveDecision('B', domainHint='main') 769 1 770 >>> g.resolveDecision('B', None, 'menu') 771 4 772 >>> g.resolveDecision('B', zoneHint='Z2') 773 Traceback (most recent call last): 774 ... 775 exploration.core.MissingDecisionError... 776 >>> g.resolveDecision('A', domainHint='menu') 777 Traceback (most recent call last): 778 ... 779 exploration.core.MissingDecisionError... 780 >>> g.resolveDecision('A', domainHint='madeup') 781 Traceback (most recent call last): 782 ... 783 exploration.core.MissingDecisionError... 784 >>> g.resolveDecision('A', zoneHint='madeup') 785 Traceback (most recent call last): 786 ... 787 exploration.core.MissingDecisionError... 788 """ 789 # Parse it to either an ID or specifier if it's a string: 790 if isinstance(spec, str): 791 try: 792 spec = int(spec) 793 except ValueError: 794 pass 795 796 # If it's an ID, check for existence: 797 if isinstance(spec, base.DecisionID): 798 if spec in self: 799 return spec 800 else: 801 raise MissingDecisionError( 802 f"There is no decision with ID {spec!r}." 803 ) 804 else: 805 if isinstance(spec, base.DecisionName): 806 spec = base.DecisionSpecifier( 807 domain=None, 808 zone=None, 809 name=spec 810 ) 811 elif not isinstance(spec, base.DecisionSpecifier): 812 raise TypeError( 813 f"Specification is not provided as a" 814 f" DecisionSpecifier or other valid type. (got type" 815 f" {type(spec)})." 816 ) 817 818 # Merge domain hints from spec/args 819 if ( 820 spec.domain is not None 821 and domainHint is not None 822 and spec.domain != domainHint 823 ): 824 raise ValueError( 825 f"Specifier {repr(spec)} includes domain hint" 826 f" {repr(spec.domain)} which is incompatible with" 827 f" explicit domain hint {repr(domainHint)}." 828 ) 829 else: 830 domainHint = spec.domain or domainHint 831 832 # Merge zone hints from spec/args 833 if ( 834 spec.zone is not None 835 and zoneHint is not None 836 and spec.zone != zoneHint 837 ): 838 raise ValueError( 839 f"Specifier {repr(spec)} includes zone hint" 840 f" {repr(spec.zone)} which is incompatible with" 841 f" explicit zone hint {repr(zoneHint)}." 842 ) 843 else: 844 zoneHint = spec.zone or zoneHint 845 846 if spec.name not in self.nameLookup: 847 raise MissingDecisionError( 848 f"No decision named {repr(spec.name)}." 849 ) 850 else: 851 options = self.nameLookup[spec.name] 852 if len(options) == 0: 853 raise MissingDecisionError( 854 f"No decision named {repr(spec.name)}." 855 ) 856 filtered = [ 857 opt 858 for opt in options 859 if ( 860 domainHint is None 861 or self.domainFor(opt) == domainHint 862 ) and ( 863 zoneHint is None 864 or zoneHint in self.zoneAncestors(opt) 865 ) 866 ] 867 if len(filtered) == 1: 868 return filtered[0] 869 else: 870 filterDesc = "" 871 if domainHint is not None: 872 filterDesc += f" in domain {repr(domainHint)}" 873 if zoneHint is not None: 874 filterDesc += f" in zone {repr(zoneHint)}" 875 if len(filtered) == 0: 876 raise MissingDecisionError( 877 f"No decisions named" 878 f" {repr(spec.name)}{filterDesc}." 879 ) 880 else: 881 raise AmbiguousDecisionSpecifierError( 882 f"There are {len(filtered)} decisions" 883 f" named {repr(spec.name)}{filterDesc}." 884 )
Given a decision specifier returns the ID associated with that
decision, or raises an AmbiguousDecisionSpecifierError
or a
MissingDecisionError
if the specified decision is either
missing or ambiguous. Cannot handle strings that contain domain
and/or zone parts; use
parsing.ParseFormat.parseDecisionSpecifier
to turn such
strings into DecisionSpecifier
s if you need to first.
Examples:
>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addDecision('C')
2
>>> g.addDecision('A')
3
>>> g.addDecision('B', 'menu')
4
>>> g.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('Z2', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('Zup', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone(0, 'Z')
>>> g.addDecisionToZone(1, 'Z')
>>> g.addDecisionToZone(2, 'Z')
>>> g.addDecisionToZone(3, 'Z2')
>>> g.addZoneToZone('Z', 'Zup')
>>> g.addZoneToZone('Z2', 'Zup')
>>> g.resolveDecision(1)
1
>>> g.resolveDecision('A')
Traceback (most recent call last):
...
AmbiguousDecisionSpecifierError...
>>> g.resolveDecision('B')
Traceback (most recent call last):
...
AmbiguousDecisionSpecifierError...
>>> g.resolveDecision('C')
2
>>> g.resolveDecision('A', 'Z')
0
>>> g.resolveDecision('A', zoneHint='Z2')
3
>>> g.resolveDecision('B', domainHint='main')
1
>>> g.resolveDecision('B', None, 'menu')
4
>>> g.resolveDecision('B', zoneHint='Z2')
Traceback (most recent call last):
...
MissingDecisionError...
>>> g.resolveDecision('A', domainHint='menu')
Traceback (most recent call last):
...
MissingDecisionError...
>>> g.resolveDecision('A', domainHint='madeup')
Traceback (most recent call last):
...
MissingDecisionError...
>>> g.resolveDecision('A', zoneHint='madeup')
Traceback (most recent call last):
...
MissingDecisionError...
886 def getDecision( 887 self, 888 decision: base.AnyDecisionSpecifier, 889 zoneHint: Optional[base.Zone] = None, 890 domainHint: Optional[base.Domain] = None 891 ) -> Optional[base.DecisionID]: 892 """ 893 Works like `resolveDecision` but returns None instead of raising 894 a `MissingDecisionError` if the specified decision isn't listed. 895 May still raise an `AmbiguousDecisionSpecifierError`. 896 """ 897 try: 898 return self.resolveDecision( 899 decision, 900 zoneHint, 901 domainHint 902 ) 903 except MissingDecisionError: 904 return None
Works like resolveDecision
but returns None instead of raising
a MissingDecisionError
if the specified decision isn't listed.
May still raise an AmbiguousDecisionSpecifierError
.
906 def nameFor( 907 self, 908 decision: base.AnyDecisionSpecifier 909 ) -> base.DecisionName: 910 """ 911 Returns the name of the specified decision. Note that names are 912 not necessarily unique. 913 914 Example: 915 916 >>> d = DecisionGraph() 917 >>> d.addDecision('A') 918 0 919 >>> d.addDecision('B') 920 1 921 >>> d.addDecision('B') 922 2 923 >>> d.nameFor(0) 924 'A' 925 >>> d.nameFor(1) 926 'B' 927 >>> d.nameFor(2) 928 'B' 929 >>> d.nameFor(3) 930 Traceback (most recent call last): 931 ... 932 exploration.core.MissingDecisionError... 933 """ 934 dID = self.resolveDecision(decision) 935 return self.nodes[dID]['name']
Returns the name of the specified decision. Note that names are not necessarily unique.
Example:
>>> d = DecisionGraph()
>>> d.addDecision('A')
0
>>> d.addDecision('B')
1
>>> d.addDecision('B')
2
>>> d.nameFor(0)
'A'
>>> d.nameFor(1)
'B'
>>> d.nameFor(2)
'B'
>>> d.nameFor(3)
Traceback (most recent call last):
...
MissingDecisionError...
937 def identityOf( 938 self, 939 decision: Optional[base.AnyDecisionSpecifier], 940 includeZones: bool = True, 941 alwaysDomain: Optional[bool] = None 942 ) -> str: 943 """ 944 Returns a string containing the given decision ID and the name 945 for that decision in parentheses afterwards. If the value 946 provided is `None`, it returns the string "(nowhere)". 947 948 If `includeZones` is true (the default) then zone information 949 is included before the decision name. 950 951 If `alwaysDomain` is true or false, then the domain information 952 will always (or never) be included. If it's `None` (the default) 953 then domain info will only be included for decisions which are 954 not in the default domain. 955 """ 956 if decision is None: 957 return "(nowhere)" 958 else: 959 dID = self.resolveDecision(decision) 960 thisDomain = self.domainFor(dID) 961 dSpec = '' 962 zSpec = '' 963 if ( 964 alwaysDomain is True 965 or ( 966 alwaysDomain is None 967 and thisDomain != base.DEFAULT_DOMAIN 968 ) 969 ): 970 dSpec = thisDomain + '//' # TODO: Don't hardcode this? 971 if includeZones: 972 zones = [ 973 z 974 for z in self.zoneParents(dID) 975 if self.zones[z].level == 0 976 ] 977 if len(zones) == 1: 978 zSpec = zones[0] + '::' # TODO: Don't hardcode this? 979 elif len(zones) > 1: 980 zSpec = '[' + ', '.join(sorted(zones)) + ']::' 981 # else leave zSpec empty 982 983 return f"{dID} ({dSpec}{zSpec}{self.nameFor(dID)})"
Returns a string containing the given decision ID and the name
for that decision in parentheses afterwards. If the value
provided is None
, it returns the string "(nowhere)".
If includeZones
is true (the default) then zone information
is included before the decision name.
If alwaysDomain
is true or false, then the domain information
will always (or never) be included. If it's None
(the default)
then domain info will only be included for decisions which are
not in the default domain.
985 def namesListing( 986 self, 987 decisions: Collection[base.DecisionID], 988 includeZones: bool = True, 989 indent: int = 2 990 ) -> str: 991 """ 992 Returns a multi-line string containing an indented listing of 993 the provided decision IDs with their names in parentheses after 994 each. Useful for debugging & error messages. 995 996 Includes level-0 zones where applicable, with a zone separator 997 before the decision, unless `includeZones` is set to False. Where 998 there are multiple level-0 zones, they're listed together in 999 brackets. 1000 1001 Uses the string '(none)' when there are no decisions are in the 1002 list. 1003 1004 Set `indent` to something other than 2 to control how much 1005 indentation is added. 1006 1007 For example: 1008 1009 >>> g = DecisionGraph() 1010 >>> g.addDecision('A') 1011 0 1012 >>> g.addDecision('B') 1013 1 1014 >>> g.addDecision('C') 1015 2 1016 >>> g.namesListing(['A', 'C', 'B']) 1017 ' 0 (A)\\n 2 (C)\\n 1 (B)\\n' 1018 >>> g.namesListing([]) 1019 ' (none)\\n' 1020 >>> g.createZone('zone', 0) 1021 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1022 annotations=[]) 1023 >>> g.createZone('zone2', 0) 1024 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1025 annotations=[]) 1026 >>> g.createZone('zoneUp', 1) 1027 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 1028 annotations=[]) 1029 >>> g.addDecisionToZone(0, 'zone') 1030 >>> g.addDecisionToZone(1, 'zone') 1031 >>> g.addDecisionToZone(1, 'zone2') 1032 >>> g.addDecisionToZone(2, 'zoneUp') # won't be listed: it's level-1 1033 >>> g.namesListing(['A', 'C', 'B']) 1034 ' 0 (zone::A)\\n 2 (C)\\n 1 ([zone, zone2]::B)\\n' 1035 """ 1036 ind = ' ' * indent 1037 if len(decisions) == 0: 1038 return ind + '(none)\n' 1039 else: 1040 result = '' 1041 for dID in decisions: 1042 result += ind + self.identityOf(dID, includeZones) + '\n' 1043 return result
Returns a multi-line string containing an indented listing of the provided decision IDs with their names in parentheses after each. Useful for debugging & error messages.
Includes level-0 zones where applicable, with a zone separator
before the decision, unless includeZones
is set to False. Where
there are multiple level-0 zones, they're listed together in
brackets.
Uses the string '(none)' when there are no decisions are in the list.
Set indent
to something other than 2 to control how much
indentation is added.
For example:
>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addDecision('C')
2
>>> g.namesListing(['A', 'C', 'B'])
' 0 (A)\n 2 (C)\n 1 (B)\n'
>>> g.namesListing([])
' (none)\n'
>>> g.createZone('zone', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('zone2', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('zoneUp', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone(0, 'zone')
>>> g.addDecisionToZone(1, 'zone')
>>> g.addDecisionToZone(1, 'zone2')
>>> g.addDecisionToZone(2, 'zoneUp') # won't be listed: it's level-1
>>> g.namesListing(['A', 'C', 'B'])
' 0 (zone::A)\n 2 (C)\n 1 ([zone, zone2]::B)\n'
1045 def destinationsListing( 1046 self, 1047 destinations: Dict[base.Transition, base.DecisionID], 1048 includeZones: bool = True, 1049 indent: int = 2 1050 ) -> str: 1051 """ 1052 Returns a multi-line string containing an indented listing of 1053 the provided transitions along with their destinations and the 1054 names of those destinations in parentheses. Useful for debugging 1055 & error messages. (Use e.g., `destinationsFrom` to get a 1056 transitions -> destinations dictionary in the required format.) 1057 1058 Uses the string '(no transitions)' when there are no transitions 1059 in the dictionary. 1060 1061 Set `indent` to something other than 2 to control how much 1062 indentation is added. 1063 1064 For example: 1065 1066 >>> g = DecisionGraph() 1067 >>> g.addDecision('A') 1068 0 1069 >>> g.addDecision('B') 1070 1 1071 >>> g.addDecision('C') 1072 2 1073 >>> g.addTransition('A', 'north', 'B', 'south') 1074 >>> g.addTransition('B', 'east', 'C', 'west') 1075 >>> g.addTransition('C', 'southwest', 'A', 'northeast') 1076 >>> g.destinationsListing(g.destinationsFrom('A')) 1077 ' north to 1 (B)\\n northeast to 2 (C)\\n' 1078 >>> g.destinationsListing(g.destinationsFrom('B')) 1079 ' south to 0 (A)\\n east to 2 (C)\\n' 1080 >>> g.destinationsListing({}) 1081 ' (none)\\n' 1082 >>> g.createZone('zone', 0) 1083 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1084 annotations=[]) 1085 >>> g.addDecisionToZone(0, 'zone') 1086 >>> g.destinationsListing(g.destinationsFrom('B')) 1087 ' south to 0 (zone::A)\\n east to 2 (C)\\n' 1088 """ 1089 ind = ' ' * indent 1090 if len(destinations) == 0: 1091 return ind + '(none)\n' 1092 else: 1093 result = '' 1094 for transition, dID in destinations.items(): 1095 line = f"{transition} to {self.identityOf(dID, includeZones)}" 1096 result += ind + line + '\n' 1097 return result
Returns a multi-line string containing an indented listing of
the provided transitions along with their destinations and the
names of those destinations in parentheses. Useful for debugging
& error messages. (Use e.g., destinationsFrom
to get a
transitions -> destinations dictionary in the required format.)
Uses the string '(no transitions)' when there are no transitions in the dictionary.
Set indent
to something other than 2 to control how much
indentation is added.
For example:
>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addDecision('C')
2
>>> g.addTransition('A', 'north', 'B', 'south')
>>> g.addTransition('B', 'east', 'C', 'west')
>>> g.addTransition('C', 'southwest', 'A', 'northeast')
>>> g.destinationsListing(g.destinationsFrom('A'))
' north to 1 (B)\n northeast to 2 (C)\n'
>>> g.destinationsListing(g.destinationsFrom('B'))
' south to 0 (A)\n east to 2 (C)\n'
>>> g.destinationsListing({})
' (none)\n'
>>> g.createZone('zone', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone(0, 'zone')
>>> g.destinationsListing(g.destinationsFrom('B'))
' south to 0 (zone::A)\n east to 2 (C)\n'
1099 def domainFor(self, decision: base.AnyDecisionSpecifier) -> base.Domain: 1100 """ 1101 Returns the domain that a decision belongs to. 1102 """ 1103 dID = self.resolveDecision(decision) 1104 return self.nodes[dID]['domain']
Returns the domain that a decision belongs to.
1106 def allDecisionsInDomain( 1107 self, 1108 domain: base.Domain 1109 ) -> Set[base.DecisionID]: 1110 """ 1111 Returns the set of all `DecisionID`s for decisions in the 1112 specified domain. 1113 """ 1114 return set(dID for dID in self if self.nodes[dID]['domain'] == domain)
Returns the set of all DecisionID
s for decisions in the
specified domain.
1116 def destination( 1117 self, 1118 decision: base.AnyDecisionSpecifier, 1119 transition: base.Transition 1120 ) -> base.DecisionID: 1121 """ 1122 Overrides base `UniqueExitsGraph.destination` to raise 1123 `MissingDecisionError` or `MissingTransitionError` as 1124 appropriate, and to work with an `AnyDecisionSpecifier`. 1125 """ 1126 dID = self.resolveDecision(decision) 1127 try: 1128 return super().destination(dID, transition) 1129 except KeyError: 1130 raise MissingTransitionError( 1131 f"Transition {transition!r} does not exist at decision" 1132 f" {self.identityOf(dID)}." 1133 )
Overrides base UniqueExitsGraph.destination
to raise
MissingDecisionError
or MissingTransitionError
as
appropriate, and to work with an AnyDecisionSpecifier
.
1135 def getDestination( 1136 self, 1137 decision: base.AnyDecisionSpecifier, 1138 transition: base.Transition, 1139 default: Any = None 1140 ) -> Optional[base.DecisionID]: 1141 """ 1142 Overrides base `UniqueExitsGraph.getDestination` with different 1143 argument names, since those matter for the edit DSL. 1144 """ 1145 dID = self.resolveDecision(decision) 1146 return super().getDestination(dID, transition)
Overrides base UniqueExitsGraph.getDestination
with different
argument names, since those matter for the edit DSL.
1148 def destinationsFrom( 1149 self, 1150 decision: base.AnyDecisionSpecifier 1151 ) -> Dict[base.Transition, base.DecisionID]: 1152 """ 1153 Override that just changes the type of the exception from a 1154 `KeyError` to a `MissingDecisionError` when the source does not 1155 exist. 1156 """ 1157 dID = self.resolveDecision(decision) 1158 return super().destinationsFrom(dID)
Override that just changes the type of the exception from a
KeyError
to a MissingDecisionError
when the source does not
exist.
1160 def bothEnds( 1161 self, 1162 decision: base.AnyDecisionSpecifier, 1163 transition: base.Transition 1164 ) -> Set[base.DecisionID]: 1165 """ 1166 Returns a set containing the `DecisionID`(s) for both the start 1167 and end of the specified transition. Raises a 1168 `MissingDecisionError` or `MissingTransitionError`if the 1169 specified decision and/or transition do not exist. 1170 1171 Note that for actions since the source and destination are the 1172 same, the set will have only one element. 1173 """ 1174 dID = self.resolveDecision(decision) 1175 result = {dID} 1176 dest = self.destination(dID, transition) 1177 if dest is not None: 1178 result.add(dest) 1179 return result
Returns a set containing the DecisionID
(s) for both the start
and end of the specified transition. Raises a
MissingDecisionError
or MissingTransitionError
if the
specified decision and/or transition do not exist.
Note that for actions since the source and destination are the same, the set will have only one element.
1181 def decisionActions( 1182 self, 1183 decision: base.AnyDecisionSpecifier 1184 ) -> Set[base.Transition]: 1185 """ 1186 Retrieves the set of self-edges at a decision. Editing the set 1187 will not affect the graph. 1188 1189 Example: 1190 1191 >>> g = DecisionGraph() 1192 >>> g.addDecision('A') 1193 0 1194 >>> g.addDecision('B') 1195 1 1196 >>> g.addDecision('C') 1197 2 1198 >>> g.addAction('A', 'action1') 1199 >>> g.addAction('A', 'action2') 1200 >>> g.addAction('B', 'action3') 1201 >>> sorted(g.decisionActions('A')) 1202 ['action1', 'action2'] 1203 >>> g.decisionActions('B') 1204 {'action3'} 1205 >>> g.decisionActions('C') 1206 set() 1207 """ 1208 result = set() 1209 dID = self.resolveDecision(decision) 1210 for transition, dest in self.destinationsFrom(dID).items(): 1211 if dest == dID: 1212 result.add(transition) 1213 return result
Retrieves the set of self-edges at a decision. Editing the set will not affect the graph.
Example:
>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addDecision('C')
2
>>> g.addAction('A', 'action1')
>>> g.addAction('A', 'action2')
>>> g.addAction('B', 'action3')
>>> sorted(g.decisionActions('A'))
['action1', 'action2']
>>> g.decisionActions('B')
{'action3'}
>>> g.decisionActions('C')
set()
1215 def getTransitionProperties( 1216 self, 1217 decision: base.AnyDecisionSpecifier, 1218 transition: base.Transition 1219 ) -> TransitionProperties: 1220 """ 1221 Returns a dictionary containing transition properties for the 1222 specified transition from the specified decision. The properties 1223 included are: 1224 1225 - 'requirement': The requirement for the transition. 1226 - 'consequence': Any consequence of the transition. 1227 - 'tags': Any tags applied to the transition. 1228 - 'annotations': Any annotations on the transition. 1229 1230 The reciprocal of the transition is not included. 1231 1232 The result is a clone of the stored properties; edits to the 1233 dictionary will NOT modify the graph. 1234 """ 1235 dID = self.resolveDecision(decision) 1236 dest = self.destination(dID, transition) 1237 1238 info: TransitionProperties = copy.deepcopy( 1239 self.edges[dID, dest, transition] # type:ignore 1240 ) 1241 return { 1242 'requirement': info.get('requirement', base.ReqNothing()), 1243 'consequence': info.get('consequence', []), 1244 'tags': info.get('tags', {}), 1245 'annotations': info.get('annotations', []) 1246 }
Returns a dictionary containing transition properties for the specified transition from the specified decision. The properties included are:
- 'requirement': The requirement for the transition.
- 'consequence': Any consequence of the transition.
- 'tags': Any tags applied to the transition.
- 'annotations': Any annotations on the transition.
The reciprocal of the transition is not included.
The result is a clone of the stored properties; edits to the dictionary will NOT modify the graph.
1248 def setTransitionProperties( 1249 self, 1250 decision: base.AnyDecisionSpecifier, 1251 transition: base.Transition, 1252 requirement: Optional[base.Requirement] = None, 1253 consequence: Optional[base.Consequence] = None, 1254 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 1255 annotations: Optional[List[base.Annotation]] = None 1256 ) -> None: 1257 """ 1258 Sets one or more transition properties all at once. Can be used 1259 to set the requirement, consequence, tags, and/or annotations. 1260 Old values are overwritten, although if `None`s are provided (or 1261 arguments are omitted), corresponding properties are not 1262 updated. 1263 1264 To add tags or annotations to existing tags/annotations instead 1265 of replacing them, use `tagTransition` or `annotateTransition` 1266 instead. 1267 """ 1268 dID = self.resolveDecision(decision) 1269 if requirement is not None: 1270 self.setTransitionRequirement(dID, transition, requirement) 1271 if consequence is not None: 1272 self.setConsequence(dID, transition, consequence) 1273 if tags is not None: 1274 dest = self.destination(dID, transition) 1275 # TODO: Submit pull request to update MultiDiGraph stubs in 1276 # types-networkx to include OutMultiEdgeView that accepts 1277 # from/to/key tuples as indices. 1278 info = cast( 1279 TransitionProperties, 1280 self.edges[dID, dest, transition] # type:ignore 1281 ) 1282 info['tags'] = tags 1283 if annotations is not None: 1284 dest = self.destination(dID, transition) 1285 info = cast( 1286 TransitionProperties, 1287 self.edges[dID, dest, transition] # type:ignore 1288 ) 1289 info['annotations'] = annotations
Sets one or more transition properties all at once. Can be used
to set the requirement, consequence, tags, and/or annotations.
Old values are overwritten, although if None
s are provided (or
arguments are omitted), corresponding properties are not
updated.
To add tags or annotations to existing tags/annotations instead
of replacing them, use tagTransition
or annotateTransition
instead.
1291 def getTransitionRequirement( 1292 self, 1293 decision: base.AnyDecisionSpecifier, 1294 transition: base.Transition 1295 ) -> base.Requirement: 1296 """ 1297 Returns the `Requirement` for accessing a specific transition at 1298 a specific decision. For transitions which don't have 1299 requirements, returns a `ReqNothing` instance. 1300 """ 1301 dID = self.resolveDecision(decision) 1302 dest = self.destination(dID, transition) 1303 1304 info = cast( 1305 TransitionProperties, 1306 self.edges[dID, dest, transition] # type:ignore 1307 ) 1308 1309 return info.get('requirement', base.ReqNothing())
Returns the Requirement
for accessing a specific transition at
a specific decision. For transitions which don't have
requirements, returns a ReqNothing
instance.
1311 def setTransitionRequirement( 1312 self, 1313 decision: base.AnyDecisionSpecifier, 1314 transition: base.Transition, 1315 requirement: Optional[base.Requirement] 1316 ) -> None: 1317 """ 1318 Sets the `Requirement` for accessing a specific transition at 1319 a specific decision. Raises a `KeyError` if the decision or 1320 transition does not exist. 1321 1322 Deletes the requirement if `None` is given as the requirement. 1323 1324 Use `parsing.ParseFormat.parseRequirement` first if you have a 1325 requirement in string format. 1326 1327 Does not raise an error if deletion is requested for a 1328 non-existent requirement, and silently overwrites any previous 1329 requirement. 1330 """ 1331 dID = self.resolveDecision(decision) 1332 1333 dest = self.destination(dID, transition) 1334 1335 info = cast( 1336 TransitionProperties, 1337 self.edges[dID, dest, transition] # type:ignore 1338 ) 1339 1340 if requirement is None: 1341 try: 1342 del info['requirement'] 1343 except KeyError: 1344 pass 1345 else: 1346 if not isinstance(requirement, base.Requirement): 1347 raise TypeError( 1348 f"Invalid requirement type: {type(requirement)}" 1349 ) 1350 1351 info['requirement'] = requirement
Sets the Requirement
for accessing a specific transition at
a specific decision. Raises a KeyError
if the decision or
transition does not exist.
Deletes the requirement if None
is given as the requirement.
Use parsing.ParseFormat.parseRequirement
first if you have a
requirement in string format.
Does not raise an error if deletion is requested for a non-existent requirement, and silently overwrites any previous requirement.
1353 def getConsequence( 1354 self, 1355 decision: base.AnyDecisionSpecifier, 1356 transition: base.Transition 1357 ) -> base.Consequence: 1358 """ 1359 Retrieves the consequence of a transition. 1360 1361 A `KeyError` is raised if the specified decision/transition 1362 combination doesn't exist. 1363 """ 1364 dID = self.resolveDecision(decision) 1365 1366 dest = self.destination(dID, transition) 1367 1368 info = cast( 1369 TransitionProperties, 1370 self.edges[dID, dest, transition] # type:ignore 1371 ) 1372 1373 return info.get('consequence', [])
Retrieves the consequence of a transition.
A KeyError
is raised if the specified decision/transition
combination doesn't exist.
1375 def addConsequence( 1376 self, 1377 decision: base.AnyDecisionSpecifier, 1378 transition: base.Transition, 1379 consequence: base.Consequence 1380 ) -> Tuple[int, int]: 1381 """ 1382 Adds the given `Consequence` to the consequence list for the 1383 specified transition, extending that list at the end. Note that 1384 this does NOT make a copy of the consequence, so it should not 1385 be used to copy consequences from one transition to another 1386 without making a deep copy first. 1387 1388 A `MissingDecisionError` or a `MissingTransitionError` is raised 1389 if the specified decision/transition combination doesn't exist. 1390 1391 Returns a pair of integers indicating the minimum and maximum 1392 depth-first-traversal-indices of the added consequence part(s). 1393 The outer consequence list itself (index 0) is not counted. 1394 1395 >>> d = DecisionGraph() 1396 >>> d.addDecision('A') 1397 0 1398 >>> d.addDecision('B') 1399 1 1400 >>> d.addTransition('A', 'fwd', 'B', 'rev') 1401 >>> d.addConsequence('A', 'fwd', [base.effect(gain='sword')]) 1402 (1, 1) 1403 >>> d.addConsequence('B', 'rev', [base.effect(lose='sword')]) 1404 (1, 1) 1405 >>> ef = d.getConsequence('A', 'fwd') 1406 >>> er = d.getConsequence('B', 'rev') 1407 >>> ef == [base.effect(gain='sword')] 1408 True 1409 >>> er == [base.effect(lose='sword')] 1410 True 1411 >>> d.addConsequence('A', 'fwd', [base.effect(deactivate=True)]) 1412 (2, 2) 1413 >>> ef = d.getConsequence('A', 'fwd') 1414 >>> ef == [base.effect(gain='sword'), base.effect(deactivate=True)] 1415 True 1416 >>> d.addConsequence( 1417 ... 'A', 1418 ... 'fwd', # adding to consequence with 3 parts already 1419 ... [ # outer list not counted because it merges 1420 ... base.challenge( # 1 part 1421 ... None, 1422 ... 0, 1423 ... [base.effect(gain=('flowers', 3))], # 2 parts 1424 ... [base.effect(gain=('flowers', 1))] # 2 parts 1425 ... ) 1426 ... ] 1427 ... ) # note indices below are inclusive; indices are 3, 4, 5, 6, 7 1428 (3, 7) 1429 """ 1430 dID = self.resolveDecision(decision) 1431 1432 dest = self.destination(dID, transition) 1433 1434 info = cast( 1435 TransitionProperties, 1436 self.edges[dID, dest, transition] # type:ignore 1437 ) 1438 1439 existing = info.setdefault('consequence', []) 1440 startIndex = base.countParts(existing) 1441 existing.extend(consequence) 1442 endIndex = base.countParts(existing) - 1 1443 return (startIndex, endIndex)
Adds the given Consequence
to the consequence list for the
specified transition, extending that list at the end. Note that
this does NOT make a copy of the consequence, so it should not
be used to copy consequences from one transition to another
without making a deep copy first.
A MissingDecisionError
or a MissingTransitionError
is raised
if the specified decision/transition combination doesn't exist.
Returns a pair of integers indicating the minimum and maximum depth-first-traversal-indices of the added consequence part(s). The outer consequence list itself (index 0) is not counted.
>>> d = DecisionGraph()
>>> d.addDecision('A')
0
>>> d.addDecision('B')
1
>>> d.addTransition('A', 'fwd', 'B', 'rev')
>>> d.addConsequence('A', 'fwd', [base.effect(gain='sword')])
(1, 1)
>>> d.addConsequence('B', 'rev', [base.effect(lose='sword')])
(1, 1)
>>> ef = d.getConsequence('A', 'fwd')
>>> er = d.getConsequence('B', 'rev')
>>> ef == [base.effect(gain='sword')]
True
>>> er == [base.effect(lose='sword')]
True
>>> d.addConsequence('A', 'fwd', [base.effect(deactivate=True)])
(2, 2)
>>> ef = d.getConsequence('A', 'fwd')
>>> ef == [base.effect(gain='sword'), base.effect(deactivate=True)]
True
>>> d.addConsequence(
... 'A',
... 'fwd', # adding to consequence with 3 parts already
... [ # outer list not counted because it merges
... base.challenge( # 1 part
... None,
... 0,
... [base.effect(gain=('flowers', 3))], # 2 parts
... [base.effect(gain=('flowers', 1))] # 2 parts
... )
... ]
... ) # note indices below are inclusive; indices are 3, 4, 5, 6, 7
(3, 7)
1445 def setConsequence( 1446 self, 1447 decision: base.AnyDecisionSpecifier, 1448 transition: base.Transition, 1449 consequence: base.Consequence 1450 ) -> None: 1451 """ 1452 Replaces the transition consequence for the given transition at 1453 the given decision. Any previous consequence is discarded. See 1454 `Consequence` for the structure of these. Note that this does 1455 NOT make a copy of the consequence, do that first to avoid 1456 effect-entanglement if you're copying a consequence. 1457 1458 A `MissingDecisionError` or a `MissingTransitionError` is raised 1459 if the specified decision/transition combination doesn't exist. 1460 """ 1461 dID = self.resolveDecision(decision) 1462 1463 dest = self.destination(dID, transition) 1464 1465 info = cast( 1466 TransitionProperties, 1467 self.edges[dID, dest, transition] # type:ignore 1468 ) 1469 1470 info['consequence'] = consequence
Replaces the transition consequence for the given transition at
the given decision. Any previous consequence is discarded. See
Consequence
for the structure of these. Note that this does
NOT make a copy of the consequence, do that first to avoid
effect-entanglement if you're copying a consequence.
A MissingDecisionError
or a MissingTransitionError
is raised
if the specified decision/transition combination doesn't exist.
1472 def addEquivalence( 1473 self, 1474 requirement: base.Requirement, 1475 capabilityOrMechanismState: Union[ 1476 base.Capability, 1477 Tuple[base.MechanismID, base.MechanismState] 1478 ] 1479 ) -> None: 1480 """ 1481 Adds the given requirement as an equivalence for the given 1482 capability or the given mechanism state. Note that having a 1483 capability via an equivalence does not count as actually having 1484 that capability; it only counts for the purpose of satisfying 1485 `Requirement`s. 1486 1487 Note also that because a mechanism-based requirement looks up 1488 the specific mechanism locally based on a name, an equivalence 1489 defined in one location may affect mechanism requirements in 1490 other locations unless the mechanism name in the requirement is 1491 zone-qualified to be specific. But in such situations the base 1492 mechanism would have caused issues in any case. 1493 """ 1494 self.equivalences.setdefault( 1495 capabilityOrMechanismState, 1496 set() 1497 ).add(requirement)
Adds the given requirement as an equivalence for the given
capability or the given mechanism state. Note that having a
capability via an equivalence does not count as actually having
that capability; it only counts for the purpose of satisfying
Requirement
s.
Note also that because a mechanism-based requirement looks up the specific mechanism locally based on a name, an equivalence defined in one location may affect mechanism requirements in other locations unless the mechanism name in the requirement is zone-qualified to be specific. But in such situations the base mechanism would have caused issues in any case.
1499 def removeEquivalence( 1500 self, 1501 requirement: base.Requirement, 1502 capabilityOrMechanismState: Union[ 1503 base.Capability, 1504 Tuple[base.MechanismID, base.MechanismState] 1505 ] 1506 ) -> None: 1507 """ 1508 Removes an equivalence. Raises a `KeyError` if no such 1509 equivalence existed. 1510 """ 1511 self.equivalences[capabilityOrMechanismState].remove(requirement)
Removes an equivalence. Raises a KeyError
if no such
equivalence existed.
1513 def hasAnyEquivalents( 1514 self, 1515 capabilityOrMechanismState: Union[ 1516 base.Capability, 1517 Tuple[base.MechanismID, base.MechanismState] 1518 ] 1519 ) -> bool: 1520 """ 1521 Returns `True` if the given capability or mechanism state has at 1522 least one equivalence. 1523 """ 1524 return capabilityOrMechanismState in self.equivalences
Returns True
if the given capability or mechanism state has at
least one equivalence.
1526 def allEquivalents( 1527 self, 1528 capabilityOrMechanismState: Union[ 1529 base.Capability, 1530 Tuple[base.MechanismID, base.MechanismState] 1531 ] 1532 ) -> Set[base.Requirement]: 1533 """ 1534 Returns the set of equivalences for the given capability. This is 1535 a live set which may be modified (it's probably better to use 1536 `addEquivalence` and `removeEquivalence` instead...). 1537 """ 1538 return self.equivalences.setdefault( 1539 capabilityOrMechanismState, 1540 set() 1541 )
Returns the set of equivalences for the given capability. This is
a live set which may be modified (it's probably better to use
addEquivalence
and removeEquivalence
instead...).
1543 def reversionType(self, name: str, equivalentTo: Set[str]) -> None: 1544 """ 1545 Specifies a new reversion type, so that when used in a reversion 1546 aspects set with a colon before the name, all items in the 1547 `equivalentTo` value will be added to that set. These may 1548 include other custom reversion type names (with the colon) but 1549 take care not to create an equivalence loop which would result 1550 in a crash. 1551 1552 If you re-use the same name, it will override the old equivalence 1553 for that name. 1554 """ 1555 self.reversionTypes[name] = equivalentTo
Specifies a new reversion type, so that when used in a reversion
aspects set with a colon before the name, all items in the
equivalentTo
value will be added to that set. These may
include other custom reversion type names (with the colon) but
take care not to create an equivalence loop which would result
in a crash.
If you re-use the same name, it will override the old equivalence for that name.
1557 def addAction( 1558 self, 1559 decision: base.AnyDecisionSpecifier, 1560 action: base.Transition, 1561 requires: Optional[base.Requirement] = None, 1562 consequence: Optional[base.Consequence] = None, 1563 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 1564 annotations: Optional[List[base.Annotation]] = None, 1565 ) -> None: 1566 """ 1567 Adds the given action as a possibility at the given decision. An 1568 action is just a self-edge, which can have requirements like any 1569 edge, and which can have consequences like any edge. 1570 The optional arguments are given to `setTransitionRequirement` 1571 and `setConsequence`; see those functions for descriptions 1572 of what they mean. 1573 1574 Raises a `KeyError` if a transition with the given name already 1575 exists at the given decision. 1576 """ 1577 if tags is None: 1578 tags = {} 1579 if annotations is None: 1580 annotations = [] 1581 1582 dID = self.resolveDecision(decision) 1583 1584 self.add_edge( 1585 dID, 1586 dID, 1587 key=action, 1588 tags=tags, 1589 annotations=annotations 1590 ) 1591 self.setTransitionRequirement(dID, action, requires) 1592 if consequence is not None: 1593 self.setConsequence(dID, action, consequence)
Adds the given action as a possibility at the given decision. An
action is just a self-edge, which can have requirements like any
edge, and which can have consequences like any edge.
The optional arguments are given to setTransitionRequirement
and setConsequence
; see those functions for descriptions
of what they mean.
Raises a KeyError
if a transition with the given name already
exists at the given decision.
1595 def tagDecision( 1596 self, 1597 decision: base.AnyDecisionSpecifier, 1598 tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]], 1599 tagValue: Union[ 1600 base.TagValue, 1601 type[base.NoTagValue] 1602 ] = base.NoTagValue 1603 ) -> None: 1604 """ 1605 Adds a tag (or many tags from a dictionary of tags) to a 1606 decision, using `1` as the value if no value is provided. It's 1607 a `ValueError` to provide a value when a dictionary of tags is 1608 provided to set multiple tags at once. 1609 1610 Note that certain tags have special meanings: 1611 1612 - 'unconfirmed' is used for decisions that represent unconfirmed 1613 parts of the graph (this is separate from the 'unknown' 1614 and/or 'hypothesized' exploration statuses, which are only 1615 tracked in a `DiscreteExploration`, not in a `DecisionGraph`). 1616 Various methods require this tag and many also add or remove 1617 it. 1618 """ 1619 if isinstance(tagOrTags, base.Tag): 1620 if tagValue is base.NoTagValue: 1621 tagValue = 1 1622 1623 # Not sure why this cast is necessary given the `if` above... 1624 tagValue = cast(base.TagValue, tagValue) 1625 1626 tagOrTags = {tagOrTags: tagValue} 1627 1628 elif tagValue is not base.NoTagValue: 1629 raise ValueError( 1630 "Provided a dictionary to update multiple tags, but" 1631 " also a tag value." 1632 ) 1633 1634 dID = self.resolveDecision(decision) 1635 1636 tagsAlready = self.nodes[dID].setdefault('tags', {}) 1637 tagsAlready.update(tagOrTags)
Adds a tag (or many tags from a dictionary of tags) to a
decision, using 1
as the value if no value is provided. It's
a ValueError
to provide a value when a dictionary of tags is
provided to set multiple tags at once.
Note that certain tags have special meanings:
- 'unconfirmed' is used for decisions that represent unconfirmed
parts of the graph (this is separate from the 'unknown'
and/or 'hypothesized' exploration statuses, which are only
tracked in a
DiscreteExploration
, not in aDecisionGraph
). Various methods require this tag and many also add or remove it.
1639 def untagDecision( 1640 self, 1641 decision: base.AnyDecisionSpecifier, 1642 tag: base.Tag 1643 ) -> Union[base.TagValue, type[base.NoTagValue]]: 1644 """ 1645 Removes a tag from a decision. Returns the tag's old value if 1646 the tag was present and got removed, or `NoTagValue` if the tag 1647 wasn't present. 1648 """ 1649 dID = self.resolveDecision(decision) 1650 1651 target = self.nodes[dID]['tags'] 1652 try: 1653 return target.pop(tag) 1654 except KeyError: 1655 return base.NoTagValue
Removes a tag from a decision. Returns the tag's old value if
the tag was present and got removed, or NoTagValue
if the tag
wasn't present.
1657 def decisionTags( 1658 self, 1659 decision: base.AnyDecisionSpecifier 1660 ) -> Dict[base.Tag, base.TagValue]: 1661 """ 1662 Returns the dictionary of tags for a decision. Edits to the 1663 returned value will be applied to the graph. 1664 """ 1665 dID = self.resolveDecision(decision) 1666 1667 return self.nodes[dID]['tags']
Returns the dictionary of tags for a decision. Edits to the returned value will be applied to the graph.
1669 def annotateDecision( 1670 self, 1671 decision: base.AnyDecisionSpecifier, 1672 annotationOrAnnotations: Union[ 1673 base.Annotation, 1674 Sequence[base.Annotation] 1675 ] 1676 ) -> None: 1677 """ 1678 Adds an annotation to a decision's annotations list. 1679 """ 1680 dID = self.resolveDecision(decision) 1681 1682 if isinstance(annotationOrAnnotations, base.Annotation): 1683 annotationOrAnnotations = [annotationOrAnnotations] 1684 self.nodes[dID]['annotations'].extend(annotationOrAnnotations)
Adds an annotation to a decision's annotations list.
1686 def decisionAnnotations( 1687 self, 1688 decision: base.AnyDecisionSpecifier 1689 ) -> List[base.Annotation]: 1690 """ 1691 Returns the list of annotations for the specified decision. 1692 Modifying the list affects the graph. 1693 """ 1694 dID = self.resolveDecision(decision) 1695 1696 return self.nodes[dID]['annotations']
Returns the list of annotations for the specified decision. Modifying the list affects the graph.
1698 def tagTransition( 1699 self, 1700 decision: base.AnyDecisionSpecifier, 1701 transition: base.Transition, 1702 tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]], 1703 tagValue: Union[ 1704 base.TagValue, 1705 type[base.NoTagValue] 1706 ] = base.NoTagValue 1707 ) -> None: 1708 """ 1709 Adds a tag (or each tag from a dictionary) to a transition 1710 coming out of a specific decision. `1` will be used as the 1711 default value if a single tag is supplied; supplying a tag value 1712 when providing a dictionary of multiple tags to update is a 1713 `ValueError`. 1714 1715 Note that certain transition tags have special meanings: 1716 - 'trigger' causes any actions (but not normal transitions) that 1717 it applies to to be automatically triggered when 1718 `advanceSituation` is called and the decision they're 1719 attached to is active in the new situation (as long as the 1720 action's requirements are met). This happens once per 1721 situation; use 'wait' steps to re-apply triggers. 1722 """ 1723 dID = self.resolveDecision(decision) 1724 1725 dest = self.destination(dID, transition) 1726 if isinstance(tagOrTags, base.Tag): 1727 if tagValue is base.NoTagValue: 1728 tagValue = 1 1729 1730 # Not sure why this is necessary given the `if` above... 1731 tagValue = cast(base.TagValue, tagValue) 1732 1733 tagOrTags = {tagOrTags: tagValue} 1734 elif tagValue is not base.NoTagValue: 1735 raise ValueError( 1736 "Provided a dictionary to update multiple tags, but" 1737 " also a tag value." 1738 ) 1739 1740 info = cast( 1741 TransitionProperties, 1742 self.edges[dID, dest, transition] # type:ignore 1743 ) 1744 1745 info.setdefault('tags', {}).update(tagOrTags)
Adds a tag (or each tag from a dictionary) to a transition
coming out of a specific decision. 1
will be used as the
default value if a single tag is supplied; supplying a tag value
when providing a dictionary of multiple tags to update is a
ValueError
.
Note that certain transition tags have special meanings:
- 'trigger' causes any actions (but not normal transitions) that
it applies to to be automatically triggered when
advanceSituation
is called and the decision they're attached to is active in the new situation (as long as the action's requirements are met). This happens once per situation; use 'wait' steps to re-apply triggers.
1747 def untagTransition( 1748 self, 1749 decision: base.AnyDecisionSpecifier, 1750 transition: base.Transition, 1751 tagOrTags: Union[base.Tag, Set[base.Tag]] 1752 ) -> None: 1753 """ 1754 Removes a tag (or each tag in a set) from a transition coming out 1755 of a specific decision. Raises a `KeyError` if (one of) the 1756 specified tag(s) is not currently applied to the specified 1757 transition. 1758 """ 1759 dID = self.resolveDecision(decision) 1760 1761 dest = self.destination(dID, transition) 1762 if isinstance(tagOrTags, base.Tag): 1763 tagOrTags = {tagOrTags} 1764 1765 info = cast( 1766 TransitionProperties, 1767 self.edges[dID, dest, transition] # type:ignore 1768 ) 1769 tagsAlready = info.setdefault('tags', {}) 1770 1771 for tag in tagOrTags: 1772 tagsAlready.pop(tag)
Removes a tag (or each tag in a set) from a transition coming out
of a specific decision. Raises a KeyError
if (one of) the
specified tag(s) is not currently applied to the specified
transition.
1774 def transitionTags( 1775 self, 1776 decision: base.AnyDecisionSpecifier, 1777 transition: base.Transition 1778 ) -> Dict[base.Tag, base.TagValue]: 1779 """ 1780 Returns the dictionary of tags for a transition. Edits to the 1781 returned dictionary will be applied to the graph. 1782 """ 1783 dID = self.resolveDecision(decision) 1784 1785 dest = self.destination(dID, transition) 1786 info = cast( 1787 TransitionProperties, 1788 self.edges[dID, dest, transition] # type:ignore 1789 ) 1790 return info.setdefault('tags', {})
Returns the dictionary of tags for a transition. Edits to the returned dictionary will be applied to the graph.
1792 def annotateTransition( 1793 self, 1794 decision: base.AnyDecisionSpecifier, 1795 transition: base.Transition, 1796 annotations: Union[base.Annotation, Sequence[base.Annotation]] 1797 ) -> None: 1798 """ 1799 Adds an annotation (or a sequence of annotations) to a 1800 transition's annotations list. 1801 """ 1802 dID = self.resolveDecision(decision) 1803 1804 dest = self.destination(dID, transition) 1805 if isinstance(annotations, base.Annotation): 1806 annotations = [annotations] 1807 info = cast( 1808 TransitionProperties, 1809 self.edges[dID, dest, transition] # type:ignore 1810 ) 1811 info['annotations'].extend(annotations)
Adds an annotation (or a sequence of annotations) to a transition's annotations list.
1813 def transitionAnnotations( 1814 self, 1815 decision: base.AnyDecisionSpecifier, 1816 transition: base.Transition 1817 ) -> List[base.Annotation]: 1818 """ 1819 Returns the annotation list for a specific transition at a 1820 specific decision. Editing the list affects the graph. 1821 """ 1822 dID = self.resolveDecision(decision) 1823 1824 dest = self.destination(dID, transition) 1825 info = cast( 1826 TransitionProperties, 1827 self.edges[dID, dest, transition] # type:ignore 1828 ) 1829 return info['annotations']
Returns the annotation list for a specific transition at a specific decision. Editing the list affects the graph.
1831 def annotateZone( 1832 self, 1833 zone: base.Zone, 1834 annotations: Union[base.Annotation, Sequence[base.Annotation]] 1835 ) -> None: 1836 """ 1837 Adds an annotation (or many annotations from a sequence) to a 1838 zone. 1839 1840 Raises a `MissingZoneError` if the specified zone does not exist. 1841 """ 1842 if zone not in self.zones: 1843 raise MissingZoneError( 1844 f"Can't add annotation(s) to zone {zone!r} because that" 1845 f" zone doesn't exist yet." 1846 ) 1847 1848 if isinstance(annotations, base.Annotation): 1849 annotations = [ annotations ] 1850 1851 self.zones[zone].annotations.extend(annotations)
Adds an annotation (or many annotations from a sequence) to a zone.
Raises a MissingZoneError
if the specified zone does not exist.
1853 def zoneAnnotations(self, zone: base.Zone) -> List[base.Annotation]: 1854 """ 1855 Returns the list of annotations for the specified zone (empty if 1856 none have been added yet). 1857 """ 1858 return self.zones[zone].annotations
Returns the list of annotations for the specified zone (empty if none have been added yet).
1860 def tagZone( 1861 self, 1862 zone: base.Zone, 1863 tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]], 1864 tagValue: Union[ 1865 base.TagValue, 1866 type[base.NoTagValue] 1867 ] = base.NoTagValue 1868 ) -> None: 1869 """ 1870 Adds a tag (or many tags from a dictionary of tags) to a 1871 zone, using `1` as the value if no value is provided. It's 1872 a `ValueError` to provide a value when a dictionary of tags is 1873 provided to set multiple tags at once. 1874 1875 Raises a `MissingZoneError` if the specified zone does not exist. 1876 """ 1877 if zone not in self.zones: 1878 raise MissingZoneError( 1879 f"Can't add tag(s) to zone {zone!r} because that zone" 1880 f" doesn't exist yet." 1881 ) 1882 1883 if isinstance(tagOrTags, base.Tag): 1884 if tagValue is base.NoTagValue: 1885 tagValue = 1 1886 1887 # Not sure why this cast is necessary given the `if` above... 1888 tagValue = cast(base.TagValue, tagValue) 1889 1890 tagOrTags = {tagOrTags: tagValue} 1891 1892 elif tagValue is not base.NoTagValue: 1893 raise ValueError( 1894 "Provided a dictionary to update multiple tags, but" 1895 " also a tag value." 1896 ) 1897 1898 tagsAlready = self.zones[zone].tags 1899 tagsAlready.update(tagOrTags)
Adds a tag (or many tags from a dictionary of tags) to a
zone, using 1
as the value if no value is provided. It's
a ValueError
to provide a value when a dictionary of tags is
provided to set multiple tags at once.
Raises a MissingZoneError
if the specified zone does not exist.
1901 def untagZone( 1902 self, 1903 zone: base.Zone, 1904 tag: base.Tag 1905 ) -> Union[base.TagValue, type[base.NoTagValue]]: 1906 """ 1907 Removes a tag from a zone. Returns the tag's old value if the 1908 tag was present and got removed, or `NoTagValue` if the tag 1909 wasn't present. 1910 1911 Raises a `MissingZoneError` if the specified zone does not exist. 1912 """ 1913 if zone not in self.zones: 1914 raise MissingZoneError( 1915 f"Can't remove tag {tag!r} from zone {zone!r} because" 1916 f" that zone doesn't exist yet." 1917 ) 1918 target = self.zones[zone].tags 1919 try: 1920 return target.pop(tag) 1921 except KeyError: 1922 return base.NoTagValue
Removes a tag from a zone. Returns the tag's old value if the
tag was present and got removed, or NoTagValue
if the tag
wasn't present.
Raises a MissingZoneError
if the specified zone does not exist.
1924 def zoneTags( 1925 self, 1926 zone: base.Zone 1927 ) -> Dict[base.Tag, base.TagValue]: 1928 """ 1929 Returns the dictionary of tags for a zone. Edits to the returned 1930 value will be applied to the graph. Returns an empty tags 1931 dictionary if called on a zone that didn't have any tags 1932 previously, but raises a `MissingZoneError` if attempting to get 1933 tags for a zone which does not exist. 1934 1935 For example: 1936 1937 >>> g = DecisionGraph() 1938 >>> g.addDecision('A') 1939 0 1940 >>> g.addDecision('B') 1941 1 1942 >>> g.createZone('Zone') 1943 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1944 annotations=[]) 1945 >>> g.tagZone('Zone', 'color', 'blue') 1946 >>> g.tagZone( 1947 ... 'Zone', 1948 ... {'shape': 'square', 'color': 'red', 'sound': 'loud'} 1949 ... ) 1950 >>> g.untagZone('Zone', 'sound') 1951 'loud' 1952 >>> g.zoneTags('Zone') 1953 {'color': 'red', 'shape': 'square'} 1954 """ 1955 if zone in self.zones: 1956 return self.zones[zone].tags 1957 else: 1958 raise MissingZoneError( 1959 f"Tags for zone {zone!r} don't exist because that" 1960 f" zone has not been created yet." 1961 )
Returns the dictionary of tags for a zone. Edits to the returned
value will be applied to the graph. Returns an empty tags
dictionary if called on a zone that didn't have any tags
previously, but raises a MissingZoneError
if attempting to get
tags for a zone which does not exist.
For example:
>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.createZone('Zone')
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.tagZone('Zone', 'color', 'blue')
>>> g.tagZone(
... 'Zone',
... {'shape': 'square', 'color': 'red', 'sound': 'loud'}
... )
>>> g.untagZone('Zone', 'sound')
'loud'
>>> g.zoneTags('Zone')
{'color': 'red', 'shape': 'square'}
1963 def createZone(self, zone: base.Zone, level: int = 0) -> base.ZoneInfo: 1964 """ 1965 Creates an empty zone with the given name at the given level 1966 (default 0). Raises a `ZoneCollisionError` if that zone name is 1967 already in use (at any level), including if it's in use by a 1968 decision. 1969 1970 Raises an `InvalidLevelError` if the level value is less than 0. 1971 1972 Returns the `ZoneInfo` for the new blank zone. 1973 1974 For example: 1975 1976 >>> d = DecisionGraph() 1977 >>> d.createZone('Z', 0) 1978 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1979 annotations=[]) 1980 >>> d.getZoneInfo('Z') 1981 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1982 annotations=[]) 1983 >>> d.createZone('Z2', 0) 1984 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1985 annotations=[]) 1986 >>> d.createZone('Z3', -1) # level -1 is not valid (must be >= 0) 1987 Traceback (most recent call last): 1988 ... 1989 exploration.core.InvalidLevelError... 1990 >>> d.createZone('Z2') # Name Z2 is already in use 1991 Traceback (most recent call last): 1992 ... 1993 exploration.core.ZoneCollisionError... 1994 """ 1995 if level < 0: 1996 raise InvalidLevelError( 1997 "Cannot create a zone with a negative level." 1998 ) 1999 if zone in self.zones: 2000 raise ZoneCollisionError(f"Zone {zone!r} already exists.") 2001 if zone in self: 2002 raise ZoneCollisionError( 2003 f"A decision named {zone!r} already exists, so a zone" 2004 f" with that name cannot be created." 2005 ) 2006 info: base.ZoneInfo = base.ZoneInfo( 2007 level=level, 2008 parents=set(), 2009 contents=set(), 2010 tags={}, 2011 annotations=[] 2012 ) 2013 self.zones[zone] = info 2014 return info
Creates an empty zone with the given name at the given level
(default 0). Raises a ZoneCollisionError
if that zone name is
already in use (at any level), including if it's in use by a
decision.
Raises an InvalidLevelError
if the level value is less than 0.
Returns the ZoneInfo
for the new blank zone.
For example:
>>> d = DecisionGraph()
>>> d.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('Z2', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('Z3', -1) # level -1 is not valid (must be >= 0)
Traceback (most recent call last):
...
InvalidLevelError...
>>> d.createZone('Z2') # Name Z2 is already in use
Traceback (most recent call last):
...
ZoneCollisionError...
2016 def getZoneInfo(self, zone: base.Zone) -> Optional[base.ZoneInfo]: 2017 """ 2018 Returns the `ZoneInfo` (level, parents, and contents) for the 2019 specified zone, or `None` if that zone does not exist. 2020 2021 For example: 2022 2023 >>> d = DecisionGraph() 2024 >>> d.createZone('Z', 0) 2025 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2026 annotations=[]) 2027 >>> d.getZoneInfo('Z') 2028 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2029 annotations=[]) 2030 >>> d.createZone('Z2', 0) 2031 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2032 annotations=[]) 2033 >>> d.getZoneInfo('Z2') 2034 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2035 annotations=[]) 2036 """ 2037 return self.zones.get(zone)
Returns the ZoneInfo
(level, parents, and contents) for the
specified zone, or None
if that zone does not exist.
For example:
>>> d = DecisionGraph()
>>> d.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('Z2', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.getZoneInfo('Z2')
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
2039 def deleteZone(self, zone: base.Zone) -> base.ZoneInfo: 2040 """ 2041 Deletes the specified zone, returning a `ZoneInfo` object with 2042 the information on the level, parents, and contents of that zone. 2043 2044 Raises a `MissingZoneError` if the zone in question does not 2045 exist. 2046 2047 For example: 2048 2049 >>> d = DecisionGraph() 2050 >>> d.createZone('Z', 0) 2051 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2052 annotations=[]) 2053 >>> d.getZoneInfo('Z') 2054 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2055 annotations=[]) 2056 >>> d.deleteZone('Z') 2057 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2058 annotations=[]) 2059 >>> d.getZoneInfo('Z') is None # no info any more 2060 True 2061 >>> d.deleteZone('Z') # can't re-delete 2062 Traceback (most recent call last): 2063 ... 2064 exploration.core.MissingZoneError... 2065 """ 2066 info = self.getZoneInfo(zone) 2067 if info is None: 2068 raise MissingZoneError( 2069 f"Cannot delete zone {zone!r}: it does not exist." 2070 ) 2071 for sub in info.contents: 2072 if 'zones' in self.nodes[sub]: 2073 try: 2074 self.nodes[sub]['zones'].remove(zone) 2075 except KeyError: 2076 pass 2077 del self.zones[zone] 2078 return info
Deletes the specified zone, returning a ZoneInfo
object with
the information on the level, parents, and contents of that zone.
Raises a MissingZoneError
if the zone in question does not
exist.
For example:
>>> d = DecisionGraph()
>>> d.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.deleteZone('Z')
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.getZoneInfo('Z') is None # no info any more
True
>>> d.deleteZone('Z') # can't re-delete
Traceback (most recent call last):
...
MissingZoneError...
2080 def addDecisionToZone( 2081 self, 2082 decision: base.AnyDecisionSpecifier, 2083 zone: base.Zone 2084 ) -> None: 2085 """ 2086 Adds a decision directly to a zone. Should normally only be used 2087 with level-0 zones. Raises a `MissingZoneError` if the specified 2088 zone did not already exist. 2089 2090 For example: 2091 2092 >>> d = DecisionGraph() 2093 >>> d.addDecision('A') 2094 0 2095 >>> d.addDecision('B') 2096 1 2097 >>> d.addDecision('C') 2098 2 2099 >>> d.createZone('Z', 0) 2100 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2101 annotations=[]) 2102 >>> d.addDecisionToZone('A', 'Z') 2103 >>> d.getZoneInfo('Z') 2104 ZoneInfo(level=0, parents=set(), contents={0}, tags={},\ 2105 annotations=[]) 2106 >>> d.addDecisionToZone('B', 'Z') 2107 >>> d.getZoneInfo('Z') 2108 ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\ 2109 annotations=[]) 2110 """ 2111 dID = self.resolveDecision(decision) 2112 2113 if zone not in self.zones: 2114 raise MissingZoneError(f"Zone {zone!r} does not exist.") 2115 2116 self.zones[zone].contents.add(dID) 2117 self.nodes[dID].setdefault('zones', set()).add(zone)
Adds a decision directly to a zone. Should normally only be used
with level-0 zones. Raises a MissingZoneError
if the specified
zone did not already exist.
For example:
>>> d = DecisionGraph()
>>> d.addDecision('A')
0
>>> d.addDecision('B')
1
>>> d.addDecision('C')
2
>>> d.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addDecisionToZone('A', 'Z')
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents=set(), contents={0}, tags={}, annotations=[])
>>> d.addDecisionToZone('B', 'Z')
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={}, annotations=[])
2119 def removeDecisionFromZone( 2120 self, 2121 decision: base.AnyDecisionSpecifier, 2122 zone: base.Zone 2123 ) -> bool: 2124 """ 2125 Removes a decision from a zone if it had been in it, returning 2126 True if that decision had been in that zone, and False if it was 2127 not in that zone, including if that zone didn't exist. 2128 2129 Note that this only removes a decision from direct zone 2130 membership. If the decision is a member of one or more zones 2131 which are (directly or indirectly) sub-zones of the target zone, 2132 the decision will remain in those zones, and will still be 2133 indirectly part of the target zone afterwards. 2134 2135 Examples: 2136 2137 >>> g = DecisionGraph() 2138 >>> g.addDecision('A') 2139 0 2140 >>> g.addDecision('B') 2141 1 2142 >>> g.createZone('level0', 0) 2143 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2144 annotations=[]) 2145 >>> g.createZone('level1', 1) 2146 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2147 annotations=[]) 2148 >>> g.createZone('level2', 2) 2149 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2150 annotations=[]) 2151 >>> g.createZone('level3', 3) 2152 ZoneInfo(level=3, parents=set(), contents=set(), tags={},\ 2153 annotations=[]) 2154 >>> g.addDecisionToZone('A', 'level0') 2155 >>> g.addDecisionToZone('B', 'level0') 2156 >>> g.addZoneToZone('level0', 'level1') 2157 >>> g.addZoneToZone('level1', 'level2') 2158 >>> g.addZoneToZone('level2', 'level3') 2159 >>> g.addDecisionToZone('B', 'level2') # Direct w/ skips 2160 >>> g.removeDecisionFromZone('A', 'level1') 2161 False 2162 >>> g.zoneParents(0) 2163 {'level0'} 2164 >>> g.removeDecisionFromZone('A', 'level0') 2165 True 2166 >>> g.zoneParents(0) 2167 set() 2168 >>> g.removeDecisionFromZone('A', 'level0') 2169 False 2170 >>> g.removeDecisionFromZone('B', 'level0') 2171 True 2172 >>> g.zoneParents(1) 2173 {'level2'} 2174 >>> g.removeDecisionFromZone('B', 'level0') 2175 False 2176 >>> g.removeDecisionFromZone('B', 'level2') 2177 True 2178 >>> g.zoneParents(1) 2179 set() 2180 """ 2181 dID = self.resolveDecision(decision) 2182 2183 if zone not in self.zones: 2184 return False 2185 2186 info = self.zones[zone] 2187 if dID not in info.contents: 2188 return False 2189 else: 2190 info.contents.remove(dID) 2191 try: 2192 self.nodes[dID]['zones'].remove(zone) 2193 except KeyError: 2194 pass 2195 return True
Removes a decision from a zone if it had been in it, returning True if that decision had been in that zone, and False if it was not in that zone, including if that zone didn't exist.
Note that this only removes a decision from direct zone membership. If the decision is a member of one or more zones which are (directly or indirectly) sub-zones of the target zone, the decision will remain in those zones, and will still be indirectly part of the target zone afterwards.
Examples:
>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.createZone('level0', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level1', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level2', 2)
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level3', 3)
ZoneInfo(level=3, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone('A', 'level0')
>>> g.addDecisionToZone('B', 'level0')
>>> g.addZoneToZone('level0', 'level1')
>>> g.addZoneToZone('level1', 'level2')
>>> g.addZoneToZone('level2', 'level3')
>>> g.addDecisionToZone('B', 'level2') # Direct w/ skips
>>> g.removeDecisionFromZone('A', 'level1')
False
>>> g.zoneParents(0)
{'level0'}
>>> g.removeDecisionFromZone('A', 'level0')
True
>>> g.zoneParents(0)
set()
>>> g.removeDecisionFromZone('A', 'level0')
False
>>> g.removeDecisionFromZone('B', 'level0')
True
>>> g.zoneParents(1)
{'level2'}
>>> g.removeDecisionFromZone('B', 'level0')
False
>>> g.removeDecisionFromZone('B', 'level2')
True
>>> g.zoneParents(1)
set()
2197 def addZoneToZone( 2198 self, 2199 addIt: base.Zone, 2200 addTo: base.Zone 2201 ) -> None: 2202 """ 2203 Adds a zone to another zone. The `addIt` one must be at a 2204 strictly lower level than the `addTo` zone, or an 2205 `InvalidLevelError` will be raised. 2206 2207 If the zone to be added didn't already exist, it is created at 2208 one level below the target zone. Similarly, if the zone being 2209 added to didn't already exist, it is created at one level above 2210 the target zone. If neither existed, a `MissingZoneError` will 2211 be raised. 2212 2213 For example: 2214 2215 >>> d = DecisionGraph() 2216 >>> d.addDecision('A') 2217 0 2218 >>> d.addDecision('B') 2219 1 2220 >>> d.addDecision('C') 2221 2 2222 >>> d.createZone('Z', 0) 2223 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2224 annotations=[]) 2225 >>> d.addDecisionToZone('A', 'Z') 2226 >>> d.addDecisionToZone('B', 'Z') 2227 >>> d.getZoneInfo('Z') 2228 ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\ 2229 annotations=[]) 2230 >>> d.createZone('Z2', 0) 2231 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2232 annotations=[]) 2233 >>> d.addDecisionToZone('B', 'Z2') 2234 >>> d.addDecisionToZone('C', 'Z2') 2235 >>> d.getZoneInfo('Z2') 2236 ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\ 2237 annotations=[]) 2238 >>> d.createZone('l1Z', 1) 2239 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2240 annotations=[]) 2241 >>> d.createZone('l2Z', 2) 2242 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2243 annotations=[]) 2244 >>> d.addZoneToZone('Z', 'l1Z') 2245 >>> d.getZoneInfo('Z') 2246 ZoneInfo(level=0, parents={'l1Z'}, contents={0, 1}, tags={},\ 2247 annotations=[]) 2248 >>> d.getZoneInfo('l1Z') 2249 ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\ 2250 annotations=[]) 2251 >>> d.addZoneToZone('l1Z', 'l2Z') 2252 >>> d.getZoneInfo('l1Z') 2253 ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={},\ 2254 annotations=[]) 2255 >>> d.getZoneInfo('l2Z') 2256 ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={},\ 2257 annotations=[]) 2258 >>> d.addZoneToZone('Z2', 'l2Z') 2259 >>> d.getZoneInfo('Z2') 2260 ZoneInfo(level=0, parents={'l2Z'}, contents={1, 2}, tags={},\ 2261 annotations=[]) 2262 >>> l2i = d.getZoneInfo('l2Z') 2263 >>> l2i.level 2264 2 2265 >>> l2i.parents 2266 set() 2267 >>> sorted(l2i.contents) 2268 ['Z2', 'l1Z'] 2269 >>> d.addZoneToZone('NZ', 'NZ2') 2270 Traceback (most recent call last): 2271 ... 2272 exploration.core.MissingZoneError... 2273 >>> d.addZoneToZone('Z', 'l1Z2') 2274 >>> zi = d.getZoneInfo('Z') 2275 >>> zi.level 2276 0 2277 >>> sorted(zi.parents) 2278 ['l1Z', 'l1Z2'] 2279 >>> sorted(zi.contents) 2280 [0, 1] 2281 >>> d.getZoneInfo('l1Z2') 2282 ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\ 2283 annotations=[]) 2284 >>> d.addZoneToZone('NZ', 'l1Z') 2285 >>> d.getZoneInfo('NZ') 2286 ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={},\ 2287 annotations=[]) 2288 >>> zi = d.getZoneInfo('l1Z') 2289 >>> zi.level 2290 1 2291 >>> zi.parents 2292 {'l2Z'} 2293 >>> sorted(zi.contents) 2294 ['NZ', 'Z'] 2295 """ 2296 # Create one or the other (but not both) if they're missing 2297 addInfo = self.getZoneInfo(addIt) 2298 toInfo = self.getZoneInfo(addTo) 2299 if addInfo is None and toInfo is None: 2300 raise MissingZoneError( 2301 f"Cannot add zone {addIt!r} to zone {addTo!r}: neither" 2302 f" exists already." 2303 ) 2304 2305 # Create missing addIt 2306 elif addInfo is None: 2307 toInfo = cast(base.ZoneInfo, toInfo) 2308 newLevel = toInfo.level - 1 2309 if newLevel < 0: 2310 raise InvalidLevelError( 2311 f"Zone {addTo!r} is at level {toInfo.level} and so" 2312 f" a new zone cannot be added underneath it." 2313 ) 2314 addInfo = self.createZone(addIt, newLevel) 2315 2316 # Create missing addTo 2317 elif toInfo is None: 2318 addInfo = cast(base.ZoneInfo, addInfo) 2319 newLevel = addInfo.level + 1 2320 if newLevel < 0: 2321 raise InvalidLevelError( 2322 f"Zone {addIt!r} is at level {addInfo.level} (!!!)" 2323 f" and so a new zone cannot be added above it." 2324 ) 2325 toInfo = self.createZone(addTo, newLevel) 2326 2327 # Now both addInfo and toInfo are defined 2328 if addInfo.level >= toInfo.level: 2329 raise InvalidLevelError( 2330 f"Cannot add zone {addIt!r} at level {addInfo.level}" 2331 f" to zone {addTo!r} at level {toInfo.level}: zones can" 2332 f" only contain zones of lower levels." 2333 ) 2334 2335 # Now both addInfo and toInfo are defined 2336 toInfo.contents.add(addIt) 2337 addInfo.parents.add(addTo)
Adds a zone to another zone. The addIt
one must be at a
strictly lower level than the addTo
zone, or an
InvalidLevelError
will be raised.
If the zone to be added didn't already exist, it is created at
one level below the target zone. Similarly, if the zone being
added to didn't already exist, it is created at one level above
the target zone. If neither existed, a MissingZoneError
will
be raised.
For example:
>>> d = DecisionGraph()
>>> d.addDecision('A')
0
>>> d.addDecision('B')
1
>>> d.addDecision('C')
2
>>> d.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addDecisionToZone('A', 'Z')
>>> d.addDecisionToZone('B', 'Z')
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={}, annotations=[])
>>> d.createZone('Z2', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addDecisionToZone('B', 'Z2')
>>> d.addDecisionToZone('C', 'Z2')
>>> d.getZoneInfo('Z2')
ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={}, annotations=[])
>>> d.createZone('l1Z', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('l2Z', 2)
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addZoneToZone('Z', 'l1Z')
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents={'l1Z'}, contents={0, 1}, tags={}, annotations=[])
>>> d.getZoneInfo('l1Z')
ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={}, annotations=[])
>>> d.addZoneToZone('l1Z', 'l2Z')
>>> d.getZoneInfo('l1Z')
ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={}, annotations=[])
>>> d.getZoneInfo('l2Z')
ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={}, annotations=[])
>>> d.addZoneToZone('Z2', 'l2Z')
>>> d.getZoneInfo('Z2')
ZoneInfo(level=0, parents={'l2Z'}, contents={1, 2}, tags={}, annotations=[])
>>> l2i = d.getZoneInfo('l2Z')
>>> l2i.level
2
>>> l2i.parents
set()
>>> sorted(l2i.contents)
['Z2', 'l1Z']
>>> d.addZoneToZone('NZ', 'NZ2')
Traceback (most recent call last):
...
MissingZoneError...
>>> d.addZoneToZone('Z', 'l1Z2')
>>> zi = d.getZoneInfo('Z')
>>> zi.level
0
>>> sorted(zi.parents)
['l1Z', 'l1Z2']
>>> sorted(zi.contents)
[0, 1]
>>> d.getZoneInfo('l1Z2')
ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={}, annotations=[])
>>> d.addZoneToZone('NZ', 'l1Z')
>>> d.getZoneInfo('NZ')
ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={}, annotations=[])
>>> zi = d.getZoneInfo('l1Z')
>>> zi.level
1
>>> zi.parents
{'l2Z'}
>>> sorted(zi.contents)
['NZ', 'Z']
2339 def removeZoneFromZone( 2340 self, 2341 removeIt: base.Zone, 2342 removeFrom: base.Zone 2343 ) -> bool: 2344 """ 2345 Removes a zone from a zone if it had been in it, returning True 2346 if that zone had been in that zone, and False if it was not in 2347 that zone, including if either zone did not exist. 2348 2349 For example: 2350 2351 >>> d = DecisionGraph() 2352 >>> d.createZone('Z', 0) 2353 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2354 annotations=[]) 2355 >>> d.createZone('Z2', 0) 2356 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2357 annotations=[]) 2358 >>> d.createZone('l1Z', 1) 2359 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2360 annotations=[]) 2361 >>> d.createZone('l2Z', 2) 2362 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2363 annotations=[]) 2364 >>> d.addZoneToZone('Z', 'l1Z') 2365 >>> d.addZoneToZone('l1Z', 'l2Z') 2366 >>> d.getZoneInfo('Z') 2367 ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={},\ 2368 annotations=[]) 2369 >>> d.getZoneInfo('l1Z') 2370 ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={},\ 2371 annotations=[]) 2372 >>> d.getZoneInfo('l2Z') 2373 ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={},\ 2374 annotations=[]) 2375 >>> d.removeZoneFromZone('l1Z', 'l2Z') 2376 True 2377 >>> d.getZoneInfo('l1Z') 2378 ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\ 2379 annotations=[]) 2380 >>> d.getZoneInfo('l2Z') 2381 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2382 annotations=[]) 2383 >>> d.removeZoneFromZone('Z', 'l1Z') 2384 True 2385 >>> d.getZoneInfo('Z') 2386 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2387 annotations=[]) 2388 >>> d.getZoneInfo('l1Z') 2389 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2390 annotations=[]) 2391 >>> d.removeZoneFromZone('Z', 'l1Z') 2392 False 2393 >>> d.removeZoneFromZone('Z', 'madeup') 2394 False 2395 >>> d.removeZoneFromZone('nope', 'madeup') 2396 False 2397 >>> d.removeZoneFromZone('nope', 'l1Z') 2398 False 2399 """ 2400 remInfo = self.getZoneInfo(removeIt) 2401 fromInfo = self.getZoneInfo(removeFrom) 2402 2403 if remInfo is None or fromInfo is None: 2404 return False 2405 2406 if removeIt not in fromInfo.contents: 2407 return False 2408 2409 remInfo.parents.remove(removeFrom) 2410 fromInfo.contents.remove(removeIt) 2411 return True
Removes a zone from a zone if it had been in it, returning True if that zone had been in that zone, and False if it was not in that zone, including if either zone did not exist.
For example:
>>> d = DecisionGraph()
>>> d.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('Z2', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('l1Z', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('l2Z', 2)
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addZoneToZone('Z', 'l1Z')
>>> d.addZoneToZone('l1Z', 'l2Z')
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={}, annotations=[])
>>> d.getZoneInfo('l1Z')
ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={}, annotations=[])
>>> d.getZoneInfo('l2Z')
ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={}, annotations=[])
>>> d.removeZoneFromZone('l1Z', 'l2Z')
True
>>> d.getZoneInfo('l1Z')
ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={}, annotations=[])
>>> d.getZoneInfo('l2Z')
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.removeZoneFromZone('Z', 'l1Z')
True
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.getZoneInfo('l1Z')
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.removeZoneFromZone('Z', 'l1Z')
False
>>> d.removeZoneFromZone('Z', 'madeup')
False
>>> d.removeZoneFromZone('nope', 'madeup')
False
>>> d.removeZoneFromZone('nope', 'l1Z')
False
2413 def decisionsInZone(self, zone: base.Zone) -> Set[base.DecisionID]: 2414 """ 2415 Returns a set of all decisions included directly in the given 2416 zone, not counting decisions included via intermediate 2417 sub-zones (see `allDecisionsInZone` to include those). 2418 2419 Raises a `MissingZoneError` if the specified zone does not 2420 exist. 2421 2422 The returned set is a copy, not a live editable set. 2423 2424 For example: 2425 2426 >>> d = DecisionGraph() 2427 >>> d.addDecision('A') 2428 0 2429 >>> d.addDecision('B') 2430 1 2431 >>> d.addDecision('C') 2432 2 2433 >>> d.createZone('Z', 0) 2434 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2435 annotations=[]) 2436 >>> d.addDecisionToZone('A', 'Z') 2437 >>> d.addDecisionToZone('B', 'Z') 2438 >>> d.getZoneInfo('Z') 2439 ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\ 2440 annotations=[]) 2441 >>> d.decisionsInZone('Z') 2442 {0, 1} 2443 >>> d.createZone('Z2', 0) 2444 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2445 annotations=[]) 2446 >>> d.addDecisionToZone('B', 'Z2') 2447 >>> d.addDecisionToZone('C', 'Z2') 2448 >>> d.getZoneInfo('Z2') 2449 ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\ 2450 annotations=[]) 2451 >>> d.decisionsInZone('Z') 2452 {0, 1} 2453 >>> d.decisionsInZone('Z2') 2454 {1, 2} 2455 >>> d.createZone('l1Z', 1) 2456 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2457 annotations=[]) 2458 >>> d.addZoneToZone('Z', 'l1Z') 2459 >>> d.decisionsInZone('Z') 2460 {0, 1} 2461 >>> d.decisionsInZone('l1Z') 2462 set() 2463 >>> d.decisionsInZone('madeup') 2464 Traceback (most recent call last): 2465 ... 2466 exploration.core.MissingZoneError... 2467 >>> zDec = d.decisionsInZone('Z') 2468 >>> zDec.add(2) # won't affect the zone 2469 >>> zDec 2470 {0, 1, 2} 2471 >>> d.decisionsInZone('Z') 2472 {0, 1} 2473 """ 2474 info = self.getZoneInfo(zone) 2475 if info is None: 2476 raise MissingZoneError(f"Zone {zone!r} does not exist.") 2477 2478 # Everything that's not a zone must be a decision 2479 return { 2480 item 2481 for item in info.contents 2482 if isinstance(item, base.DecisionID) 2483 }
Returns a set of all decisions included directly in the given
zone, not counting decisions included via intermediate
sub-zones (see allDecisionsInZone
to include those).
Raises a MissingZoneError
if the specified zone does not
exist.
The returned set is a copy, not a live editable set.
For example:
>>> d = DecisionGraph()
>>> d.addDecision('A')
0
>>> d.addDecision('B')
1
>>> d.addDecision('C')
2
>>> d.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addDecisionToZone('A', 'Z')
>>> d.addDecisionToZone('B', 'Z')
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={}, annotations=[])
>>> d.decisionsInZone('Z')
{0, 1}
>>> d.createZone('Z2', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addDecisionToZone('B', 'Z2')
>>> d.addDecisionToZone('C', 'Z2')
>>> d.getZoneInfo('Z2')
ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={}, annotations=[])
>>> d.decisionsInZone('Z')
{0, 1}
>>> d.decisionsInZone('Z2')
{1, 2}
>>> d.createZone('l1Z', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addZoneToZone('Z', 'l1Z')
>>> d.decisionsInZone('Z')
{0, 1}
>>> d.decisionsInZone('l1Z')
set()
>>> d.decisionsInZone('madeup')
Traceback (most recent call last):
...
MissingZoneError...
>>> zDec = d.decisionsInZone('Z')
>>> zDec.add(2) # won't affect the zone
>>> zDec
{0, 1, 2}
>>> d.decisionsInZone('Z')
{0, 1}
2485 def subZones(self, zone: base.Zone) -> Set[base.Zone]: 2486 """ 2487 Returns the set of all immediate sub-zones of the given zone. 2488 Will be an empty set if there are no sub-zones; raises a 2489 `MissingZoneError` if the specified zone does not exit. 2490 2491 The returned set is a copy, not a live editable set. 2492 2493 For example: 2494 2495 >>> d = DecisionGraph() 2496 >>> d.createZone('Z', 0) 2497 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2498 annotations=[]) 2499 >>> d.subZones('Z') 2500 set() 2501 >>> d.createZone('l1Z', 1) 2502 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2503 annotations=[]) 2504 >>> d.addZoneToZone('Z', 'l1Z') 2505 >>> d.subZones('Z') 2506 set() 2507 >>> d.subZones('l1Z') 2508 {'Z'} 2509 >>> s = d.subZones('l1Z') 2510 >>> s.add('Q') # doesn't affect the zone 2511 >>> sorted(s) 2512 ['Q', 'Z'] 2513 >>> d.subZones('l1Z') 2514 {'Z'} 2515 >>> d.subZones('madeup') 2516 Traceback (most recent call last): 2517 ... 2518 exploration.core.MissingZoneError... 2519 """ 2520 info = self.getZoneInfo(zone) 2521 if info is None: 2522 raise MissingZoneError(f"Zone {zone!r} does not exist.") 2523 2524 # Sub-zones will appear in self.zones 2525 return { 2526 item 2527 for item in info.contents 2528 if isinstance(item, base.Zone) 2529 }
Returns the set of all immediate sub-zones of the given zone.
Will be an empty set if there are no sub-zones; raises a
MissingZoneError
if the specified zone does not exit.
The returned set is a copy, not a live editable set.
For example:
>>> d = DecisionGraph()
>>> d.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.subZones('Z')
set()
>>> d.createZone('l1Z', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addZoneToZone('Z', 'l1Z')
>>> d.subZones('Z')
set()
>>> d.subZones('l1Z')
{'Z'}
>>> s = d.subZones('l1Z')
>>> s.add('Q') # doesn't affect the zone
>>> sorted(s)
['Q', 'Z']
>>> d.subZones('l1Z')
{'Z'}
>>> d.subZones('madeup')
Traceback (most recent call last):
...
MissingZoneError...
2531 def allDecisionsInZone(self, zone: base.Zone) -> Set[base.DecisionID]: 2532 """ 2533 Returns a set containing all decisions in the given zone, 2534 including those included via sub-zones. 2535 2536 Raises a `MissingZoneError` if the specified zone does not 2537 exist.` 2538 2539 For example: 2540 2541 >>> d = DecisionGraph() 2542 >>> d.addDecision('A') 2543 0 2544 >>> d.addDecision('B') 2545 1 2546 >>> d.addDecision('C') 2547 2 2548 >>> d.createZone('Z', 0) 2549 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2550 annotations=[]) 2551 >>> d.addDecisionToZone('A', 'Z') 2552 >>> d.addDecisionToZone('B', 'Z') 2553 >>> d.getZoneInfo('Z') 2554 ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\ 2555 annotations=[]) 2556 >>> d.decisionsInZone('Z') 2557 {0, 1} 2558 >>> d.allDecisionsInZone('Z') 2559 {0, 1} 2560 >>> d.createZone('Z2', 0) 2561 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2562 annotations=[]) 2563 >>> d.addDecisionToZone('B', 'Z2') 2564 >>> d.addDecisionToZone('C', 'Z2') 2565 >>> d.getZoneInfo('Z2') 2566 ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\ 2567 annotations=[]) 2568 >>> d.decisionsInZone('Z') 2569 {0, 1} 2570 >>> d.decisionsInZone('Z2') 2571 {1, 2} 2572 >>> d.allDecisionsInZone('Z2') 2573 {1, 2} 2574 >>> d.createZone('l1Z', 1) 2575 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2576 annotations=[]) 2577 >>> d.createZone('l2Z', 2) 2578 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2579 annotations=[]) 2580 >>> d.addZoneToZone('Z', 'l1Z') 2581 >>> d.addZoneToZone('l1Z', 'l2Z') 2582 >>> d.addZoneToZone('Z2', 'l2Z') 2583 >>> d.decisionsInZone('Z') 2584 {0, 1} 2585 >>> d.decisionsInZone('Z2') 2586 {1, 2} 2587 >>> d.decisionsInZone('l1Z') 2588 set() 2589 >>> d.allDecisionsInZone('l1Z') 2590 {0, 1} 2591 >>> d.allDecisionsInZone('l2Z') 2592 {0, 1, 2} 2593 """ 2594 result: Set[base.DecisionID] = set() 2595 info = self.getZoneInfo(zone) 2596 if info is None: 2597 raise MissingZoneError(f"Zone {zone!r} does not exist.") 2598 2599 for item in info.contents: 2600 if isinstance(item, base.Zone): 2601 # This can't be an error because of the condition above 2602 result |= self.allDecisionsInZone(item) 2603 else: # it's a decision 2604 result.add(item) 2605 2606 return result
Returns a set containing all decisions in the given zone, including those included via sub-zones.
Raises a MissingZoneError
if the specified zone does not
exist.`
For example:
>>> d = DecisionGraph()
>>> d.addDecision('A')
0
>>> d.addDecision('B')
1
>>> d.addDecision('C')
2
>>> d.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addDecisionToZone('A', 'Z')
>>> d.addDecisionToZone('B', 'Z')
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={}, annotations=[])
>>> d.decisionsInZone('Z')
{0, 1}
>>> d.allDecisionsInZone('Z')
{0, 1}
>>> d.createZone('Z2', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addDecisionToZone('B', 'Z2')
>>> d.addDecisionToZone('C', 'Z2')
>>> d.getZoneInfo('Z2')
ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={}, annotations=[])
>>> d.decisionsInZone('Z')
{0, 1}
>>> d.decisionsInZone('Z2')
{1, 2}
>>> d.allDecisionsInZone('Z2')
{1, 2}
>>> d.createZone('l1Z', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('l2Z', 2)
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addZoneToZone('Z', 'l1Z')
>>> d.addZoneToZone('l1Z', 'l2Z')
>>> d.addZoneToZone('Z2', 'l2Z')
>>> d.decisionsInZone('Z')
{0, 1}
>>> d.decisionsInZone('Z2')
{1, 2}
>>> d.decisionsInZone('l1Z')
set()
>>> d.allDecisionsInZone('l1Z')
{0, 1}
>>> d.allDecisionsInZone('l2Z')
{0, 1, 2}
2608 def zoneHierarchyLevel(self, zone: base.Zone) -> int: 2609 """ 2610 Returns the hierarchy level of the given zone, as stored in its 2611 zone info. 2612 2613 Raises a `MissingZoneError` if the specified zone does not 2614 exist. 2615 2616 For example: 2617 2618 >>> d = DecisionGraph() 2619 >>> d.createZone('Z', 0) 2620 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2621 annotations=[]) 2622 >>> d.createZone('l1Z', 1) 2623 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2624 annotations=[]) 2625 >>> d.createZone('l5Z', 5) 2626 ZoneInfo(level=5, parents=set(), contents=set(), tags={},\ 2627 annotations=[]) 2628 >>> d.zoneHierarchyLevel('Z') 2629 0 2630 >>> d.zoneHierarchyLevel('l1Z') 2631 1 2632 >>> d.zoneHierarchyLevel('l5Z') 2633 5 2634 >>> d.zoneHierarchyLevel('madeup') 2635 Traceback (most recent call last): 2636 ... 2637 exploration.core.MissingZoneError... 2638 """ 2639 info = self.getZoneInfo(zone) 2640 if info is None: 2641 raise MissingZoneError(f"Zone {zone!r} dose not exist.") 2642 2643 return info.level
Returns the hierarchy level of the given zone, as stored in its zone info.
Raises a MissingZoneError
if the specified zone does not
exist.
For example:
>>> d = DecisionGraph()
>>> d.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('l1Z', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('l5Z', 5)
ZoneInfo(level=5, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.zoneHierarchyLevel('Z')
0
>>> d.zoneHierarchyLevel('l1Z')
1
>>> d.zoneHierarchyLevel('l5Z')
5
>>> d.zoneHierarchyLevel('madeup')
Traceback (most recent call last):
...
MissingZoneError...
2645 def zoneParents( 2646 self, 2647 zoneOrDecision: Union[base.Zone, base.DecisionID] 2648 ) -> Set[base.Zone]: 2649 """ 2650 Returns the set of all zones which directly contain the target 2651 zone or decision. 2652 2653 Raises a `MissingDecisionError` if the target is neither a valid 2654 zone nor a valid decision. 2655 2656 Returns a copy, not a live editable set. 2657 2658 Example: 2659 2660 >>> g = DecisionGraph() 2661 >>> g.addDecision('A') 2662 0 2663 >>> g.addDecision('B') 2664 1 2665 >>> g.createZone('level0', 0) 2666 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2667 annotations=[]) 2668 >>> g.createZone('level1', 1) 2669 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2670 annotations=[]) 2671 >>> g.createZone('level2', 2) 2672 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2673 annotations=[]) 2674 >>> g.createZone('level3', 3) 2675 ZoneInfo(level=3, parents=set(), contents=set(), tags={},\ 2676 annotations=[]) 2677 >>> g.addDecisionToZone('A', 'level0') 2678 >>> g.addDecisionToZone('B', 'level0') 2679 >>> g.addZoneToZone('level0', 'level1') 2680 >>> g.addZoneToZone('level1', 'level2') 2681 >>> g.addZoneToZone('level2', 'level3') 2682 >>> g.addDecisionToZone('B', 'level2') # Direct w/ skips 2683 >>> sorted(g.zoneParents(0)) 2684 ['level0'] 2685 >>> sorted(g.zoneParents(1)) 2686 ['level0', 'level2'] 2687 """ 2688 if zoneOrDecision in self.zones: 2689 zoneOrDecision = cast(base.Zone, zoneOrDecision) 2690 info = cast(base.ZoneInfo, self.getZoneInfo(zoneOrDecision)) 2691 return copy.copy(info.parents) 2692 elif zoneOrDecision in self: 2693 return self.nodes[zoneOrDecision].get('zones', set()) 2694 else: 2695 raise MissingDecisionError( 2696 f"Name {zoneOrDecision!r} is neither a valid zone nor a" 2697 f" valid decision." 2698 )
Returns the set of all zones which directly contain the target zone or decision.
Raises a MissingDecisionError
if the target is neither a valid
zone nor a valid decision.
Returns a copy, not a live editable set.
Example:
>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.createZone('level0', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level1', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level2', 2)
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level3', 3)
ZoneInfo(level=3, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone('A', 'level0')
>>> g.addDecisionToZone('B', 'level0')
>>> g.addZoneToZone('level0', 'level1')
>>> g.addZoneToZone('level1', 'level2')
>>> g.addZoneToZone('level2', 'level3')
>>> g.addDecisionToZone('B', 'level2') # Direct w/ skips
>>> sorted(g.zoneParents(0))
['level0']
>>> sorted(g.zoneParents(1))
['level0', 'level2']
2700 def zoneAncestors( 2701 self, 2702 zoneOrDecision: Union[base.Zone, base.DecisionID], 2703 exclude: Set[base.Zone] = set() 2704 ) -> Set[base.Zone]: 2705 """ 2706 Returns the set of zones which contain the target zone or 2707 decision, either directly or indirectly. The target is not 2708 included in the set. 2709 2710 Any ones listed in the `exclude` set are also excluded, as are 2711 any of their ancestors which are not also ancestors of the 2712 target zone via another path of inclusion. 2713 2714 Raises a `MissingDecisionError` if the target is nether a valid 2715 zone nor a valid decision. 2716 2717 Example: 2718 2719 >>> g = DecisionGraph() 2720 >>> g.addDecision('A') 2721 0 2722 >>> g.addDecision('B') 2723 1 2724 >>> g.createZone('level0', 0) 2725 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2726 annotations=[]) 2727 >>> g.createZone('level1', 1) 2728 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2729 annotations=[]) 2730 >>> g.createZone('level2', 2) 2731 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2732 annotations=[]) 2733 >>> g.createZone('level3', 3) 2734 ZoneInfo(level=3, parents=set(), contents=set(), tags={},\ 2735 annotations=[]) 2736 >>> g.addDecisionToZone('A', 'level0') 2737 >>> g.addDecisionToZone('B', 'level0') 2738 >>> g.addZoneToZone('level0', 'level1') 2739 >>> g.addZoneToZone('level1', 'level2') 2740 >>> g.addZoneToZone('level2', 'level3') 2741 >>> g.addDecisionToZone('B', 'level2') # Direct w/ skips 2742 >>> sorted(g.zoneAncestors(0)) 2743 ['level0', 'level1', 'level2', 'level3'] 2744 >>> sorted(g.zoneAncestors(1)) 2745 ['level0', 'level1', 'level2', 'level3'] 2746 >>> sorted(g.zoneParents(0)) 2747 ['level0'] 2748 >>> sorted(g.zoneParents(1)) 2749 ['level0', 'level2'] 2750 """ 2751 # Copy is important here! 2752 result = set(self.zoneParents(zoneOrDecision)) 2753 result -= exclude 2754 for parent in copy.copy(result): 2755 # Recursively dig up ancestors, but exclude 2756 # results-so-far to avoid re-enumerating when there are 2757 # multiple braided inclusion paths. 2758 result |= self.zoneAncestors(parent, result | exclude) 2759 2760 return result
Returns the set of zones which contain the target zone or decision, either directly or indirectly. The target is not included in the set.
Any ones listed in the exclude
set are also excluded, as are
any of their ancestors which are not also ancestors of the
target zone via another path of inclusion.
Raises a MissingDecisionError
if the target is nether a valid
zone nor a valid decision.
Example:
>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.createZone('level0', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level1', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level2', 2)
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level3', 3)
ZoneInfo(level=3, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone('A', 'level0')
>>> g.addDecisionToZone('B', 'level0')
>>> g.addZoneToZone('level0', 'level1')
>>> g.addZoneToZone('level1', 'level2')
>>> g.addZoneToZone('level2', 'level3')
>>> g.addDecisionToZone('B', 'level2') # Direct w/ skips
>>> sorted(g.zoneAncestors(0))
['level0', 'level1', 'level2', 'level3']
>>> sorted(g.zoneAncestors(1))
['level0', 'level1', 'level2', 'level3']
>>> sorted(g.zoneParents(0))
['level0']
>>> sorted(g.zoneParents(1))
['level0', 'level2']
2762 def zoneEdges(self, zone: base.Zone) -> Optional[ 2763 Tuple[ 2764 Set[Tuple[base.DecisionID, base.Transition]], 2765 Set[Tuple[base.DecisionID, base.Transition]] 2766 ] 2767 ]: 2768 """ 2769 Given a zone to look at, finds all of the transitions which go 2770 out of and into that zone, ignoring internal transitions between 2771 decisions in the zone. This includes all decisions in sub-zones. 2772 The return value is a pair of sets for outgoing and then 2773 incoming transitions, where each transition is specified as a 2774 (sourceID, transitionName) pair. 2775 2776 Returns `None` if the target zone isn't yet fully defined. 2777 2778 Note that this takes time proportional to *all* edges plus *all* 2779 nodes in the graph no matter how large or small the zone in 2780 question is. 2781 2782 >>> g = DecisionGraph() 2783 >>> g.addDecision('A') 2784 0 2785 >>> g.addDecision('B') 2786 1 2787 >>> g.addDecision('C') 2788 2 2789 >>> g.addDecision('D') 2790 3 2791 >>> g.addTransition('A', 'up', 'B', 'down') 2792 >>> g.addTransition('B', 'right', 'C', 'left') 2793 >>> g.addTransition('C', 'down', 'D', 'up') 2794 >>> g.addTransition('D', 'left', 'A', 'right') 2795 >>> g.addTransition('A', 'tunnel', 'C', 'tunnel') 2796 >>> g.createZone('Z', 0) 2797 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2798 annotations=[]) 2799 >>> g.createZone('ZZ', 1) 2800 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2801 annotations=[]) 2802 >>> g.addZoneToZone('Z', 'ZZ') 2803 >>> g.addDecisionToZone('A', 'Z') 2804 >>> g.addDecisionToZone('B', 'Z') 2805 >>> g.addDecisionToZone('D', 'ZZ') 2806 >>> outgoing, incoming = g.zoneEdges('Z') # TODO: Sort for testing 2807 >>> sorted(outgoing) 2808 [(0, 'right'), (0, 'tunnel'), (1, 'right')] 2809 >>> sorted(incoming) 2810 [(2, 'left'), (2, 'tunnel'), (3, 'left')] 2811 >>> outgoing, incoming = g.zoneEdges('ZZ') 2812 >>> sorted(outgoing) 2813 [(0, 'tunnel'), (1, 'right'), (3, 'up')] 2814 >>> sorted(incoming) 2815 [(2, 'down'), (2, 'left'), (2, 'tunnel')] 2816 >>> g.zoneEdges('madeup') is None 2817 True 2818 """ 2819 # Find the interior nodes 2820 try: 2821 interior = self.allDecisionsInZone(zone) 2822 except MissingZoneError: 2823 return None 2824 2825 # Set up our result 2826 results: Tuple[ 2827 Set[Tuple[base.DecisionID, base.Transition]], 2828 Set[Tuple[base.DecisionID, base.Transition]] 2829 ] = (set(), set()) 2830 2831 # Because finding incoming edges requires searching the entire 2832 # graph anyways, it's more efficient to just consider each edge 2833 # once. 2834 for fromDecision in self: 2835 fromThere = self[fromDecision] 2836 for toDecision in fromThere: 2837 for transition in fromThere[toDecision]: 2838 sourceIn = fromDecision in interior 2839 destIn = toDecision in interior 2840 if sourceIn and not destIn: 2841 results[0].add((fromDecision, transition)) 2842 elif destIn and not sourceIn: 2843 results[1].add((fromDecision, transition)) 2844 2845 return results
Given a zone to look at, finds all of the transitions which go out of and into that zone, ignoring internal transitions between decisions in the zone. This includes all decisions in sub-zones. The return value is a pair of sets for outgoing and then incoming transitions, where each transition is specified as a (sourceID, transitionName) pair.
Returns None
if the target zone isn't yet fully defined.
Note that this takes time proportional to all edges plus all nodes in the graph no matter how large or small the zone in question is.
>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addDecision('C')
2
>>> g.addDecision('D')
3
>>> g.addTransition('A', 'up', 'B', 'down')
>>> g.addTransition('B', 'right', 'C', 'left')
>>> g.addTransition('C', 'down', 'D', 'up')
>>> g.addTransition('D', 'left', 'A', 'right')
>>> g.addTransition('A', 'tunnel', 'C', 'tunnel')
>>> g.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('ZZ', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addZoneToZone('Z', 'ZZ')
>>> g.addDecisionToZone('A', 'Z')
>>> g.addDecisionToZone('B', 'Z')
>>> g.addDecisionToZone('D', 'ZZ')
>>> outgoing, incoming = g.zoneEdges('Z') # TODO: Sort for testing
>>> sorted(outgoing)
[(0, 'right'), (0, 'tunnel'), (1, 'right')]
>>> sorted(incoming)
[(2, 'left'), (2, 'tunnel'), (3, 'left')]
>>> outgoing, incoming = g.zoneEdges('ZZ')
>>> sorted(outgoing)
[(0, 'tunnel'), (1, 'right'), (3, 'up')]
>>> sorted(incoming)
[(2, 'down'), (2, 'left'), (2, 'tunnel')]
>>> g.zoneEdges('madeup') is None
True
2847 def replaceZonesInHierarchy( 2848 self, 2849 target: base.AnyDecisionSpecifier, 2850 zone: base.Zone, 2851 level: int 2852 ) -> None: 2853 """ 2854 This method replaces one or more zones which contain the 2855 specified `target` decision with a specific zone, at a specific 2856 level in the zone hierarchy (see `zoneHierarchyLevel`). If the 2857 named zone doesn't yet exist, it will be created. 2858 2859 To do this, it looks at all zones which contain the target 2860 decision directly or indirectly (see `zoneAncestors`) and which 2861 are at the specified level. 2862 2863 - Any direct children of those zones which are ancestors of the 2864 target decision are removed from those zones and placed into 2865 the new zone instead, regardless of their levels. Indirect 2866 children are not affected (except perhaps indirectly via 2867 their parents' ancestors changing). 2868 - The new zone is placed into every direct parent of those 2869 zones, regardless of their levels (those parents are by 2870 definition all ancestors of the target decision). 2871 - If there were no zones at the target level, every zone at the 2872 next level down which is an ancestor of the target decision 2873 (or just that decision if the level is 0) is placed into the 2874 new zone as a direct child (and is removed from any previous 2875 parents it had). In this case, the new zone will also be 2876 added as a sub-zone to every ancestor of the target decision 2877 at the level above the specified level, if there are any. 2878 * In this case, if there are no zones at the level below the 2879 specified level, the highest level of zones smaller than 2880 that is treated as the level below, down to targeting 2881 the decision itself. 2882 * Similarly, if there are no zones at the level above the 2883 specified level but there are zones at a higher level, 2884 the new zone will be added to each of the zones in the 2885 lowest level above the target level that has zones in it. 2886 2887 A `MissingDecisionError` will be raised if the specified 2888 decision is not valid, or if the decision is left as default but 2889 there is no current decision in the exploration. 2890 2891 An `InvalidLevelError` will be raised if the level is less than 2892 zero. 2893 2894 Example: 2895 2896 >>> g = DecisionGraph() 2897 >>> g.addDecision('decision') 2898 0 2899 >>> g.addDecision('alternate') 2900 1 2901 >>> g.createZone('zone0', 0) 2902 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2903 annotations=[]) 2904 >>> g.createZone('zone1', 1) 2905 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2906 annotations=[]) 2907 >>> g.createZone('zone2.1', 2) 2908 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2909 annotations=[]) 2910 >>> g.createZone('zone2.2', 2) 2911 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2912 annotations=[]) 2913 >>> g.createZone('zone3', 3) 2914 ZoneInfo(level=3, parents=set(), contents=set(), tags={},\ 2915 annotations=[]) 2916 >>> g.addDecisionToZone('decision', 'zone0') 2917 >>> g.addDecisionToZone('alternate', 'zone0') 2918 >>> g.addZoneToZone('zone0', 'zone1') 2919 >>> g.addZoneToZone('zone1', 'zone2.1') 2920 >>> g.addZoneToZone('zone1', 'zone2.2') 2921 >>> g.addZoneToZone('zone2.1', 'zone3') 2922 >>> g.addZoneToZone('zone2.2', 'zone3') 2923 >>> g.zoneHierarchyLevel('zone0') 2924 0 2925 >>> g.zoneHierarchyLevel('zone1') 2926 1 2927 >>> g.zoneHierarchyLevel('zone2.1') 2928 2 2929 >>> g.zoneHierarchyLevel('zone2.2') 2930 2 2931 >>> g.zoneHierarchyLevel('zone3') 2932 3 2933 >>> sorted(g.decisionsInZone('zone0')) 2934 [0, 1] 2935 >>> sorted(g.zoneAncestors('zone0')) 2936 ['zone1', 'zone2.1', 'zone2.2', 'zone3'] 2937 >>> g.subZones('zone1') 2938 {'zone0'} 2939 >>> g.zoneParents('zone0') 2940 {'zone1'} 2941 >>> g.replaceZonesInHierarchy('decision', 'new0', 0) 2942 >>> g.zoneParents('zone0') 2943 {'zone1'} 2944 >>> g.zoneParents('new0') 2945 {'zone1'} 2946 >>> sorted(g.zoneAncestors('zone0')) 2947 ['zone1', 'zone2.1', 'zone2.2', 'zone3'] 2948 >>> sorted(g.zoneAncestors('new0')) 2949 ['zone1', 'zone2.1', 'zone2.2', 'zone3'] 2950 >>> g.decisionsInZone('zone0') 2951 {1} 2952 >>> g.decisionsInZone('new0') 2953 {0} 2954 >>> sorted(g.subZones('zone1')) 2955 ['new0', 'zone0'] 2956 >>> g.zoneParents('new0') 2957 {'zone1'} 2958 >>> g.replaceZonesInHierarchy('decision', 'new1', 1) 2959 >>> sorted(g.zoneAncestors(0)) 2960 ['new0', 'new1', 'zone2.1', 'zone2.2', 'zone3'] 2961 >>> g.subZones('zone1') 2962 {'zone0'} 2963 >>> g.subZones('new1') 2964 {'new0'} 2965 >>> g.zoneParents('new0') 2966 {'new1'} 2967 >>> sorted(g.zoneParents('zone1')) 2968 ['zone2.1', 'zone2.2'] 2969 >>> sorted(g.zoneParents('new1')) 2970 ['zone2.1', 'zone2.2'] 2971 >>> g.zoneParents('zone2.1') 2972 {'zone3'} 2973 >>> g.zoneParents('zone2.2') 2974 {'zone3'} 2975 >>> sorted(g.subZones('zone2.1')) 2976 ['new1', 'zone1'] 2977 >>> sorted(g.subZones('zone2.2')) 2978 ['new1', 'zone1'] 2979 >>> sorted(g.allDecisionsInZone('zone2.1')) 2980 [0, 1] 2981 >>> sorted(g.allDecisionsInZone('zone2.2')) 2982 [0, 1] 2983 >>> g.replaceZonesInHierarchy('decision', 'new2', 2) 2984 >>> g.zoneParents('zone2.1') 2985 {'zone3'} 2986 >>> g.zoneParents('zone2.2') 2987 {'zone3'} 2988 >>> g.subZones('zone2.1') 2989 {'zone1'} 2990 >>> g.subZones('zone2.2') 2991 {'zone1'} 2992 >>> g.subZones('new2') 2993 {'new1'} 2994 >>> g.zoneParents('new2') 2995 {'zone3'} 2996 >>> g.allDecisionsInZone('zone2.1') 2997 {1} 2998 >>> g.allDecisionsInZone('zone2.2') 2999 {1} 3000 >>> g.allDecisionsInZone('new2') 3001 {0} 3002 >>> sorted(g.subZones('zone3')) 3003 ['new2', 'zone2.1', 'zone2.2'] 3004 >>> g.zoneParents('zone3') 3005 set() 3006 >>> sorted(g.allDecisionsInZone('zone3')) 3007 [0, 1] 3008 >>> g.replaceZonesInHierarchy('decision', 'new3', 3) 3009 >>> sorted(g.subZones('zone3')) 3010 ['zone2.1', 'zone2.2'] 3011 >>> g.subZones('new3') 3012 {'new2'} 3013 >>> g.zoneParents('zone3') 3014 set() 3015 >>> g.zoneParents('new3') 3016 set() 3017 >>> g.allDecisionsInZone('zone3') 3018 {1} 3019 >>> g.allDecisionsInZone('new3') 3020 {0} 3021 >>> g.replaceZonesInHierarchy('decision', 'new4', 5) 3022 >>> g.subZones('new4') 3023 {'new3'} 3024 >>> g.zoneHierarchyLevel('new4') 3025 5 3026 3027 Another example of level collapse when trying to replace a zone 3028 at a level above : 3029 3030 >>> g = DecisionGraph() 3031 >>> g.addDecision('A') 3032 0 3033 >>> g.addDecision('B') 3034 1 3035 >>> g.createZone('level0', 0) 3036 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 3037 annotations=[]) 3038 >>> g.createZone('level1', 1) 3039 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 3040 annotations=[]) 3041 >>> g.createZone('level2', 2) 3042 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 3043 annotations=[]) 3044 >>> g.createZone('level3', 3) 3045 ZoneInfo(level=3, parents=set(), contents=set(), tags={},\ 3046 annotations=[]) 3047 >>> g.addDecisionToZone('B', 'level0') 3048 >>> g.addZoneToZone('level0', 'level1') 3049 >>> g.addZoneToZone('level1', 'level2') 3050 >>> g.addZoneToZone('level2', 'level3') 3051 >>> g.addDecisionToZone('A', 'level3') # missing some zone levels 3052 >>> g.zoneHierarchyLevel('level3') 3053 3 3054 >>> g.replaceZonesInHierarchy('A', 'newFirst', 1) 3055 >>> g.zoneHierarchyLevel('newFirst') 3056 1 3057 >>> g.decisionsInZone('newFirst') 3058 {0} 3059 >>> g.decisionsInZone('level3') 3060 set() 3061 >>> sorted(g.allDecisionsInZone('level3')) 3062 [0, 1] 3063 >>> g.subZones('newFirst') 3064 set() 3065 >>> sorted(g.subZones('level3')) 3066 ['level2', 'newFirst'] 3067 >>> g.zoneParents('newFirst') 3068 {'level3'} 3069 >>> g.replaceZonesInHierarchy('A', 'newSecond', 2) 3070 >>> g.zoneHierarchyLevel('newSecond') 3071 2 3072 >>> g.decisionsInZone('newSecond') 3073 set() 3074 >>> g.allDecisionsInZone('newSecond') 3075 {0} 3076 >>> g.subZones('newSecond') 3077 {'newFirst'} 3078 >>> g.zoneParents('newSecond') 3079 {'level3'} 3080 >>> g.zoneParents('newFirst') 3081 {'newSecond'} 3082 >>> sorted(g.subZones('level3')) 3083 ['level2', 'newSecond'] 3084 """ 3085 tID = self.resolveDecision(target) 3086 3087 if level < 0: 3088 raise InvalidLevelError( 3089 f"Target level must be positive (got {level})." 3090 ) 3091 3092 info = self.getZoneInfo(zone) 3093 if info is None: 3094 info = self.createZone(zone, level) 3095 elif level != info.level: 3096 raise InvalidLevelError( 3097 f"Target level ({level}) does not match the level of" 3098 f" the target zone ({zone!r} at level {info.level})." 3099 ) 3100 3101 # Collect both parents & ancestors 3102 parents = self.zoneParents(tID) 3103 ancestors = set(self.zoneAncestors(tID)) 3104 3105 # Map from levels to sets of zones from the ancestors pool 3106 levelMap: Dict[int, Set[base.Zone]] = {} 3107 highest = -1 3108 for ancestor in ancestors: 3109 ancestorLevel = self.zoneHierarchyLevel(ancestor) 3110 levelMap.setdefault(ancestorLevel, set()).add(ancestor) 3111 if ancestorLevel > highest: 3112 highest = ancestorLevel 3113 3114 # Figure out if we have target zones to replace or not 3115 reparentDecision = False 3116 if level in levelMap: 3117 # If there are zones at the target level, 3118 targetZones = levelMap[level] 3119 3120 above = set() 3121 below = set() 3122 3123 for replaced in targetZones: 3124 above |= self.zoneParents(replaced) 3125 below |= self.subZones(replaced) 3126 if replaced in parents: 3127 reparentDecision = True 3128 3129 # Only ancestors should be reparented 3130 below &= ancestors 3131 3132 else: 3133 # Find levels w/ zones in them above + below 3134 levelBelow = level - 1 3135 levelAbove = level + 1 3136 below = levelMap.get(levelBelow, set()) 3137 above = levelMap.get(levelAbove, set()) 3138 3139 while len(below) == 0 and levelBelow > 0: 3140 levelBelow -= 1 3141 below = levelMap.get(levelBelow, set()) 3142 3143 if len(below) == 0: 3144 reparentDecision = True 3145 3146 while len(above) == 0 and levelAbove < highest: 3147 levelAbove += 1 3148 above = levelMap.get(levelAbove, set()) 3149 3150 # Handle re-parenting zones below 3151 for under in below: 3152 for parent in self.zoneParents(under): 3153 if parent in ancestors: 3154 self.removeZoneFromZone(under, parent) 3155 self.addZoneToZone(under, zone) 3156 3157 # Add this zone to each parent 3158 for parent in above: 3159 self.addZoneToZone(zone, parent) 3160 3161 # Re-parent the decision itself if necessary 3162 if reparentDecision: 3163 # (using set() here to avoid size-change-during-iteration) 3164 for parent in set(parents): 3165 self.removeDecisionFromZone(tID, parent) 3166 self.addDecisionToZone(tID, zone)
This method replaces one or more zones which contain the
specified target
decision with a specific zone, at a specific
level in the zone hierarchy (see zoneHierarchyLevel
). If the
named zone doesn't yet exist, it will be created.
To do this, it looks at all zones which contain the target
decision directly or indirectly (see zoneAncestors
) and which
are at the specified level.
- Any direct children of those zones which are ancestors of the target decision are removed from those zones and placed into the new zone instead, regardless of their levels. Indirect children are not affected (except perhaps indirectly via their parents' ancestors changing).
- The new zone is placed into every direct parent of those zones, regardless of their levels (those parents are by definition all ancestors of the target decision).
- If there were no zones at the target level, every zone at the
next level down which is an ancestor of the target decision
(or just that decision if the level is 0) is placed into the
new zone as a direct child (and is removed from any previous
parents it had). In this case, the new zone will also be
added as a sub-zone to every ancestor of the target decision
at the level above the specified level, if there are any.
- In this case, if there are no zones at the level below the specified level, the highest level of zones smaller than that is treated as the level below, down to targeting the decision itself.
- Similarly, if there are no zones at the level above the specified level but there are zones at a higher level, the new zone will be added to each of the zones in the lowest level above the target level that has zones in it.
A MissingDecisionError
will be raised if the specified
decision is not valid, or if the decision is left as default but
there is no current decision in the exploration.
An InvalidLevelError
will be raised if the level is less than
zero.
Example:
>>> g = DecisionGraph()
>>> g.addDecision('decision')
0
>>> g.addDecision('alternate')
1
>>> g.createZone('zone0', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('zone1', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('zone2.1', 2)
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('zone2.2', 2)
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('zone3', 3)
ZoneInfo(level=3, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone('decision', 'zone0')
>>> g.addDecisionToZone('alternate', 'zone0')
>>> g.addZoneToZone('zone0', 'zone1')
>>> g.addZoneToZone('zone1', 'zone2.1')
>>> g.addZoneToZone('zone1', 'zone2.2')
>>> g.addZoneToZone('zone2.1', 'zone3')
>>> g.addZoneToZone('zone2.2', 'zone3')
>>> g.zoneHierarchyLevel('zone0')
0
>>> g.zoneHierarchyLevel('zone1')
1
>>> g.zoneHierarchyLevel('zone2.1')
2
>>> g.zoneHierarchyLevel('zone2.2')
2
>>> g.zoneHierarchyLevel('zone3')
3
>>> sorted(g.decisionsInZone('zone0'))
[0, 1]
>>> sorted(g.zoneAncestors('zone0'))
['zone1', 'zone2.1', 'zone2.2', 'zone3']
>>> g.subZones('zone1')
{'zone0'}
>>> g.zoneParents('zone0')
{'zone1'}
>>> g.replaceZonesInHierarchy('decision', 'new0', 0)
>>> g.zoneParents('zone0')
{'zone1'}
>>> g.zoneParents('new0')
{'zone1'}
>>> sorted(g.zoneAncestors('zone0'))
['zone1', 'zone2.1', 'zone2.2', 'zone3']
>>> sorted(g.zoneAncestors('new0'))
['zone1', 'zone2.1', 'zone2.2', 'zone3']
>>> g.decisionsInZone('zone0')
{1}
>>> g.decisionsInZone('new0')
{0}
>>> sorted(g.subZones('zone1'))
['new0', 'zone0']
>>> g.zoneParents('new0')
{'zone1'}
>>> g.replaceZonesInHierarchy('decision', 'new1', 1)
>>> sorted(g.zoneAncestors(0))
['new0', 'new1', 'zone2.1', 'zone2.2', 'zone3']
>>> g.subZones('zone1')
{'zone0'}
>>> g.subZones('new1')
{'new0'}
>>> g.zoneParents('new0')
{'new1'}
>>> sorted(g.zoneParents('zone1'))
['zone2.1', 'zone2.2']
>>> sorted(g.zoneParents('new1'))
['zone2.1', 'zone2.2']
>>> g.zoneParents('zone2.1')
{'zone3'}
>>> g.zoneParents('zone2.2')
{'zone3'}
>>> sorted(g.subZones('zone2.1'))
['new1', 'zone1']
>>> sorted(g.subZones('zone2.2'))
['new1', 'zone1']
>>> sorted(g.allDecisionsInZone('zone2.1'))
[0, 1]
>>> sorted(g.allDecisionsInZone('zone2.2'))
[0, 1]
>>> g.replaceZonesInHierarchy('decision', 'new2', 2)
>>> g.zoneParents('zone2.1')
{'zone3'}
>>> g.zoneParents('zone2.2')
{'zone3'}
>>> g.subZones('zone2.1')
{'zone1'}
>>> g.subZones('zone2.2')
{'zone1'}
>>> g.subZones('new2')
{'new1'}
>>> g.zoneParents('new2')
{'zone3'}
>>> g.allDecisionsInZone('zone2.1')
{1}
>>> g.allDecisionsInZone('zone2.2')
{1}
>>> g.allDecisionsInZone('new2')
{0}
>>> sorted(g.subZones('zone3'))
['new2', 'zone2.1', 'zone2.2']
>>> g.zoneParents('zone3')
set()
>>> sorted(g.allDecisionsInZone('zone3'))
[0, 1]
>>> g.replaceZonesInHierarchy('decision', 'new3', 3)
>>> sorted(g.subZones('zone3'))
['zone2.1', 'zone2.2']
>>> g.subZones('new3')
{'new2'}
>>> g.zoneParents('zone3')
set()
>>> g.zoneParents('new3')
set()
>>> g.allDecisionsInZone('zone3')
{1}
>>> g.allDecisionsInZone('new3')
{0}
>>> g.replaceZonesInHierarchy('decision', 'new4', 5)
>>> g.subZones('new4')
{'new3'}
>>> g.zoneHierarchyLevel('new4')
5
Another example of level collapse when trying to replace a zone at a level above :
>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.createZone('level0', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level1', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level2', 2)
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level3', 3)
ZoneInfo(level=3, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone('B', 'level0')
>>> g.addZoneToZone('level0', 'level1')
>>> g.addZoneToZone('level1', 'level2')
>>> g.addZoneToZone('level2', 'level3')
>>> g.addDecisionToZone('A', 'level3') # missing some zone levels
>>> g.zoneHierarchyLevel('level3')
3
>>> g.replaceZonesInHierarchy('A', 'newFirst', 1)
>>> g.zoneHierarchyLevel('newFirst')
1
>>> g.decisionsInZone('newFirst')
{0}
>>> g.decisionsInZone('level3')
set()
>>> sorted(g.allDecisionsInZone('level3'))
[0, 1]
>>> g.subZones('newFirst')
set()
>>> sorted(g.subZones('level3'))
['level2', 'newFirst']
>>> g.zoneParents('newFirst')
{'level3'}
>>> g.replaceZonesInHierarchy('A', 'newSecond', 2)
>>> g.zoneHierarchyLevel('newSecond')
2
>>> g.decisionsInZone('newSecond')
set()
>>> g.allDecisionsInZone('newSecond')
{0}
>>> g.subZones('newSecond')
{'newFirst'}
>>> g.zoneParents('newSecond')
{'level3'}
>>> g.zoneParents('newFirst')
{'newSecond'}
>>> sorted(g.subZones('level3'))
['level2', 'newSecond']
3168 def getReciprocal( 3169 self, 3170 decision: base.AnyDecisionSpecifier, 3171 transition: base.Transition 3172 ) -> Optional[base.Transition]: 3173 """ 3174 Returns the reciprocal edge for the specified transition from the 3175 specified decision (see `setReciprocal`). Returns 3176 `None` if no reciprocal has been established for that 3177 transition, or if that decision or transition does not exist. 3178 """ 3179 dID = self.resolveDecision(decision) 3180 3181 dest = self.getDestination(dID, transition) 3182 if dest is not None: 3183 info = cast( 3184 TransitionProperties, 3185 self.edges[dID, dest, transition] # type:ignore 3186 ) 3187 recip = info.get("reciprocal") 3188 if recip is not None and not isinstance(recip, base.Transition): 3189 raise ValueError(f"Invalid reciprocal value: {repr(recip)}") 3190 return recip 3191 else: 3192 return None
Returns the reciprocal edge for the specified transition from the
specified decision (see setReciprocal
). Returns
None
if no reciprocal has been established for that
transition, or if that decision or transition does not exist.
3194 def setReciprocal( 3195 self, 3196 decision: base.AnyDecisionSpecifier, 3197 transition: base.Transition, 3198 reciprocal: Optional[base.Transition], 3199 setBoth: bool = True, 3200 cleanup: bool = True 3201 ) -> None: 3202 """ 3203 Sets the 'reciprocal' transition for a particular transition from 3204 a particular decision, and removes the reciprocal property from 3205 any old reciprocal transition. 3206 3207 Raises a `MissingDecisionError` or a `MissingTransitionError` if 3208 the specified decision or transition does not exist. 3209 3210 Raises an `InvalidDestinationError` if the reciprocal transition 3211 does not exist, or if it does exist but does not lead back to 3212 the decision the transition came from. 3213 3214 If `setBoth` is True (the default) then the transition which is 3215 being identified as a reciprocal will also have its reciprocal 3216 property set, pointing back to the primary transition being 3217 modified, and any old reciprocal of that transition will have its 3218 reciprocal set to None. If you want to create a situation with 3219 non-exclusive reciprocals, use `setBoth=False`. 3220 3221 If `cleanup` is True (the default) then abandoned reciprocal 3222 transitions (for both edges if `setBoth` was true) have their 3223 reciprocal properties removed. Set `cleanup` to false if you want 3224 to retain them, although this will result in non-exclusive 3225 reciprocal relationships. 3226 3227 If the `reciprocal` value is None, this deletes the reciprocal 3228 value entirely, and if `setBoth` is true, it does this for the 3229 previous reciprocal edge as well. No error is raised in this case 3230 when there was not already a reciprocal to delete. 3231 3232 Note that one should remove a reciprocal relationship before 3233 redirecting either edge of the pair in a way that gives it a new 3234 reciprocal, since otherwise, a later attempt to remove the 3235 reciprocal with `setBoth` set to True (the default) will end up 3236 deleting the reciprocal information from the other edge that was 3237 already modified. There is no way to reliably detect and avoid 3238 this, because two different decisions could (and often do in 3239 practice) have transitions with identical names, meaning that the 3240 reciprocal value will still be the same, but it will indicate a 3241 different edge in virtue of the destination of the edge changing. 3242 3243 ## Example 3244 3245 >>> g = DecisionGraph() 3246 >>> g.addDecision('G') 3247 0 3248 >>> g.addDecision('H') 3249 1 3250 >>> g.addDecision('I') 3251 2 3252 >>> g.addTransition('G', 'up', 'H', 'down') 3253 >>> g.addTransition('G', 'next', 'H', 'prev') 3254 >>> g.addTransition('H', 'next', 'I', 'prev') 3255 >>> g.addTransition('H', 'return', 'G') 3256 >>> g.setReciprocal('G', 'up', 'next') # Error w/ destinations 3257 Traceback (most recent call last): 3258 ... 3259 exploration.core.InvalidDestinationError... 3260 >>> g.setReciprocal('G', 'up', 'none') # Doesn't exist 3261 Traceback (most recent call last): 3262 ... 3263 exploration.core.MissingTransitionError... 3264 >>> g.getReciprocal('G', 'up') 3265 'down' 3266 >>> g.getReciprocal('H', 'down') 3267 'up' 3268 >>> g.getReciprocal('H', 'return') is None 3269 True 3270 >>> g.setReciprocal('G', 'up', 'return') 3271 >>> g.getReciprocal('G', 'up') 3272 'return' 3273 >>> g.getReciprocal('H', 'down') is None 3274 True 3275 >>> g.getReciprocal('H', 'return') 3276 'up' 3277 >>> g.setReciprocal('H', 'return', None) # remove the reciprocal 3278 >>> g.getReciprocal('G', 'up') is None 3279 True 3280 >>> g.getReciprocal('H', 'down') is None 3281 True 3282 >>> g.getReciprocal('H', 'return') is None 3283 True 3284 >>> g.setReciprocal('G', 'up', 'down', setBoth=False) # one-way 3285 >>> g.getReciprocal('G', 'up') 3286 'down' 3287 >>> g.getReciprocal('H', 'down') is None 3288 True 3289 >>> g.getReciprocal('H', 'return') is None 3290 True 3291 >>> g.setReciprocal('H', 'return', 'up', setBoth=False) # non-sym 3292 >>> g.getReciprocal('G', 'up') 3293 'down' 3294 >>> g.getReciprocal('H', 'down') is None 3295 True 3296 >>> g.getReciprocal('H', 'return') 3297 'up' 3298 >>> g.setReciprocal('H', 'down', 'up') # setBoth not needed 3299 >>> g.getReciprocal('G', 'up') 3300 'down' 3301 >>> g.getReciprocal('H', 'down') 3302 'up' 3303 >>> g.getReciprocal('H', 'return') # unchanged 3304 'up' 3305 >>> g.setReciprocal('G', 'up', 'return', cleanup=False) # no cleanup 3306 >>> g.getReciprocal('G', 'up') 3307 'return' 3308 >>> g.getReciprocal('H', 'down') 3309 'up' 3310 >>> g.getReciprocal('H', 'return') # unchanged 3311 'up' 3312 >>> # Cleanup only applies to reciprocal if setBoth is true 3313 >>> g.setReciprocal('H', 'down', 'up', setBoth=False) 3314 >>> g.getReciprocal('G', 'up') 3315 'return' 3316 >>> g.getReciprocal('H', 'down') 3317 'up' 3318 >>> g.getReciprocal('H', 'return') # not cleaned up w/out setBoth 3319 'up' 3320 >>> g.setReciprocal('H', 'down', 'up') # with cleanup and setBoth 3321 >>> g.getReciprocal('G', 'up') 3322 'down' 3323 >>> g.getReciprocal('H', 'down') 3324 'up' 3325 >>> g.getReciprocal('H', 'return') is None # cleaned up 3326 True 3327 """ 3328 dID = self.resolveDecision(decision) 3329 3330 dest = self.destination(dID, transition) # possible KeyError 3331 if reciprocal is None: 3332 rDest = None 3333 else: 3334 rDest = self.getDestination(dest, reciprocal) 3335 3336 # Set or delete reciprocal property 3337 if reciprocal is None: 3338 # Delete the property 3339 info = self.edges[dID, dest, transition] # type:ignore 3340 3341 old = info.pop('reciprocal') 3342 if setBoth: 3343 rDest = self.getDestination(dest, old) 3344 if rDest != dID: 3345 raise RuntimeError( 3346 f"Invalid reciprocal {old!r} for transition" 3347 f" {transition!r} from {self.identityOf(dID)}:" 3348 f" destination is {rDest}." 3349 ) 3350 rInfo = self.edges[dest, dID, old] # type:ignore 3351 if 'reciprocal' in rInfo: 3352 del rInfo['reciprocal'] 3353 else: 3354 # Set the property, checking for errors first 3355 if rDest is None: 3356 raise MissingTransitionError( 3357 f"Reciprocal transition {reciprocal!r} for" 3358 f" transition {transition!r} from decision" 3359 f" {self.identityOf(dID)} does not exist at" 3360 f" decision {self.identityOf(dest)}" 3361 ) 3362 3363 if rDest != dID: 3364 raise InvalidDestinationError( 3365 f"Reciprocal transition {reciprocal!r} from" 3366 f" decision {self.identityOf(dest)} does not lead" 3367 f" back to decision {self.identityOf(dID)}." 3368 ) 3369 3370 eProps = self.edges[dID, dest, transition] # type:ignore [index] 3371 abandoned = eProps.get('reciprocal') 3372 eProps['reciprocal'] = reciprocal 3373 if cleanup and abandoned not in (None, reciprocal): 3374 aProps = self.edges[dest, dID, abandoned] # type:ignore 3375 if 'reciprocal' in aProps: 3376 del aProps['reciprocal'] 3377 3378 if setBoth: 3379 rProps = self.edges[dest, dID, reciprocal] # type:ignore 3380 revAbandoned = rProps.get('reciprocal') 3381 rProps['reciprocal'] = transition 3382 # Sever old reciprocal relationship 3383 if cleanup and revAbandoned not in (None, transition): 3384 raProps = self.edges[ 3385 dID, # type:ignore 3386 dest, 3387 revAbandoned 3388 ] 3389 del raProps['reciprocal']
Sets the 'reciprocal' transition for a particular transition from a particular decision, and removes the reciprocal property from any old reciprocal transition.
Raises a MissingDecisionError
or a MissingTransitionError
if
the specified decision or transition does not exist.
Raises an InvalidDestinationError
if the reciprocal transition
does not exist, or if it does exist but does not lead back to
the decision the transition came from.
If setBoth
is True (the default) then the transition which is
being identified as a reciprocal will also have its reciprocal
property set, pointing back to the primary transition being
modified, and any old reciprocal of that transition will have its
reciprocal set to None. If you want to create a situation with
non-exclusive reciprocals, use setBoth=False
.
If cleanup
is True (the default) then abandoned reciprocal
transitions (for both edges if setBoth
was true) have their
reciprocal properties removed. Set cleanup
to false if you want
to retain them, although this will result in non-exclusive
reciprocal relationships.
If the reciprocal
value is None, this deletes the reciprocal
value entirely, and if setBoth
is true, it does this for the
previous reciprocal edge as well. No error is raised in this case
when there was not already a reciprocal to delete.
Note that one should remove a reciprocal relationship before
redirecting either edge of the pair in a way that gives it a new
reciprocal, since otherwise, a later attempt to remove the
reciprocal with setBoth
set to True (the default) will end up
deleting the reciprocal information from the other edge that was
already modified. There is no way to reliably detect and avoid
this, because two different decisions could (and often do in
practice) have transitions with identical names, meaning that the
reciprocal value will still be the same, but it will indicate a
different edge in virtue of the destination of the edge changing.
Example
>>> g = DecisionGraph()
>>> g.addDecision('G')
0
>>> g.addDecision('H')
1
>>> g.addDecision('I')
2
>>> g.addTransition('G', 'up', 'H', 'down')
>>> g.addTransition('G', 'next', 'H', 'prev')
>>> g.addTransition('H', 'next', 'I', 'prev')
>>> g.addTransition('H', 'return', 'G')
>>> g.setReciprocal('G', 'up', 'next') # Error w/ destinations
Traceback (most recent call last):
...
InvalidDestinationError...
>>> g.setReciprocal('G', 'up', 'none') # Doesn't exist
Traceback (most recent call last):
...
MissingTransitionError...
>>> g.getReciprocal('G', 'up')
'down'
>>> g.getReciprocal('H', 'down')
'up'
>>> g.getReciprocal('H', 'return') is None
True
>>> g.setReciprocal('G', 'up', 'return')
>>> g.getReciprocal('G', 'up')
'return'
>>> g.getReciprocal('H', 'down') is None
True
>>> g.getReciprocal('H', 'return')
'up'
>>> g.setReciprocal('H', 'return', None) # remove the reciprocal
>>> g.getReciprocal('G', 'up') is None
True
>>> g.getReciprocal('H', 'down') is None
True
>>> g.getReciprocal('H', 'return') is None
True
>>> g.setReciprocal('G', 'up', 'down', setBoth=False) # one-way
>>> g.getReciprocal('G', 'up')
'down'
>>> g.getReciprocal('H', 'down') is None
True
>>> g.getReciprocal('H', 'return') is None
True
>>> g.setReciprocal('H', 'return', 'up', setBoth=False) # non-sym
>>> g.getReciprocal('G', 'up')
'down'
>>> g.getReciprocal('H', 'down') is None
True
>>> g.getReciprocal('H', 'return')
'up'
>>> g.setReciprocal('H', 'down', 'up') # setBoth not needed
>>> g.getReciprocal('G', 'up')
'down'
>>> g.getReciprocal('H', 'down')
'up'
>>> g.getReciprocal('H', 'return') # unchanged
'up'
>>> g.setReciprocal('G', 'up', 'return', cleanup=False) # no cleanup
>>> g.getReciprocal('G', 'up')
'return'
>>> g.getReciprocal('H', 'down')
'up'
>>> g.getReciprocal('H', 'return') # unchanged
'up'
>>> # Cleanup only applies to reciprocal if setBoth is true
>>> g.setReciprocal('H', 'down', 'up', setBoth=False)
>>> g.getReciprocal('G', 'up')
'return'
>>> g.getReciprocal('H', 'down')
'up'
>>> g.getReciprocal('H', 'return') # not cleaned up w/out setBoth
'up'
>>> g.setReciprocal('H', 'down', 'up') # with cleanup and setBoth
>>> g.getReciprocal('G', 'up')
'down'
>>> g.getReciprocal('H', 'down')
'up'
>>> g.getReciprocal('H', 'return') is None # cleaned up
True
3391 def getReciprocalPair( 3392 self, 3393 decision: base.AnyDecisionSpecifier, 3394 transition: base.Transition 3395 ) -> Optional[Tuple[base.DecisionID, base.Transition]]: 3396 """ 3397 Returns a tuple containing both the destination decision ID and 3398 the transition at that decision which is the reciprocal of the 3399 specified destination & transition. Returns `None` if no 3400 reciprocal has been established for that transition, or if that 3401 decision or transition does not exist. 3402 3403 >>> g = DecisionGraph() 3404 >>> g.addDecision('A') 3405 0 3406 >>> g.addDecision('B') 3407 1 3408 >>> g.addDecision('C') 3409 2 3410 >>> g.addTransition('A', 'up', 'B', 'down') 3411 >>> g.addTransition('B', 'right', 'C', 'left') 3412 >>> g.addTransition('A', 'oneway', 'C') 3413 >>> g.getReciprocalPair('A', 'up') 3414 (1, 'down') 3415 >>> g.getReciprocalPair('B', 'down') 3416 (0, 'up') 3417 >>> g.getReciprocalPair('B', 'right') 3418 (2, 'left') 3419 >>> g.getReciprocalPair('C', 'left') 3420 (1, 'right') 3421 >>> g.getReciprocalPair('C', 'up') is None 3422 True 3423 >>> g.getReciprocalPair('Q', 'up') is None 3424 True 3425 >>> g.getReciprocalPair('A', 'tunnel') is None 3426 True 3427 """ 3428 try: 3429 dID = self.resolveDecision(decision) 3430 except MissingDecisionError: 3431 return None 3432 3433 reciprocal = self.getReciprocal(dID, transition) 3434 if reciprocal is None: 3435 return None 3436 else: 3437 destination = self.getDestination(dID, transition) 3438 if destination is None: 3439 return None 3440 else: 3441 return (destination, reciprocal)
Returns a tuple containing both the destination decision ID and
the transition at that decision which is the reciprocal of the
specified destination & transition. Returns None
if no
reciprocal has been established for that transition, or if that
decision or transition does not exist.
>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addDecision('C')
2
>>> g.addTransition('A', 'up', 'B', 'down')
>>> g.addTransition('B', 'right', 'C', 'left')
>>> g.addTransition('A', 'oneway', 'C')
>>> g.getReciprocalPair('A', 'up')
(1, 'down')
>>> g.getReciprocalPair('B', 'down')
(0, 'up')
>>> g.getReciprocalPair('B', 'right')
(2, 'left')
>>> g.getReciprocalPair('C', 'left')
(1, 'right')
>>> g.getReciprocalPair('C', 'up') is None
True
>>> g.getReciprocalPair('Q', 'up') is None
True
>>> g.getReciprocalPair('A', 'tunnel') is None
True
3443 def addDecision( 3444 self, 3445 name: base.DecisionName, 3446 domain: Optional[base.Domain] = None, 3447 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 3448 annotations: Optional[List[base.Annotation]] = None 3449 ) -> base.DecisionID: 3450 """ 3451 Adds a decision to the graph, without any transitions yet. Each 3452 decision will be assigned an ID so name collisions are allowed, 3453 but it's usually best to keep names unique at least within each 3454 zone. If no domain is provided, the `DEFAULT_DOMAIN` will be 3455 used for the decision's domain. A dictionary of tags and/or a 3456 list of annotations (strings in both cases) may be provided. 3457 3458 Returns the newly-assigned `DecisionID` for the decision it 3459 created. 3460 3461 Emits a `DecisionCollisionWarning` if a decision with the 3462 provided name already exists and the `WARN_OF_NAME_COLLISIONS` 3463 global variable is set to `True`. 3464 """ 3465 # Defaults 3466 if domain is None: 3467 domain = base.DEFAULT_DOMAIN 3468 if tags is None: 3469 tags = {} 3470 if annotations is None: 3471 annotations = [] 3472 3473 # Error checking 3474 if name in self.nameLookup and WARN_OF_NAME_COLLISIONS: 3475 warnings.warn( 3476 ( 3477 f"Adding decision {name!r}: Another decision with" 3478 f" that name already exists." 3479 ), 3480 DecisionCollisionWarning 3481 ) 3482 3483 dID = self._assignID() 3484 3485 # Add the decision 3486 self.add_node( 3487 dID, 3488 name=name, 3489 domain=domain, 3490 tags=tags, 3491 annotations=annotations 3492 ) 3493 #TODO: Elide tags/annotations if they're empty? 3494 3495 # Track it in our `nameLookup` dictionary 3496 self.nameLookup.setdefault(name, []).append(dID) 3497 3498 return dID
Adds a decision to the graph, without any transitions yet. Each
decision will be assigned an ID so name collisions are allowed,
but it's usually best to keep names unique at least within each
zone. If no domain is provided, the DEFAULT_DOMAIN
will be
used for the decision's domain. A dictionary of tags and/or a
list of annotations (strings in both cases) may be provided.
Returns the newly-assigned DecisionID
for the decision it
created.
Emits a DecisionCollisionWarning
if a decision with the
provided name already exists and the WARN_OF_NAME_COLLISIONS
global variable is set to True
.
3500 def addIdentifiedDecision( 3501 self, 3502 dID: base.DecisionID, 3503 name: base.DecisionName, 3504 domain: Optional[base.Domain] = None, 3505 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 3506 annotations: Optional[List[base.Annotation]] = None 3507 ) -> None: 3508 """ 3509 Adds a new decision to the graph using a specific decision ID, 3510 rather than automatically assigning a new decision ID like 3511 `addDecision` does. Otherwise works like `addDecision`. 3512 3513 Raises a `MechanismCollisionError` if the specified decision ID 3514 is already in use. 3515 """ 3516 # Defaults 3517 if domain is None: 3518 domain = base.DEFAULT_DOMAIN 3519 if tags is None: 3520 tags = {} 3521 if annotations is None: 3522 annotations = [] 3523 3524 # Error checking 3525 if dID in self.nodes: 3526 raise MechanismCollisionError( 3527 f"Cannot add a node with id {dID} and name {name!r}:" 3528 f" that ID is already used by node {self.identityOf(dID)}" 3529 ) 3530 3531 if name in self.nameLookup and WARN_OF_NAME_COLLISIONS: 3532 warnings.warn( 3533 ( 3534 f"Adding decision {name!r}: Another decision with" 3535 f" that name already exists." 3536 ), 3537 DecisionCollisionWarning 3538 ) 3539 3540 # Add the decision 3541 self.add_node( 3542 dID, 3543 name=name, 3544 domain=domain, 3545 tags=tags, 3546 annotations=annotations 3547 ) 3548 #TODO: Elide tags/annotations if they're empty? 3549 3550 # Track it in our `nameLookup` dictionary 3551 self.nameLookup.setdefault(name, []).append(dID)
Adds a new decision to the graph using a specific decision ID,
rather than automatically assigning a new decision ID like
addDecision
does. Otherwise works like addDecision
.
Raises a MechanismCollisionError
if the specified decision ID
is already in use.
3553 def addTransition( 3554 self, 3555 fromDecision: base.AnyDecisionSpecifier, 3556 name: base.Transition, 3557 toDecision: base.AnyDecisionSpecifier, 3558 reciprocal: Optional[base.Transition] = None, 3559 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 3560 annotations: Optional[List[base.Annotation]] = None, 3561 revTags: Optional[Dict[base.Tag, base.TagValue]] = None, 3562 revAnnotations: Optional[List[base.Annotation]] = None, 3563 requires: Optional[base.Requirement] = None, 3564 consequence: Optional[base.Consequence] = None, 3565 revRequires: Optional[base.Requirement] = None, 3566 revConsequece: Optional[base.Consequence] = None 3567 ) -> None: 3568 """ 3569 Adds a transition connecting two decisions. A specifier for each 3570 decision is required, as is a name for the transition. If a 3571 `reciprocal` is provided, a reciprocal edge will be added in the 3572 opposite direction using that name; by default only the specified 3573 edge is added. A `TransitionCollisionError` will be raised if the 3574 `reciprocal` matches the name of an existing edge at the 3575 destination decision. 3576 3577 Both decisions must already exist, or a `MissingDecisionError` 3578 will be raised. 3579 3580 A dictionary of tags and/or a list of annotations may be 3581 provided. Tags and/or annotations for the reverse edge may also 3582 be specified if one is being added. 3583 3584 The `requires`, `consequence`, `revRequires`, and `revConsequece` 3585 arguments specify requirements and/or consequences of the new 3586 outgoing and reciprocal edges. 3587 """ 3588 # Defaults 3589 if tags is None: 3590 tags = {} 3591 if annotations is None: 3592 annotations = [] 3593 if revTags is None: 3594 revTags = {} 3595 if revAnnotations is None: 3596 revAnnotations = [] 3597 3598 # Error checking 3599 fromID = self.resolveDecision(fromDecision) 3600 toID = self.resolveDecision(toDecision) 3601 3602 # Note: have to check this first so we don't add the forward edge 3603 # and then error out after a side effect! 3604 if ( 3605 reciprocal is not None 3606 and self.getDestination(toDecision, reciprocal) is not None 3607 ): 3608 raise TransitionCollisionError( 3609 f"Cannot add a transition from" 3610 f" {self.identityOf(fromDecision)} to" 3611 f" {self.identityOf(toDecision)} with reciprocal edge" 3612 f" {reciprocal!r}: {reciprocal!r} is already used as an" 3613 f" edge name at {self.identityOf(toDecision)}." 3614 ) 3615 3616 # Add the edge 3617 self.add_edge( 3618 fromID, 3619 toID, 3620 key=name, 3621 tags=tags, 3622 annotations=annotations 3623 ) 3624 self.setTransitionRequirement(fromDecision, name, requires) 3625 if consequence is not None: 3626 self.setConsequence(fromDecision, name, consequence) 3627 if reciprocal is not None: 3628 # Add the reciprocal edge 3629 self.add_edge( 3630 toID, 3631 fromID, 3632 key=reciprocal, 3633 tags=revTags, 3634 annotations=revAnnotations 3635 ) 3636 self.setReciprocal(fromID, name, reciprocal) 3637 self.setTransitionRequirement( 3638 toDecision, 3639 reciprocal, 3640 revRequires 3641 ) 3642 if revConsequece is not None: 3643 self.setConsequence(toDecision, reciprocal, revConsequece)
Adds a transition connecting two decisions. A specifier for each
decision is required, as is a name for the transition. If a
reciprocal
is provided, a reciprocal edge will be added in the
opposite direction using that name; by default only the specified
edge is added. A TransitionCollisionError
will be raised if the
reciprocal
matches the name of an existing edge at the
destination decision.
Both decisions must already exist, or a MissingDecisionError
will be raised.
A dictionary of tags and/or a list of annotations may be provided. Tags and/or annotations for the reverse edge may also be specified if one is being added.
The requires
, consequence
, revRequires
, and revConsequece
arguments specify requirements and/or consequences of the new
outgoing and reciprocal edges.
3645 def removeTransition( 3646 self, 3647 fromDecision: base.AnyDecisionSpecifier, 3648 transition: base.Transition, 3649 removeReciprocal=False 3650 ) -> Union[ 3651 TransitionProperties, 3652 Tuple[TransitionProperties, TransitionProperties] 3653 ]: 3654 """ 3655 Removes a transition. If `removeReciprocal` is true (False is the 3656 default) any reciprocal transition will also be removed (but no 3657 error will occur if there wasn't a reciprocal). 3658 3659 For each removed transition, *every* transition that targeted 3660 that transition as its reciprocal will have its reciprocal set to 3661 `None`, to avoid leaving any invalid reciprocal values. 3662 3663 Raises a `KeyError` if either the target decision or the target 3664 transition does not exist. 3665 3666 Returns a transition properties dictionary with the properties 3667 of the removed transition, or if `removeReciprocal` is true, 3668 returns a pair of such dictionaries for the target transition 3669 and its reciprocal. 3670 3671 ## Example 3672 3673 >>> g = DecisionGraph() 3674 >>> g.addDecision('A') 3675 0 3676 >>> g.addDecision('B') 3677 1 3678 >>> g.addTransition('A', 'up', 'B', 'down', tags={'wide'}) 3679 >>> g.addTransition('A', 'in', 'B', 'out') # we won't touch this 3680 >>> g.addTransition('A', 'next', 'B') 3681 >>> g.setReciprocal('A', 'next', 'down', setBoth=False) 3682 >>> p = g.removeTransition('A', 'up') 3683 >>> p['tags'] 3684 {'wide'} 3685 >>> g.destinationsFrom('A') 3686 {'in': 1, 'next': 1} 3687 >>> g.destinationsFrom('B') 3688 {'down': 0, 'out': 0} 3689 >>> g.getReciprocal('B', 'down') is None 3690 True 3691 >>> g.getReciprocal('A', 'next') # Asymmetrical left over 3692 'down' 3693 >>> g.getReciprocal('A', 'in') # not affected 3694 'out' 3695 >>> g.getReciprocal('B', 'out') # not affected 3696 'in' 3697 >>> # Now with removeReciprocal set to True 3698 >>> g.addTransition('A', 'up', 'B') # add this back in 3699 >>> g.setReciprocal('A', 'up', 'down') # sets both 3700 >>> p = g.removeTransition('A', 'up', removeReciprocal=True) 3701 >>> g.destinationsFrom('A') 3702 {'in': 1, 'next': 1} 3703 >>> g.destinationsFrom('B') 3704 {'out': 0} 3705 >>> g.getReciprocal('A', 'next') is None 3706 True 3707 >>> g.getReciprocal('A', 'in') # not affected 3708 'out' 3709 >>> g.getReciprocal('B', 'out') # not affected 3710 'in' 3711 >>> g.removeTransition('A', 'none') 3712 Traceback (most recent call last): 3713 ... 3714 exploration.core.MissingTransitionError... 3715 >>> g.removeTransition('Z', 'nope') 3716 Traceback (most recent call last): 3717 ... 3718 exploration.core.MissingDecisionError... 3719 """ 3720 # Resolve target ID 3721 fromID = self.resolveDecision(fromDecision) 3722 3723 # raises if either is missing: 3724 destination = self.destination(fromID, transition) 3725 reciprocal = self.getReciprocal(fromID, transition) 3726 3727 # Get dictionaries of parallel & antiparallel edges to be 3728 # checked for invalid reciprocals after removing edges 3729 # Note: these will update live as we remove edges 3730 allAntiparallel = self[destination][fromID] 3731 allParallel = self[fromID][destination] 3732 3733 # Remove the target edge 3734 fProps = self.getTransitionProperties(fromID, transition) 3735 self.remove_edge(fromID, destination, transition) 3736 3737 # Clean up any dangling reciprocal values 3738 for tProps in allAntiparallel.values(): 3739 if tProps.get('reciprocal') == transition: 3740 del tProps['reciprocal'] 3741 3742 # Remove the reciprocal if requested 3743 if removeReciprocal and reciprocal is not None: 3744 rProps = self.getTransitionProperties(destination, reciprocal) 3745 self.remove_edge(destination, fromID, reciprocal) 3746 3747 # Clean up any dangling reciprocal values 3748 for tProps in allParallel.values(): 3749 if tProps.get('reciprocal') == reciprocal: 3750 del tProps['reciprocal'] 3751 3752 return (fProps, rProps) 3753 else: 3754 return fProps
Removes a transition. If removeReciprocal
is true (False is the
default) any reciprocal transition will also be removed (but no
error will occur if there wasn't a reciprocal).
For each removed transition, every transition that targeted
that transition as its reciprocal will have its reciprocal set to
None
, to avoid leaving any invalid reciprocal values.
Raises a KeyError
if either the target decision or the target
transition does not exist.
Returns a transition properties dictionary with the properties
of the removed transition, or if removeReciprocal
is true,
returns a pair of such dictionaries for the target transition
and its reciprocal.
Example
>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addTransition('A', 'up', 'B', 'down', tags={'wide'})
>>> g.addTransition('A', 'in', 'B', 'out') # we won't touch this
>>> g.addTransition('A', 'next', 'B')
>>> g.setReciprocal('A', 'next', 'down', setBoth=False)
>>> p = g.removeTransition('A', 'up')
>>> p['tags']
{'wide'}
>>> g.destinationsFrom('A')
{'in': 1, 'next': 1}
>>> g.destinationsFrom('B')
{'down': 0, 'out': 0}
>>> g.getReciprocal('B', 'down') is None
True
>>> g.getReciprocal('A', 'next') # Asymmetrical left over
'down'
>>> g.getReciprocal('A', 'in') # not affected
'out'
>>> g.getReciprocal('B', 'out') # not affected
'in'
>>> # Now with removeReciprocal set to True
>>> g.addTransition('A', 'up', 'B') # add this back in
>>> g.setReciprocal('A', 'up', 'down') # sets both
>>> p = g.removeTransition('A', 'up', removeReciprocal=True)
>>> g.destinationsFrom('A')
{'in': 1, 'next': 1}
>>> g.destinationsFrom('B')
{'out': 0}
>>> g.getReciprocal('A', 'next') is None
True
>>> g.getReciprocal('A', 'in') # not affected
'out'
>>> g.getReciprocal('B', 'out') # not affected
'in'
>>> g.removeTransition('A', 'none')
Traceback (most recent call last):
...
MissingTransitionError...
>>> g.removeTransition('Z', 'nope')
Traceback (most recent call last):
...
MissingDecisionError...
3756 def addMechanism( 3757 self, 3758 name: base.MechanismName, 3759 where: Optional[base.AnyDecisionSpecifier] = None 3760 ) -> base.MechanismID: 3761 """ 3762 Creates a new mechanism with the given name at the specified 3763 decision, returning its assigned ID. If `where` is `None`, it 3764 creates a global mechanism. Raises a `MechanismCollisionError` 3765 if a mechanism with the same name already exists at a specified 3766 decision (or already exists as a global mechanism). 3767 3768 Note that if the decision is deleted, the mechanism will be as 3769 well. 3770 3771 Since `MechanismState`s are not tracked by `DecisionGraph`s but 3772 instead are part of a `State`, the mechanism won't be in any 3773 particular state, which means it will be treated as being in the 3774 `base.DEFAULT_MECHANISM_STATE`. 3775 """ 3776 if where is None: 3777 mechs = self.globalMechanisms 3778 dID = None 3779 else: 3780 dID = self.resolveDecision(where) 3781 mechs = self.nodes[dID].setdefault('mechanisms', {}) 3782 3783 if name in mechs: 3784 if dID is None: 3785 raise MechanismCollisionError( 3786 f"A global mechanism named {name!r} already exists." 3787 ) 3788 else: 3789 raise MechanismCollisionError( 3790 f"A mechanism named {name!r} already exists at" 3791 f" decision {self.identityOf(dID)}." 3792 ) 3793 3794 mID = self._assignMechanismID() 3795 mechs[name] = mID 3796 self.mechanisms[mID] = (dID, name) 3797 return mID
Creates a new mechanism with the given name at the specified
decision, returning its assigned ID. If where
is None
, it
creates a global mechanism. Raises a MechanismCollisionError
if a mechanism with the same name already exists at a specified
decision (or already exists as a global mechanism).
Note that if the decision is deleted, the mechanism will be as well.
Since MechanismState
s are not tracked by DecisionGraph
s but
instead are part of a State
, the mechanism won't be in any
particular state, which means it will be treated as being in the
base.DEFAULT_MECHANISM_STATE
.
3799 def mechanismsAt( 3800 self, 3801 decision: base.AnyDecisionSpecifier 3802 ) -> Dict[base.MechanismName, base.MechanismID]: 3803 """ 3804 Returns a dictionary mapping mechanism names to their IDs for 3805 all mechanisms at the specified decision. 3806 """ 3807 dID = self.resolveDecision(decision) 3808 3809 return self.nodes[dID]['mechanisms']
Returns a dictionary mapping mechanism names to their IDs for all mechanisms at the specified decision.
3811 def mechanismDetails( 3812 self, 3813 mID: base.MechanismID 3814 ) -> Optional[Tuple[Optional[base.DecisionID], base.MechanismName]]: 3815 """ 3816 Returns a tuple containing the decision ID and mechanism name 3817 for the specified mechanism. Returns `None` if there is no 3818 mechanism with that ID. For global mechanisms, `None` is used in 3819 place of a decision ID. 3820 """ 3821 return self.mechanisms.get(mID)
Returns a tuple containing the decision ID and mechanism name
for the specified mechanism. Returns None
if there is no
mechanism with that ID. For global mechanisms, None
is used in
place of a decision ID.
3823 def deleteMechanism(self, mID: base.MechanismID) -> None: 3824 """ 3825 Deletes the specified mechanism. 3826 """ 3827 name, dID = self.mechanisms.pop(mID) 3828 3829 del self.nodes[dID]['mechanisms'][name]
Deletes the specified mechanism.
3831 def localLookup( 3832 self, 3833 startFrom: Union[ 3834 base.AnyDecisionSpecifier, 3835 Collection[base.AnyDecisionSpecifier] 3836 ], 3837 findAmong: Callable[ 3838 ['DecisionGraph', Union[Set[base.DecisionID], str]], 3839 Optional[LookupResult] 3840 ], 3841 fallbackLayerName: Optional[str] = "fallback", 3842 fallbackToAllDecisions: bool = True 3843 ) -> Optional[LookupResult]: 3844 """ 3845 Looks up some kind of result in the graph by starting from a 3846 base set of decisions and widening the search iteratively based 3847 on zones. This first searches for result(s) in the set of 3848 decisions given, then in the set of all decisions which are in 3849 level-0 zones containing those decisions, then in level-1 zones, 3850 etc. When it runs out of relevant zones, it will check all 3851 decisions which are in any domain that a decision from the 3852 initial search set is in, and then if `fallbackLayerName` is a 3853 string, it will provide that string instead of a set of decision 3854 IDs to the `findAmong` function as the next layer to search. 3855 After the `fallbackLayerName` is used, if 3856 `fallbackToAllDecisions` is `True` (the default) a final search 3857 will be run on all decisions in the graph. The provided 3858 `findAmong` function is called on each successive decision ID 3859 set, until it generates a non-`None` result. We stop and return 3860 that non-`None` result as soon as one is generated. But if none 3861 of the decision sets consulted generate non-`None` results, then 3862 the entire result will be `None`. 3863 """ 3864 # Normalize starting decisions to a set 3865 if isinstance(startFrom, (int, str, base.DecisionSpecifier)): 3866 startFrom = set([startFrom]) 3867 3868 # Resolve decision IDs; convert to list 3869 searchArea: Union[Set[base.DecisionID], str] = set( 3870 self.resolveDecision(spec) for spec in startFrom 3871 ) 3872 3873 # Find all ancestor zones & all relevant domains 3874 allAncestors = set() 3875 relevantDomains = set() 3876 for startingDecision in searchArea: 3877 allAncestors |= self.zoneAncestors(startingDecision) 3878 relevantDomains.add(self.domainFor(startingDecision)) 3879 3880 # Build layers dictionary 3881 ancestorLayers: Dict[int, Set[base.Zone]] = {} 3882 for zone in allAncestors: 3883 info = self.getZoneInfo(zone) 3884 assert info is not None 3885 level = info.level 3886 ancestorLayers.setdefault(level, set()).add(zone) 3887 3888 searchLayers: LookupLayersList = ( 3889 cast(LookupLayersList, [None]) 3890 + cast(LookupLayersList, sorted(ancestorLayers.keys())) 3891 + cast(LookupLayersList, ["domains"]) 3892 ) 3893 if fallbackLayerName is not None: 3894 searchLayers.append("fallback") 3895 3896 if fallbackToAllDecisions: 3897 searchLayers.append("all") 3898 3899 # Continue our search through zone layers 3900 for layer in searchLayers: 3901 # Update search area on subsequent iterations 3902 if layer == "domains": 3903 searchArea = set() 3904 for relevant in relevantDomains: 3905 searchArea |= self.allDecisionsInDomain(relevant) 3906 elif layer == "fallback": 3907 assert fallbackLayerName is not None 3908 searchArea = fallbackLayerName 3909 elif layer == "all": 3910 searchArea = set(self.nodes) 3911 elif layer is not None: 3912 layer = cast(int, layer) # must be an integer 3913 searchZones = ancestorLayers[layer] 3914 searchArea = set() 3915 for zone in searchZones: 3916 searchArea |= self.allDecisionsInZone(zone) 3917 # else it's the first iteration and we use the starting 3918 # searchArea 3919 3920 searchResult: Optional[LookupResult] = findAmong( 3921 self, 3922 searchArea 3923 ) 3924 3925 if searchResult is not None: 3926 return searchResult 3927 3928 # Didn't find any non-None results. 3929 return None
Looks up some kind of result in the graph by starting from a
base set of decisions and widening the search iteratively based
on zones. This first searches for result(s) in the set of
decisions given, then in the set of all decisions which are in
level-0 zones containing those decisions, then in level-1 zones,
etc. When it runs out of relevant zones, it will check all
decisions which are in any domain that a decision from the
initial search set is in, and then if fallbackLayerName
is a
string, it will provide that string instead of a set of decision
IDs to the findAmong
function as the next layer to search.
After the fallbackLayerName
is used, if
fallbackToAllDecisions
is True
(the default) a final search
will be run on all decisions in the graph. The provided
findAmong
function is called on each successive decision ID
set, until it generates a non-None
result. We stop and return
that non-None
result as soon as one is generated. But if none
of the decision sets consulted generate non-None
results, then
the entire result will be None
.
3931 @staticmethod 3932 def uniqueMechanismFinder(name: base.MechanismName) -> Callable[ 3933 ['DecisionGraph', Union[Set[base.DecisionID], str]], 3934 Optional[base.MechanismID] 3935 ]: 3936 """ 3937 Returns a search function that looks for the given mechanism ID, 3938 suitable for use with `localLookup`. The finder will raise a 3939 `MechanismCollisionError` if it finds more than one mechanism 3940 with the specified name at the same level of the search. 3941 """ 3942 def namedMechanismFinder( 3943 graph: 'DecisionGraph', 3944 searchIn: Union[Set[base.DecisionID], str] 3945 ) -> Optional[base.MechanismID]: 3946 """ 3947 Generated finder function for `localLookup` to find a unique 3948 mechanism by name. 3949 """ 3950 candidates: List[base.DecisionID] = [] 3951 3952 if searchIn == "fallback": 3953 if name in graph.globalMechanisms: 3954 candidates = [graph.globalMechanisms[name]] 3955 3956 else: 3957 assert isinstance(searchIn, set) 3958 for dID in searchIn: 3959 mechs = graph.nodes[dID].get('mechanisms', {}) 3960 if name in mechs: 3961 candidates.append(mechs[name]) 3962 3963 if len(candidates) > 1: 3964 raise MechanismCollisionError( 3965 f"There are {len(candidates)} mechanisms named {name!r}" 3966 f" in the search area ({len(searchIn)} decisions(s))." 3967 ) 3968 elif len(candidates) == 1: 3969 return candidates[0] 3970 else: 3971 return None 3972 3973 return namedMechanismFinder
Returns a search function that looks for the given mechanism ID,
suitable for use with localLookup
. The finder will raise a
MechanismCollisionError
if it finds more than one mechanism
with the specified name at the same level of the search.
3975 def lookupMechanism( 3976 self, 3977 startFrom: Union[ 3978 base.AnyDecisionSpecifier, 3979 Collection[base.AnyDecisionSpecifier] 3980 ], 3981 name: base.MechanismName 3982 ) -> base.MechanismID: 3983 """ 3984 Looks up the mechanism with the given name 'closest' to the 3985 given decision or set of decisions. First it looks for a 3986 mechanism with that name that's at one of those decisions. Then 3987 it starts looking in level-0 zones which contain any of them, 3988 then in level-1 zones, and so on. If it finds two mechanisms 3989 with the target name during the same search pass, it raises a 3990 `MechanismCollisionError`, but if it finds one it returns it. 3991 Raises a `MissingMechanismError` if there is no mechanisms with 3992 that name among global mechanisms (searched after the last 3993 applicable level of zones) or anywhere in the graph (which is the 3994 final level of search after checking global mechanisms). 3995 3996 For example: 3997 3998 >>> d = DecisionGraph() 3999 >>> d.addDecision('A') 4000 0 4001 >>> d.addDecision('B') 4002 1 4003 >>> d.addDecision('C') 4004 2 4005 >>> d.addDecision('D') 4006 3 4007 >>> d.addDecision('E') 4008 4 4009 >>> d.addMechanism('switch', 'A') 4010 0 4011 >>> d.addMechanism('switch', 'B') 4012 1 4013 >>> d.addMechanism('switch', 'C') 4014 2 4015 >>> d.addMechanism('lever', 'D') 4016 3 4017 >>> d.addMechanism('lever', None) # global 4018 4 4019 >>> d.createZone('Z1', 0) 4020 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 4021 annotations=[]) 4022 >>> d.createZone('Z2', 0) 4023 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 4024 annotations=[]) 4025 >>> d.createZone('Zup', 1) 4026 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 4027 annotations=[]) 4028 >>> d.addDecisionToZone('A', 'Z1') 4029 >>> d.addDecisionToZone('B', 'Z1') 4030 >>> d.addDecisionToZone('C', 'Z2') 4031 >>> d.addDecisionToZone('D', 'Z2') 4032 >>> d.addDecisionToZone('E', 'Z1') 4033 >>> d.addZoneToZone('Z1', 'Zup') 4034 >>> d.addZoneToZone('Z2', 'Zup') 4035 >>> d.lookupMechanism(set(), 'switch') # 3x among all decisions 4036 Traceback (most recent call last): 4037 ... 4038 exploration.core.MechanismCollisionError... 4039 >>> d.lookupMechanism(set(), 'lever') # 1x global > 1x all 4040 4 4041 >>> d.lookupMechanism({'D'}, 'lever') # local 4042 3 4043 >>> d.lookupMechanism({'A'}, 'lever') # found at D via Zup 4044 3 4045 >>> d.lookupMechanism({'A', 'D'}, 'lever') # local again 4046 3 4047 >>> d.lookupMechanism({'A'}, 'switch') # local 4048 0 4049 >>> d.lookupMechanism({'B'}, 'switch') # local 4050 1 4051 >>> d.lookupMechanism({'C'}, 'switch') # local 4052 2 4053 >>> d.lookupMechanism({'A', 'B'}, 'switch') # ambiguous 4054 Traceback (most recent call last): 4055 ... 4056 exploration.core.MechanismCollisionError... 4057 >>> d.lookupMechanism({'A', 'B', 'C'}, 'switch') # ambiguous 4058 Traceback (most recent call last): 4059 ... 4060 exploration.core.MechanismCollisionError... 4061 >>> d.lookupMechanism({'B', 'D'}, 'switch') # not ambiguous 4062 1 4063 >>> d.lookupMechanism({'E', 'D'}, 'switch') # ambiguous at L0 zone 4064 Traceback (most recent call last): 4065 ... 4066 exploration.core.MechanismCollisionError... 4067 >>> d.lookupMechanism({'E'}, 'switch') # ambiguous at L0 zone 4068 Traceback (most recent call last): 4069 ... 4070 exploration.core.MechanismCollisionError... 4071 >>> d.lookupMechanism({'D'}, 'switch') # found at L0 zone 4072 2 4073 """ 4074 result = self.localLookup( 4075 startFrom, 4076 DecisionGraph.uniqueMechanismFinder(name) 4077 ) 4078 if result is None: 4079 raise MissingMechanismError( 4080 f"No mechanism named {name!r}" 4081 ) 4082 else: 4083 return result
Looks up the mechanism with the given name 'closest' to the
given decision or set of decisions. First it looks for a
mechanism with that name that's at one of those decisions. Then
it starts looking in level-0 zones which contain any of them,
then in level-1 zones, and so on. If it finds two mechanisms
with the target name during the same search pass, it raises a
MechanismCollisionError
, but if it finds one it returns it.
Raises a MissingMechanismError
if there is no mechanisms with
that name among global mechanisms (searched after the last
applicable level of zones) or anywhere in the graph (which is the
final level of search after checking global mechanisms).
For example:
>>> d = DecisionGraph()
>>> d.addDecision('A')
0
>>> d.addDecision('B')
1
>>> d.addDecision('C')
2
>>> d.addDecision('D')
3
>>> d.addDecision('E')
4
>>> d.addMechanism('switch', 'A')
0
>>> d.addMechanism('switch', 'B')
1
>>> d.addMechanism('switch', 'C')
2
>>> d.addMechanism('lever', 'D')
3
>>> d.addMechanism('lever', None) # global
4
>>> d.createZone('Z1', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('Z2', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('Zup', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addDecisionToZone('A', 'Z1')
>>> d.addDecisionToZone('B', 'Z1')
>>> d.addDecisionToZone('C', 'Z2')
>>> d.addDecisionToZone('D', 'Z2')
>>> d.addDecisionToZone('E', 'Z1')
>>> d.addZoneToZone('Z1', 'Zup')
>>> d.addZoneToZone('Z2', 'Zup')
>>> d.lookupMechanism(set(), 'switch') # 3x among all decisions
Traceback (most recent call last):
...
MechanismCollisionError...
>>> d.lookupMechanism(set(), 'lever') # 1x global > 1x all
4
>>> d.lookupMechanism({'D'}, 'lever') # local
3
>>> d.lookupMechanism({'A'}, 'lever') # found at D via Zup
3
>>> d.lookupMechanism({'A', 'D'}, 'lever') # local again
3
>>> d.lookupMechanism({'A'}, 'switch') # local
0
>>> d.lookupMechanism({'B'}, 'switch') # local
1
>>> d.lookupMechanism({'C'}, 'switch') # local
2
>>> d.lookupMechanism({'A', 'B'}, 'switch') # ambiguous
Traceback (most recent call last):
...
MechanismCollisionError...
>>> d.lookupMechanism({'A', 'B', 'C'}, 'switch') # ambiguous
Traceback (most recent call last):
...
MechanismCollisionError...
>>> d.lookupMechanism({'B', 'D'}, 'switch') # not ambiguous
1
>>> d.lookupMechanism({'E', 'D'}, 'switch') # ambiguous at L0 zone
Traceback (most recent call last):
...
MechanismCollisionError...
>>> d.lookupMechanism({'E'}, 'switch') # ambiguous at L0 zone
Traceback (most recent call last):
...
MechanismCollisionError...
>>> d.lookupMechanism({'D'}, 'switch') # found at L0 zone
2
4085 def resolveMechanism( 4086 self, 4087 specifier: base.AnyMechanismSpecifier, 4088 startFrom: Union[ 4089 None, 4090 base.AnyDecisionSpecifier, 4091 Collection[base.AnyDecisionSpecifier] 4092 ] = None 4093 ) -> base.MechanismID: 4094 """ 4095 Works like `lookupMechanism`, except it accepts a 4096 `base.AnyMechanismSpecifier` which may have position information 4097 baked in, and so the `startFrom` information is optional. If 4098 position information isn't specified in the mechanism specifier 4099 and startFrom is not provided, the mechanism is searched for at 4100 the global scope and then in the entire graph. On the other 4101 hand, if the specifier includes any position information, the 4102 startFrom value provided here will be ignored. 4103 """ 4104 if isinstance(specifier, base.MechanismID): 4105 return specifier 4106 4107 elif isinstance(specifier, base.MechanismName): 4108 if startFrom is None: 4109 startFrom = set() 4110 return self.lookupMechanism(startFrom, specifier) 4111 4112 elif isinstance(specifier, tuple) and len(specifier) == 4: 4113 domain, zone, decision, mechanism = specifier 4114 if domain is None and zone is None and decision is None: 4115 if startFrom is None: 4116 startFrom = set() 4117 return self.lookupMechanism(startFrom, mechanism) 4118 4119 elif decision is not None: 4120 startFrom = { 4121 self.resolveDecision( 4122 base.DecisionSpecifier(domain, zone, decision) 4123 ) 4124 } 4125 return self.lookupMechanism(startFrom, mechanism) 4126 4127 else: # decision is None but domain and/or zone aren't 4128 startFrom = set() 4129 if zone is not None: 4130 baseStart = self.allDecisionsInZone(zone) 4131 else: 4132 baseStart = set(self) 4133 4134 if domain is None: 4135 startFrom = baseStart 4136 else: 4137 for dID in baseStart: 4138 if self.domainFor(dID) == domain: 4139 startFrom.add(dID) 4140 return self.lookupMechanism(startFrom, mechanism) 4141 4142 else: 4143 raise TypeError( 4144 f"Invalid mechanism specifier: {repr(specifier)}" 4145 f"\n(Must be a mechanism ID, mechanism name, or" 4146 f" mechanism specifier tuple)" 4147 )
Works like lookupMechanism
, except it accepts a
base.AnyMechanismSpecifier
which may have position information
baked in, and so the startFrom
information is optional. If
position information isn't specified in the mechanism specifier
and startFrom is not provided, the mechanism is searched for at
the global scope and then in the entire graph. On the other
hand, if the specifier includes any position information, the
startFrom value provided here will be ignored.
4149 def walkConsequenceMechanisms( 4150 self, 4151 consequence: base.Consequence, 4152 searchFrom: Set[base.DecisionID] 4153 ) -> Generator[base.MechanismID, None, None]: 4154 """ 4155 Yields each requirement in the given `base.Consequence`, 4156 including those in `base.Condition`s, `base.ConditionalSkill`s 4157 within `base.Challenge`s, and those set or toggled by 4158 `base.Effect`s. The `searchFrom` argument specifies where to 4159 start searching for mechanisms, since requirements include them 4160 by name, not by ID. 4161 """ 4162 for part in base.walkParts(consequence): 4163 if isinstance(part, dict): 4164 if 'skills' in part: # a Challenge 4165 for cSkill in part['skills'].walk(): 4166 if isinstance(cSkill, base.ConditionalSkill): 4167 yield from self.walkRequirementMechanisms( 4168 cSkill.requirement, 4169 searchFrom 4170 ) 4171 elif 'condition' in part: # a Condition 4172 yield from self.walkRequirementMechanisms( 4173 part['condition'], 4174 searchFrom 4175 ) 4176 elif 'value' in part: # an Effect 4177 val = part['value'] 4178 if part['type'] == 'set': 4179 if ( 4180 isinstance(val, tuple) 4181 and len(val) == 2 4182 and isinstance(val[1], base.State) 4183 ): 4184 yield from self.walkRequirementMechanisms( 4185 base.ReqMechanism(val[0], val[1]), 4186 searchFrom 4187 ) 4188 elif part['type'] == 'toggle': 4189 if isinstance(val, tuple): 4190 assert len(val) == 2 4191 yield from self.walkRequirementMechanisms( 4192 base.ReqMechanism(val[0], '_'), 4193 # state part is ignored here 4194 searchFrom 4195 )
Yields each requirement in the given base.Consequence
,
including those in base.Condition
s, base.ConditionalSkill
s
within base.Challenge
s, and those set or toggled by
base.Effect
s. The searchFrom
argument specifies where to
start searching for mechanisms, since requirements include them
by name, not by ID.
4197 def walkRequirementMechanisms( 4198 self, 4199 req: base.Requirement, 4200 searchFrom: Set[base.DecisionID] 4201 ) -> Generator[base.MechanismID, None, None]: 4202 """ 4203 Given a requirement, yields any mechanisms mentioned in that 4204 requirement, in depth-first traversal order. 4205 """ 4206 for part in req.walk(): 4207 if isinstance(part, base.ReqMechanism): 4208 mech = part.mechanism 4209 yield self.resolveMechanism( 4210 mech, 4211 startFrom=searchFrom 4212 )
Given a requirement, yields any mechanisms mentioned in that requirement, in depth-first traversal order.
4214 def addUnexploredEdge( 4215 self, 4216 fromDecision: base.AnyDecisionSpecifier, 4217 name: base.Transition, 4218 destinationName: Optional[base.DecisionName] = None, 4219 reciprocal: Optional[base.Transition] = 'return', 4220 toDomain: Optional[base.Domain] = None, 4221 placeInZone: Union[base.Zone, type[base.DefaultZone], None] = None, 4222 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 4223 annotations: Optional[List[base.Annotation]] = None, 4224 revTags: Optional[Dict[base.Tag, base.TagValue]] = None, 4225 revAnnotations: Optional[List[base.Annotation]] = None, 4226 requires: Optional[base.Requirement] = None, 4227 consequence: Optional[base.Consequence] = None, 4228 revRequires: Optional[base.Requirement] = None, 4229 revConsequece: Optional[base.Consequence] = None 4230 ) -> base.DecisionID: 4231 """ 4232 Adds a transition connecting to a new decision named `'_u.-n-'` 4233 where '-n-' is the number of unknown decisions (named or not) 4234 that have ever been created in this graph (or using the 4235 specified destination name if one is provided). This represents 4236 a transition to an unknown destination. The destination node 4237 gets tagged 'unconfirmed'. 4238 4239 This also adds a reciprocal transition in the reverse direction, 4240 unless `reciprocal` is set to `None`. The reciprocal will use 4241 the provided name (default is 'return'). The new decision will 4242 be in the same domain as the decision it's connected to, unless 4243 `toDecision` is specified, in which case it will be in that 4244 domain. 4245 4246 The new decision will not be placed into any zones, unless 4247 `placeInZone` is specified, in which case it will be placed into 4248 that zone. If that zone needs to be created, it will be created 4249 at level 0; in that case that zone will be added to any 4250 grandparent zones of the decision we're branching off of. If 4251 `placeInZone` is set to `base.DefaultZone`, then the new 4252 decision will be placed into each parent zone of the decision 4253 we're branching off of, as long as the new decision is in the 4254 same domain as the decision we're branching from (otherwise only 4255 an explicit `placeInZone` would apply). 4256 4257 The ID of the decision that was created is returned. 4258 4259 A `MissingDecisionError` will be raised if the starting decision 4260 does not exist, a `TransitionCollisionError` will be raised if 4261 it exists but already has a transition with the given name, and a 4262 `DecisionCollisionWarning` will be issued if a decision with the 4263 specified destination name already exists (won't happen when 4264 using an automatic name). 4265 4266 Lists of tags and/or annotations (strings in both cases) may be 4267 provided. These may also be provided for the reciprocal edge. 4268 4269 Similarly, requirements and/or consequences for either edge may 4270 be provided. 4271 4272 ## Example 4273 4274 >>> g = DecisionGraph() 4275 >>> g.addDecision('A') 4276 0 4277 >>> g.addUnexploredEdge('A', 'up') 4278 1 4279 >>> g.nameFor(1) 4280 '_u.0' 4281 >>> g.decisionTags(1) 4282 {'unconfirmed': 1} 4283 >>> g.addUnexploredEdge('A', 'right', 'B') 4284 2 4285 >>> g.nameFor(2) 4286 'B' 4287 >>> g.decisionTags(2) 4288 {'unconfirmed': 1} 4289 >>> g.addUnexploredEdge('A', 'down', None, 'up') 4290 3 4291 >>> g.nameFor(3) 4292 '_u.2' 4293 >>> g.addUnexploredEdge( 4294 ... '_u.0', 4295 ... 'beyond', 4296 ... toDomain='otherDomain', 4297 ... tags={'fast':1}, 4298 ... revTags={'slow':1}, 4299 ... annotations=['comment'], 4300 ... revAnnotations=['one', 'two'], 4301 ... requires=base.ReqCapability('dash'), 4302 ... revRequires=base.ReqCapability('super dash'), 4303 ... consequence=[base.effect(gain='super dash')], 4304 ... revConsequece=[base.effect(lose='super dash')] 4305 ... ) 4306 4 4307 >>> g.nameFor(4) 4308 '_u.3' 4309 >>> g.domainFor(4) 4310 'otherDomain' 4311 >>> g.transitionTags('_u.0', 'beyond') 4312 {'fast': 1} 4313 >>> g.transitionAnnotations('_u.0', 'beyond') 4314 ['comment'] 4315 >>> g.getTransitionRequirement('_u.0', 'beyond') 4316 ReqCapability('dash') 4317 >>> e = g.getConsequence('_u.0', 'beyond') 4318 >>> e == [base.effect(gain='super dash')] 4319 True 4320 >>> g.transitionTags('_u.3', 'return') 4321 {'slow': 1} 4322 >>> g.transitionAnnotations('_u.3', 'return') 4323 ['one', 'two'] 4324 >>> g.getTransitionRequirement('_u.3', 'return') 4325 ReqCapability('super dash') 4326 >>> e = g.getConsequence('_u.3', 'return') 4327 >>> e == [base.effect(lose='super dash')] 4328 True 4329 """ 4330 # Defaults 4331 if tags is None: 4332 tags = {} 4333 if annotations is None: 4334 annotations = [] 4335 if revTags is None: 4336 revTags = {} 4337 if revAnnotations is None: 4338 revAnnotations = [] 4339 4340 # Resolve ID 4341 fromID = self.resolveDecision(fromDecision) 4342 if toDomain is None: 4343 toDomain = self.domainFor(fromID) 4344 4345 if name in self.destinationsFrom(fromID): 4346 raise TransitionCollisionError( 4347 f"Cannot add a new edge {name!r}:" 4348 f" {self.identityOf(fromDecision)} already has an" 4349 f" outgoing edge with that name." 4350 ) 4351 4352 if destinationName in self.nameLookup and WARN_OF_NAME_COLLISIONS: 4353 warnings.warn( 4354 ( 4355 f"Cannot add a new unexplored node" 4356 f" {destinationName!r}: A decision with that name" 4357 f" already exists.\n(Leave destinationName as None" 4358 f" to use an automatic name.)" 4359 ), 4360 DecisionCollisionWarning 4361 ) 4362 4363 # Create the new unexplored decision and add the edge 4364 if destinationName is None: 4365 toName = '_u.' + str(self.unknownCount) 4366 else: 4367 toName = destinationName 4368 self.unknownCount += 1 4369 newID = self.addDecision(toName, domain=toDomain) 4370 self.addTransition( 4371 fromID, 4372 name, 4373 newID, 4374 tags=tags, 4375 annotations=annotations 4376 ) 4377 self.setTransitionRequirement(fromID, name, requires) 4378 if consequence is not None: 4379 self.setConsequence(fromID, name, consequence) 4380 4381 # Add it to a zone if requested 4382 if ( 4383 placeInZone is base.DefaultZone 4384 and toDomain == self.domainFor(fromID) 4385 ): 4386 # Add to each parent of the from decision 4387 for parent in self.zoneParents(fromID): 4388 self.addDecisionToZone(newID, parent) 4389 elif placeInZone is not None: 4390 # Otherwise add it to one specific zone, creating that zone 4391 # at level 0 if necessary 4392 assert isinstance(placeInZone, base.Zone) 4393 if self.getZoneInfo(placeInZone) is None: 4394 self.createZone(placeInZone, 0) 4395 # Add new zone to each grandparent of the from decision 4396 for parent in self.zoneParents(fromID): 4397 for grandparent in self.zoneParents(parent): 4398 self.addZoneToZone(placeInZone, grandparent) 4399 self.addDecisionToZone(newID, placeInZone) 4400 4401 # Create the reciprocal edge 4402 if reciprocal is not None: 4403 self.addTransition( 4404 newID, 4405 reciprocal, 4406 fromID, 4407 tags=revTags, 4408 annotations=revAnnotations 4409 ) 4410 self.setTransitionRequirement(newID, reciprocal, revRequires) 4411 if revConsequece is not None: 4412 self.setConsequence(newID, reciprocal, revConsequece) 4413 # Set as a reciprocal 4414 self.setReciprocal(fromID, name, reciprocal) 4415 4416 # Tag the destination as 'unconfirmed' 4417 self.tagDecision(newID, 'unconfirmed') 4418 4419 # Return ID of new destination 4420 return newID
Adds a transition connecting to a new decision named '_u.-n-'
where '-n-' is the number of unknown decisions (named or not)
that have ever been created in this graph (or using the
specified destination name if one is provided). This represents
a transition to an unknown destination. The destination node
gets tagged 'unconfirmed'.
This also adds a reciprocal transition in the reverse direction,
unless reciprocal
is set to None
. The reciprocal will use
the provided name (default is 'return'). The new decision will
be in the same domain as the decision it's connected to, unless
toDecision
is specified, in which case it will be in that
domain.
The new decision will not be placed into any zones, unless
placeInZone
is specified, in which case it will be placed into
that zone. If that zone needs to be created, it will be created
at level 0; in that case that zone will be added to any
grandparent zones of the decision we're branching off of. If
placeInZone
is set to base.DefaultZone
, then the new
decision will be placed into each parent zone of the decision
we're branching off of, as long as the new decision is in the
same domain as the decision we're branching from (otherwise only
an explicit placeInZone
would apply).
The ID of the decision that was created is returned.
A MissingDecisionError
will be raised if the starting decision
does not exist, a TransitionCollisionError
will be raised if
it exists but already has a transition with the given name, and a
DecisionCollisionWarning
will be issued if a decision with the
specified destination name already exists (won't happen when
using an automatic name).
Lists of tags and/or annotations (strings in both cases) may be provided. These may also be provided for the reciprocal edge.
Similarly, requirements and/or consequences for either edge may be provided.
Example
>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addUnexploredEdge('A', 'up')
1
>>> g.nameFor(1)
'_u.0'
>>> g.decisionTags(1)
{'unconfirmed': 1}
>>> g.addUnexploredEdge('A', 'right', 'B')
2
>>> g.nameFor(2)
'B'
>>> g.decisionTags(2)
{'unconfirmed': 1}
>>> g.addUnexploredEdge('A', 'down', None, 'up')
3
>>> g.nameFor(3)
'_u.2'
>>> g.addUnexploredEdge(
... '_u.0',
... 'beyond',
... toDomain='otherDomain',
... tags={'fast':1},
... revTags={'slow':1},
... annotations=['comment'],
... revAnnotations=['one', 'two'],
... requires=base.ReqCapability('dash'),
... revRequires=base.ReqCapability('super dash'),
... consequence=[base.effect(gain='super dash')],
... revConsequece=[base.effect(lose='super dash')]
... )
4
>>> g.nameFor(4)
'_u.3'
>>> g.domainFor(4)
'otherDomain'
>>> g.transitionTags('_u.0', 'beyond')
{'fast': 1}
>>> g.transitionAnnotations('_u.0', 'beyond')
['comment']
>>> g.getTransitionRequirement('_u.0', 'beyond')
ReqCapability('dash')
>>> e = g.getConsequence('_u.0', 'beyond')
>>> e == [base.effect(gain='super dash')]
True
>>> g.transitionTags('_u.3', 'return')
{'slow': 1}
>>> g.transitionAnnotations('_u.3', 'return')
['one', 'two']
>>> g.getTransitionRequirement('_u.3', 'return')
ReqCapability('super dash')
>>> e = g.getConsequence('_u.3', 'return')
>>> e == [base.effect(lose='super dash')]
True
4422 def retargetTransition( 4423 self, 4424 fromDecision: base.AnyDecisionSpecifier, 4425 transition: base.Transition, 4426 newDestination: base.AnyDecisionSpecifier, 4427 swapReciprocal=True, 4428 errorOnNameColision=True 4429 ) -> Optional[base.Transition]: 4430 """ 4431 Given a particular decision and a transition at that decision, 4432 changes that transition so that it goes to the specified new 4433 destination instead of wherever it was connected to before. If 4434 the new destination is the same as the old one, no changes are 4435 made. 4436 4437 If `swapReciprocal` is set to True (the default) then any 4438 reciprocal edge at the old destination will be deleted, and a 4439 new reciprocal edge from the new destination with equivalent 4440 properties to the original reciprocal will be created, pointing 4441 to the origin of the specified transition. If `swapReciprocal` 4442 is set to False, then the reciprocal relationship with any old 4443 reciprocal edge will be removed, but the old reciprocal edge 4444 will not be changed. 4445 4446 Note that if `errorOnNameColision` is True (the default), then 4447 if the reciprocal transition has the same name as a transition 4448 which already exists at the new destination node, a 4449 `TransitionCollisionError` will be thrown. However, if it is set 4450 to False, the reciprocal transition will be renamed with a suffix 4451 to avoid any possible name collisions. Either way, the name of 4452 the reciprocal transition (possibly just changed) will be 4453 returned, or None if there was no reciprocal transition. 4454 4455 ## Example 4456 4457 >>> g = DecisionGraph() 4458 >>> for fr, to, nm in [ 4459 ... ('A', 'B', 'up'), 4460 ... ('A', 'B', 'up2'), 4461 ... ('B', 'A', 'down'), 4462 ... ('B', 'B', 'self'), 4463 ... ('B', 'C', 'next'), 4464 ... ('C', 'B', 'prev') 4465 ... ]: 4466 ... if g.getDecision(fr) is None: 4467 ... g.addDecision(fr) 4468 ... if g.getDecision(to) is None: 4469 ... g.addDecision(to) 4470 ... g.addTransition(fr, nm, to) 4471 0 4472 1 4473 2 4474 >>> g.setReciprocal('A', 'up', 'down') 4475 >>> g.setReciprocal('B', 'next', 'prev') 4476 >>> g.destination('A', 'up') 4477 1 4478 >>> g.destination('B', 'down') 4479 0 4480 >>> g.retargetTransition('A', 'up', 'C') 4481 'down' 4482 >>> g.destination('A', 'up') 4483 2 4484 >>> g.getDestination('B', 'down') is None 4485 True 4486 >>> g.destination('C', 'down') 4487 0 4488 >>> g.addTransition('A', 'next', 'B') 4489 >>> g.addTransition('B', 'prev', 'A') 4490 >>> g.setReciprocal('A', 'next', 'prev') 4491 >>> # Can't swap a reciprocal in a way that would collide names 4492 >>> g.getReciprocal('C', 'prev') 4493 'next' 4494 >>> g.retargetTransition('C', 'prev', 'A') 4495 Traceback (most recent call last): 4496 ... 4497 exploration.core.TransitionCollisionError... 4498 >>> g.retargetTransition('C', 'prev', 'A', swapReciprocal=False) 4499 'next' 4500 >>> g.destination('C', 'prev') 4501 0 4502 >>> g.destination('A', 'next') # not changed 4503 1 4504 >>> # Reciprocal relationship is severed: 4505 >>> g.getReciprocal('C', 'prev') is None 4506 True 4507 >>> g.getReciprocal('B', 'next') is None 4508 True 4509 >>> # Swap back so we can do another demo 4510 >>> g.retargetTransition('C', 'prev', 'B', swapReciprocal=False) 4511 >>> # Note return value was None here because there was no reciprocal 4512 >>> g.setReciprocal('C', 'prev', 'next') 4513 >>> # Swap reciprocal by renaming it 4514 >>> g.retargetTransition('C', 'prev', 'A', errorOnNameColision=False) 4515 'next.1' 4516 >>> g.getReciprocal('C', 'prev') 4517 'next.1' 4518 >>> g.destination('C', 'prev') 4519 0 4520 >>> g.destination('A', 'next.1') 4521 2 4522 >>> g.destination('A', 'next') 4523 1 4524 >>> # Note names are the same but these are from different nodes 4525 >>> g.getReciprocal('A', 'next') 4526 'prev' 4527 >>> g.getReciprocal('A', 'next.1') 4528 'prev' 4529 """ 4530 fromID = self.resolveDecision(fromDecision) 4531 newDestID = self.resolveDecision(newDestination) 4532 4533 # Figure out the old destination of the transition we're swapping 4534 oldDestID = self.destination(fromID, transition) 4535 reciprocal = self.getReciprocal(fromID, transition) 4536 4537 # If thew new destination is the same, we don't do anything! 4538 if oldDestID == newDestID: 4539 return reciprocal 4540 4541 # First figure out reciprocal business so we can error out 4542 # without making changes if we need to 4543 if swapReciprocal and reciprocal is not None: 4544 reciprocal = self.rebaseTransition( 4545 oldDestID, 4546 reciprocal, 4547 newDestID, 4548 swapReciprocal=False, 4549 errorOnNameColision=errorOnNameColision 4550 ) 4551 4552 # Handle the forward transition... 4553 # Find the transition properties 4554 tProps = self.getTransitionProperties(fromID, transition) 4555 4556 # Delete the edge 4557 self.removeEdgeByKey(fromID, transition) 4558 4559 # Add the new edge 4560 self.addTransition(fromID, transition, newDestID) 4561 4562 # Reapply the transition properties 4563 self.setTransitionProperties(fromID, transition, **tProps) 4564 4565 # Handle the reciprocal transition if there is one... 4566 if reciprocal is not None: 4567 if not swapReciprocal: 4568 # Then sever the relationship, but only if that edge 4569 # still exists (we might be in the middle of a rebase) 4570 check = self.getDestination(oldDestID, reciprocal) 4571 if check is not None: 4572 self.setReciprocal( 4573 oldDestID, 4574 reciprocal, 4575 None, 4576 setBoth=False # Other transition was deleted already 4577 ) 4578 else: 4579 # Establish new reciprocal relationship 4580 self.setReciprocal( 4581 fromID, 4582 transition, 4583 reciprocal 4584 ) 4585 4586 return reciprocal
Given a particular decision and a transition at that decision, changes that transition so that it goes to the specified new destination instead of wherever it was connected to before. If the new destination is the same as the old one, no changes are made.
If swapReciprocal
is set to True (the default) then any
reciprocal edge at the old destination will be deleted, and a
new reciprocal edge from the new destination with equivalent
properties to the original reciprocal will be created, pointing
to the origin of the specified transition. If swapReciprocal
is set to False, then the reciprocal relationship with any old
reciprocal edge will be removed, but the old reciprocal edge
will not be changed.
Note that if errorOnNameColision
is True (the default), then
if the reciprocal transition has the same name as a transition
which already exists at the new destination node, a
TransitionCollisionError
will be thrown. However, if it is set
to False, the reciprocal transition will be renamed with a suffix
to avoid any possible name collisions. Either way, the name of
the reciprocal transition (possibly just changed) will be
returned, or None if there was no reciprocal transition.
Example
>>> g = DecisionGraph()
>>> for fr, to, nm in [
... ('A', 'B', 'up'),
... ('A', 'B', 'up2'),
... ('B', 'A', 'down'),
... ('B', 'B', 'self'),
... ('B', 'C', 'next'),
... ('C', 'B', 'prev')
... ]:
... if g.getDecision(fr) is None:
... g.addDecision(fr)
... if g.getDecision(to) is None:
... g.addDecision(to)
... g.addTransition(fr, nm, to)
0
1
2
>>> g.setReciprocal('A', 'up', 'down')
>>> g.setReciprocal('B', 'next', 'prev')
>>> g.destination('A', 'up')
1
>>> g.destination('B', 'down')
0
>>> g.retargetTransition('A', 'up', 'C')
'down'
>>> g.destination('A', 'up')
2
>>> g.getDestination('B', 'down') is None
True
>>> g.destination('C', 'down')
0
>>> g.addTransition('A', 'next', 'B')
>>> g.addTransition('B', 'prev', 'A')
>>> g.setReciprocal('A', 'next', 'prev')
>>> # Can't swap a reciprocal in a way that would collide names
>>> g.getReciprocal('C', 'prev')
'next'
>>> g.retargetTransition('C', 'prev', 'A')
Traceback (most recent call last):
...
TransitionCollisionError...
>>> g.retargetTransition('C', 'prev', 'A', swapReciprocal=False)
'next'
>>> g.destination('C', 'prev')
0
>>> g.destination('A', 'next') # not changed
1
>>> # Reciprocal relationship is severed:
>>> g.getReciprocal('C', 'prev') is None
True
>>> g.getReciprocal('B', 'next') is None
True
>>> # Swap back so we can do another demo
>>> g.retargetTransition('C', 'prev', 'B', swapReciprocal=False)
>>> # Note return value was None here because there was no reciprocal
>>> g.setReciprocal('C', 'prev', 'next')
>>> # Swap reciprocal by renaming it
>>> g.retargetTransition('C', 'prev', 'A', errorOnNameColision=False)
'next.1'
>>> g.getReciprocal('C', 'prev')
'next.1'
>>> g.destination('C', 'prev')
0
>>> g.destination('A', 'next.1')
2
>>> g.destination('A', 'next')
1
>>> # Note names are the same but these are from different nodes
>>> g.getReciprocal('A', 'next')
'prev'
>>> g.getReciprocal('A', 'next.1')
'prev'
4588 def rebaseTransition( 4589 self, 4590 fromDecision: base.AnyDecisionSpecifier, 4591 transition: base.Transition, 4592 newBase: base.AnyDecisionSpecifier, 4593 swapReciprocal=True, 4594 errorOnNameColision=True 4595 ) -> base.Transition: 4596 """ 4597 Given a particular destination and a transition at that 4598 destination, changes that transition's origin to a new base 4599 decision. If the new source is the same as the old one, no 4600 changes are made. 4601 4602 If `swapReciprocal` is set to True (the default) then any 4603 reciprocal edge at the destination will be retargeted to point 4604 to the new source so that it can remain a reciprocal. If 4605 `swapReciprocal` is set to False, then the reciprocal 4606 relationship with any old reciprocal edge will be removed, but 4607 the old reciprocal edge will not be otherwise changed. 4608 4609 Note that if `errorOnNameColision` is True (the default), then 4610 if the transition has the same name as a transition which 4611 already exists at the new source node, a 4612 `TransitionCollisionError` will be raised. However, if it is set 4613 to False, the transition will be renamed with a suffix to avoid 4614 any possible name collisions. Either way, the (possibly new) name 4615 of the transition that was rebased will be returned. 4616 4617 ## Example 4618 4619 >>> g = DecisionGraph() 4620 >>> for fr, to, nm in [ 4621 ... ('A', 'B', 'up'), 4622 ... ('A', 'B', 'up2'), 4623 ... ('B', 'A', 'down'), 4624 ... ('B', 'B', 'self'), 4625 ... ('B', 'C', 'next'), 4626 ... ('C', 'B', 'prev') 4627 ... ]: 4628 ... if g.getDecision(fr) is None: 4629 ... g.addDecision(fr) 4630 ... if g.getDecision(to) is None: 4631 ... g.addDecision(to) 4632 ... g.addTransition(fr, nm, to) 4633 0 4634 1 4635 2 4636 >>> g.setReciprocal('A', 'up', 'down') 4637 >>> g.setReciprocal('B', 'next', 'prev') 4638 >>> g.destination('A', 'up') 4639 1 4640 >>> g.destination('B', 'down') 4641 0 4642 >>> g.rebaseTransition('B', 'down', 'C') 4643 'down' 4644 >>> g.destination('A', 'up') 4645 2 4646 >>> g.getDestination('B', 'down') is None 4647 True 4648 >>> g.destination('C', 'down') 4649 0 4650 >>> g.addTransition('A', 'next', 'B') 4651 >>> g.addTransition('B', 'prev', 'A') 4652 >>> g.setReciprocal('A', 'next', 'prev') 4653 >>> # Can't rebase in a way that would collide names 4654 >>> g.rebaseTransition('B', 'next', 'A') 4655 Traceback (most recent call last): 4656 ... 4657 exploration.core.TransitionCollisionError... 4658 >>> g.rebaseTransition('B', 'next', 'A', errorOnNameColision=False) 4659 'next.1' 4660 >>> g.destination('C', 'prev') 4661 0 4662 >>> g.destination('A', 'next') # not changed 4663 1 4664 >>> # Collision is avoided by renaming 4665 >>> g.destination('A', 'next.1') 4666 2 4667 >>> # Swap without reciprocal 4668 >>> g.getReciprocal('A', 'next.1') 4669 'prev' 4670 >>> g.getReciprocal('C', 'prev') 4671 'next.1' 4672 >>> g.rebaseTransition('A', 'next.1', 'B', swapReciprocal=False) 4673 'next.1' 4674 >>> g.getReciprocal('C', 'prev') is None 4675 True 4676 >>> g.destination('C', 'prev') 4677 0 4678 >>> g.getDestination('A', 'next.1') is None 4679 True 4680 >>> g.destination('A', 'next') 4681 1 4682 >>> g.destination('B', 'next.1') 4683 2 4684 >>> g.getReciprocal('B', 'next.1') is None 4685 True 4686 >>> # Rebase in a way that creates a self-edge 4687 >>> g.rebaseTransition('A', 'next', 'B') 4688 'next' 4689 >>> g.getDestination('A', 'next') is None 4690 True 4691 >>> g.destination('B', 'next') 4692 1 4693 >>> g.destination('B', 'prev') # swapped as a reciprocal 4694 1 4695 >>> g.getReciprocal('B', 'next') # still reciprocals 4696 'prev' 4697 >>> g.getReciprocal('B', 'prev') 4698 'next' 4699 >>> # And rebasing of a self-edge also works 4700 >>> g.rebaseTransition('B', 'prev', 'A') 4701 'prev' 4702 >>> g.destination('A', 'prev') 4703 1 4704 >>> g.destination('B', 'next') 4705 0 4706 >>> g.getReciprocal('B', 'next') # still reciprocals 4707 'prev' 4708 >>> g.getReciprocal('A', 'prev') 4709 'next' 4710 >>> # We've effectively reversed this edge/reciprocal pair 4711 >>> # by rebasing twice 4712 """ 4713 fromID = self.resolveDecision(fromDecision) 4714 newBaseID = self.resolveDecision(newBase) 4715 4716 # If thew new base is the same, we don't do anything! 4717 if newBaseID == fromID: 4718 return transition 4719 4720 # First figure out reciprocal business so we can swap it later 4721 # without making changes if we need to 4722 destination = self.destination(fromID, transition) 4723 reciprocal = self.getReciprocal(fromID, transition) 4724 # Check for an already-deleted reciprocal 4725 if ( 4726 reciprocal is not None 4727 and self.getDestination(destination, reciprocal) is None 4728 ): 4729 reciprocal = None 4730 4731 # Handle the base swap... 4732 # Find the transition properties 4733 tProps = self.getTransitionProperties(fromID, transition) 4734 4735 # Check for a collision 4736 targetDestinations = self.destinationsFrom(newBaseID) 4737 if transition in targetDestinations: 4738 if errorOnNameColision: 4739 raise TransitionCollisionError( 4740 f"Cannot rebase transition {transition!r} from" 4741 f" {self.identityOf(fromDecision)}: it would be a" 4742 f" duplicate transition name at the new base" 4743 f" decision {self.identityOf(newBase)}." 4744 ) 4745 else: 4746 # Figure out a good fresh name 4747 newName = utils.uniqueName( 4748 transition, 4749 targetDestinations 4750 ) 4751 else: 4752 newName = transition 4753 4754 # Delete the edge 4755 self.removeEdgeByKey(fromID, transition) 4756 4757 # Add the new edge 4758 self.addTransition(newBaseID, newName, destination) 4759 4760 # Reapply the transition properties 4761 self.setTransitionProperties(newBaseID, newName, **tProps) 4762 4763 # Handle the reciprocal transition if there is one... 4764 if reciprocal is not None: 4765 if not swapReciprocal: 4766 # Then sever the relationship 4767 self.setReciprocal( 4768 destination, 4769 reciprocal, 4770 None, 4771 setBoth=False # Other transition was deleted already 4772 ) 4773 else: 4774 # Otherwise swap the reciprocal edge 4775 self.retargetTransition( 4776 destination, 4777 reciprocal, 4778 newBaseID, 4779 swapReciprocal=False 4780 ) 4781 4782 # And establish a new reciprocal relationship 4783 self.setReciprocal( 4784 newBaseID, 4785 newName, 4786 reciprocal 4787 ) 4788 4789 # Return the new name in case it was changed 4790 return newName
Given a particular destination and a transition at that destination, changes that transition's origin to a new base decision. If the new source is the same as the old one, no changes are made.
If swapReciprocal
is set to True (the default) then any
reciprocal edge at the destination will be retargeted to point
to the new source so that it can remain a reciprocal. If
swapReciprocal
is set to False, then the reciprocal
relationship with any old reciprocal edge will be removed, but
the old reciprocal edge will not be otherwise changed.
Note that if errorOnNameColision
is True (the default), then
if the transition has the same name as a transition which
already exists at the new source node, a
TransitionCollisionError
will be raised. However, if it is set
to False, the transition will be renamed with a suffix to avoid
any possible name collisions. Either way, the (possibly new) name
of the transition that was rebased will be returned.
Example
>>> g = DecisionGraph()
>>> for fr, to, nm in [
... ('A', 'B', 'up'),
... ('A', 'B', 'up2'),
... ('B', 'A', 'down'),
... ('B', 'B', 'self'),
... ('B', 'C', 'next'),
... ('C', 'B', 'prev')
... ]:
... if g.getDecision(fr) is None:
... g.addDecision(fr)
... if g.getDecision(to) is None:
... g.addDecision(to)
... g.addTransition(fr, nm, to)
0
1
2
>>> g.setReciprocal('A', 'up', 'down')
>>> g.setReciprocal('B', 'next', 'prev')
>>> g.destination('A', 'up')
1
>>> g.destination('B', 'down')
0
>>> g.rebaseTransition('B', 'down', 'C')
'down'
>>> g.destination('A', 'up')
2
>>> g.getDestination('B', 'down') is None
True
>>> g.destination('C', 'down')
0
>>> g.addTransition('A', 'next', 'B')
>>> g.addTransition('B', 'prev', 'A')
>>> g.setReciprocal('A', 'next', 'prev')
>>> # Can't rebase in a way that would collide names
>>> g.rebaseTransition('B', 'next', 'A')
Traceback (most recent call last):
...
TransitionCollisionError...
>>> g.rebaseTransition('B', 'next', 'A', errorOnNameColision=False)
'next.1'
>>> g.destination('C', 'prev')
0
>>> g.destination('A', 'next') # not changed
1
>>> # Collision is avoided by renaming
>>> g.destination('A', 'next.1')
2
>>> # Swap without reciprocal
>>> g.getReciprocal('A', 'next.1')
'prev'
>>> g.getReciprocal('C', 'prev')
'next.1'
>>> g.rebaseTransition('A', 'next.1', 'B', swapReciprocal=False)
'next.1'
>>> g.getReciprocal('C', 'prev') is None
True
>>> g.destination('C', 'prev')
0
>>> g.getDestination('A', 'next.1') is None
True
>>> g.destination('A', 'next')
1
>>> g.destination('B', 'next.1')
2
>>> g.getReciprocal('B', 'next.1') is None
True
>>> # Rebase in a way that creates a self-edge
>>> g.rebaseTransition('A', 'next', 'B')
'next'
>>> g.getDestination('A', 'next') is None
True
>>> g.destination('B', 'next')
1
>>> g.destination('B', 'prev') # swapped as a reciprocal
1
>>> g.getReciprocal('B', 'next') # still reciprocals
'prev'
>>> g.getReciprocal('B', 'prev')
'next'
>>> # And rebasing of a self-edge also works
>>> g.rebaseTransition('B', 'prev', 'A')
'prev'
>>> g.destination('A', 'prev')
1
>>> g.destination('B', 'next')
0
>>> g.getReciprocal('B', 'next') # still reciprocals
'prev'
>>> g.getReciprocal('A', 'prev')
'next'
>>> # We've effectively reversed this edge/reciprocal pair
>>> # by rebasing twice
4796 def mergeDecisions( 4797 self, 4798 merge: base.AnyDecisionSpecifier, 4799 mergeInto: base.AnyDecisionSpecifier, 4800 errorOnNameColision=True 4801 ) -> Dict[base.Transition, base.Transition]: 4802 """ 4803 Merges two decisions, deleting the first after transferring all 4804 of its incoming and outgoing edges to target the second one, 4805 whose name is retained. The second decision will be added to any 4806 zones that the first decision was a member of. If either decision 4807 does not exist, a `MissingDecisionError` will be raised. If 4808 `merge` and `mergeInto` are the same, then nothing will be 4809 changed. 4810 4811 Unless `errorOnNameColision` is set to False, a 4812 `TransitionCollisionError` will be raised if the two decisions 4813 have outgoing transitions with the same name. If 4814 `errorOnNameColision` is set to False, then such edges will be 4815 renamed using a suffix to avoid name collisions, with edges 4816 connected to the second decision retaining their original names 4817 and edges that were connected to the first decision getting 4818 renamed. 4819 4820 Any mechanisms located at the first decision will be moved to the 4821 merged decision. 4822 4823 The tags and annotations of the merged decision are added to the 4824 tags and annotations of the merge target. If there are shared 4825 tags, the values from the merge target will override those of 4826 the merged decision. If this is undesired behavior, clear/edit 4827 the tags/annotations of the merged decision before the merge. 4828 4829 The 'unconfirmed' tag is treated specially: if both decisions have 4830 it it will be retained, but otherwise it will be dropped even if 4831 one of the situations had it before. 4832 4833 The domain of the second decision is retained. 4834 4835 Returns a dictionary mapping each original transition name to 4836 its new name in cases where transitions get renamed; this will 4837 be empty when no re-naming occurs, including when 4838 `errorOnNameColision` is True. If there were any transitions 4839 connecting the nodes that were merged, these become self-edges 4840 of the merged node (and may be renamed if necessary). 4841 Note that all renamed transitions were originally based on the 4842 first (merged) node, since transitions of the second (merge 4843 target) node are not renamed. 4844 4845 ## Example 4846 4847 >>> g = DecisionGraph() 4848 >>> for fr, to, nm in [ 4849 ... ('A', 'B', 'up'), 4850 ... ('A', 'B', 'up2'), 4851 ... ('B', 'A', 'down'), 4852 ... ('B', 'B', 'self'), 4853 ... ('B', 'C', 'next'), 4854 ... ('C', 'B', 'prev'), 4855 ... ('A', 'C', 'right') 4856 ... ]: 4857 ... if g.getDecision(fr) is None: 4858 ... g.addDecision(fr) 4859 ... if g.getDecision(to) is None: 4860 ... g.addDecision(to) 4861 ... g.addTransition(fr, nm, to) 4862 0 4863 1 4864 2 4865 >>> g.getDestination('A', 'up') 4866 1 4867 >>> g.getDestination('B', 'down') 4868 0 4869 >>> sorted(g) 4870 [0, 1, 2] 4871 >>> g.setReciprocal('A', 'up', 'down') 4872 >>> g.setReciprocal('B', 'next', 'prev') 4873 >>> g.mergeDecisions('C', 'B') 4874 {} 4875 >>> g.destinationsFrom('A') 4876 {'up': 1, 'up2': 1, 'right': 1} 4877 >>> g.destinationsFrom('B') 4878 {'down': 0, 'self': 1, 'prev': 1, 'next': 1} 4879 >>> 'C' in g 4880 False 4881 >>> g.mergeDecisions('A', 'A') # does nothing 4882 {} 4883 >>> # Can't merge non-existent decision 4884 >>> g.mergeDecisions('A', 'Z') 4885 Traceback (most recent call last): 4886 ... 4887 exploration.core.MissingDecisionError... 4888 >>> g.mergeDecisions('Z', 'A') 4889 Traceback (most recent call last): 4890 ... 4891 exploration.core.MissingDecisionError... 4892 >>> # Can't merge decisions w/ shared edge names 4893 >>> g.addDecision('D') 4894 3 4895 >>> g.addTransition('D', 'next', 'A') 4896 >>> g.addTransition('A', 'prev', 'D') 4897 >>> g.setReciprocal('D', 'next', 'prev') 4898 >>> g.mergeDecisions('D', 'B') # both have a 'next' transition 4899 Traceback (most recent call last): 4900 ... 4901 exploration.core.TransitionCollisionError... 4902 >>> # Auto-rename colliding edges 4903 >>> g.mergeDecisions('D', 'B', errorOnNameColision=False) 4904 {'next': 'next.1'} 4905 >>> g.destination('B', 'next') # merge target unchanged 4906 1 4907 >>> g.destination('B', 'next.1') # merged decision name changed 4908 0 4909 >>> g.destination('B', 'prev') # name unchanged (no collision) 4910 1 4911 >>> g.getReciprocal('B', 'next') # unchanged (from B) 4912 'prev' 4913 >>> g.getReciprocal('B', 'next.1') # from A 4914 'prev' 4915 >>> g.getReciprocal('A', 'prev') # from B 4916 'next.1' 4917 4918 ## Folding four nodes into a 2-node loop 4919 4920 >>> g = DecisionGraph() 4921 >>> g.addDecision('X') 4922 0 4923 >>> g.addDecision('Y') 4924 1 4925 >>> g.addTransition('X', 'next', 'Y', 'prev') 4926 >>> g.addDecision('preX') 4927 2 4928 >>> g.addDecision('postY') 4929 3 4930 >>> g.addTransition('preX', 'next', 'X', 'prev') 4931 >>> g.addTransition('Y', 'next', 'postY', 'prev') 4932 >>> g.mergeDecisions('preX', 'Y', errorOnNameColision=False) 4933 {'next': 'next.1'} 4934 >>> g.destinationsFrom('X') 4935 {'next': 1, 'prev': 1} 4936 >>> g.destinationsFrom('Y') 4937 {'prev': 0, 'next': 3, 'next.1': 0} 4938 >>> 2 in g 4939 False 4940 >>> g.destinationsFrom('postY') 4941 {'prev': 1} 4942 >>> g.mergeDecisions('postY', 'X', errorOnNameColision=False) 4943 {'prev': 'prev.1'} 4944 >>> g.destinationsFrom('X') 4945 {'next': 1, 'prev': 1, 'prev.1': 1} 4946 >>> g.destinationsFrom('Y') # order 'cause of 'next' re-target 4947 {'prev': 0, 'next.1': 0, 'next': 0} 4948 >>> 2 in g 4949 False 4950 >>> 3 in g 4951 False 4952 >>> # Reciprocals are tangled... 4953 >>> g.getReciprocal(0, 'prev') 4954 'next.1' 4955 >>> g.getReciprocal(0, 'prev.1') 4956 'next' 4957 >>> g.getReciprocal(1, 'next') 4958 'prev.1' 4959 >>> g.getReciprocal(1, 'next.1') 4960 'prev' 4961 >>> # Note: one merge cannot handle both extra transitions 4962 >>> # because their reciprocals are crossed (e.g., prev.1 <-> next) 4963 >>> # (It would merge both edges but the result would retain 4964 >>> # 'next.1' instead of retaining 'next'.) 4965 >>> g.mergeTransitions('X', 'prev.1', 'prev', mergeReciprocal=False) 4966 >>> g.mergeTransitions('Y', 'next.1', 'next', mergeReciprocal=True) 4967 >>> g.destinationsFrom('X') 4968 {'next': 1, 'prev': 1} 4969 >>> g.destinationsFrom('Y') 4970 {'prev': 0, 'next': 0} 4971 >>> # Reciprocals were salvaged in second merger 4972 >>> g.getReciprocal('X', 'prev') 4973 'next' 4974 >>> g.getReciprocal('Y', 'next') 4975 'prev' 4976 4977 ## Merging with tags/requirements/annotations/consequences 4978 4979 >>> g = DecisionGraph() 4980 >>> g.addDecision('X') 4981 0 4982 >>> g.addDecision('Y') 4983 1 4984 >>> g.addDecision('Z') 4985 2 4986 >>> g.addTransition('X', 'next', 'Y', 'prev') 4987 >>> g.addTransition('X', 'down', 'Z', 'up') 4988 >>> g.tagDecision('X', 'tag0', 1) 4989 >>> g.tagDecision('Y', 'tag1', 10) 4990 >>> g.tagDecision('Y', 'unconfirmed') 4991 >>> g.tagDecision('Z', 'tag1', 20) 4992 >>> g.tagDecision('Z', 'tag2', 30) 4993 >>> g.tagTransition('X', 'next', 'ttag1', 11) 4994 >>> g.tagTransition('Y', 'prev', 'ttag2', 22) 4995 >>> g.tagTransition('X', 'down', 'ttag3', 33) 4996 >>> g.tagTransition('Z', 'up', 'ttag4', 44) 4997 >>> g.annotateDecision('Y', 'annotation 1') 4998 >>> g.annotateDecision('Z', 'annotation 2') 4999 >>> g.annotateDecision('Z', 'annotation 3') 5000 >>> g.annotateTransition('Y', 'prev', 'trans annotation 1') 5001 >>> g.annotateTransition('Y', 'prev', 'trans annotation 2') 5002 >>> g.annotateTransition('Z', 'up', 'trans annotation 3') 5003 >>> g.setTransitionRequirement( 5004 ... 'X', 5005 ... 'next', 5006 ... base.ReqCapability('power') 5007 ... ) 5008 >>> g.setTransitionRequirement( 5009 ... 'Y', 5010 ... 'prev', 5011 ... base.ReqTokens('token', 1) 5012 ... ) 5013 >>> g.setTransitionRequirement( 5014 ... 'X', 5015 ... 'down', 5016 ... base.ReqCapability('power2') 5017 ... ) 5018 >>> g.setTransitionRequirement( 5019 ... 'Z', 5020 ... 'up', 5021 ... base.ReqTokens('token2', 2) 5022 ... ) 5023 >>> g.setConsequence( 5024 ... 'Y', 5025 ... 'prev', 5026 ... [base.effect(gain="power2")] 5027 ... ) 5028 >>> g.mergeDecisions('Y', 'Z') 5029 {} 5030 >>> g.destination('X', 'next') 5031 2 5032 >>> g.destination('X', 'down') 5033 2 5034 >>> g.destination('Z', 'prev') 5035 0 5036 >>> g.destination('Z', 'up') 5037 0 5038 >>> g.decisionTags('X') 5039 {'tag0': 1} 5040 >>> g.decisionTags('Z') # note that 'unconfirmed' is removed 5041 {'tag1': 20, 'tag2': 30} 5042 >>> g.transitionTags('X', 'next') 5043 {'ttag1': 11} 5044 >>> g.transitionTags('X', 'down') 5045 {'ttag3': 33} 5046 >>> g.transitionTags('Z', 'prev') 5047 {'ttag2': 22} 5048 >>> g.transitionTags('Z', 'up') 5049 {'ttag4': 44} 5050 >>> g.decisionAnnotations('Z') 5051 ['annotation 2', 'annotation 3', 'annotation 1'] 5052 >>> g.transitionAnnotations('Z', 'prev') 5053 ['trans annotation 1', 'trans annotation 2'] 5054 >>> g.transitionAnnotations('Z', 'up') 5055 ['trans annotation 3'] 5056 >>> g.getTransitionRequirement('X', 'next') 5057 ReqCapability('power') 5058 >>> g.getTransitionRequirement('Z', 'prev') 5059 ReqTokens('token', 1) 5060 >>> g.getTransitionRequirement('X', 'down') 5061 ReqCapability('power2') 5062 >>> g.getTransitionRequirement('Z', 'up') 5063 ReqTokens('token2', 2) 5064 >>> g.getConsequence('Z', 'prev') == [ 5065 ... { 5066 ... 'type': 'gain', 5067 ... 'applyTo': 'active', 5068 ... 'value': 'power2', 5069 ... 'charges': None, 5070 ... 'delay': None, 5071 ... 'hidden': False 5072 ... } 5073 ... ] 5074 True 5075 5076 ## Merging into node without tags 5077 5078 >>> g = DecisionGraph() 5079 >>> g.addDecision('X') 5080 0 5081 >>> g.addDecision('Y') 5082 1 5083 >>> g.tagDecision('Y', 'unconfirmed') # special handling 5084 >>> g.tagDecision('Y', 'tag', 'value') 5085 >>> g.mergeDecisions('Y', 'X') 5086 {} 5087 >>> g.decisionTags('X') 5088 {'tag': 'value'} 5089 >>> 0 in g # Second argument remains 5090 True 5091 >>> 1 in g # First argument is deleted 5092 False 5093 """ 5094 # Resolve IDs 5095 mergeID = self.resolveDecision(merge) 5096 mergeIntoID = self.resolveDecision(mergeInto) 5097 5098 # Create our result as an empty dictionary 5099 result: Dict[base.Transition, base.Transition] = {} 5100 5101 # Short-circuit if the two decisions are the same 5102 if mergeID == mergeIntoID: 5103 return result 5104 5105 # MissingDecisionErrors from here if either doesn't exist 5106 allNewOutgoing = set(self.destinationsFrom(mergeID)) 5107 allOldOutgoing = set(self.destinationsFrom(mergeIntoID)) 5108 # Find colliding transition names 5109 collisions = allNewOutgoing & allOldOutgoing 5110 if len(collisions) > 0 and errorOnNameColision: 5111 raise TransitionCollisionError( 5112 f"Cannot merge decision {self.identityOf(merge)} into" 5113 f" decision {self.identityOf(mergeInto)}: the decisions" 5114 f" share {len(collisions)} transition names:" 5115 f" {collisions}\n(Note that errorOnNameColision was set" 5116 f" to True, set it to False to allow the operation by" 5117 f" renaming half of those transitions.)" 5118 ) 5119 5120 # Record zones that will have to change after the merge 5121 zoneParents = self.zoneParents(mergeID) 5122 5123 # First, swap all incoming edges, along with their reciprocals 5124 # This will include self-edges, which will be retargeted and 5125 # whose reciprocals will be rebased in the process, leading to 5126 # the possibility of a missing edge during the loop 5127 for source, incoming in self.allEdgesTo(mergeID): 5128 # Skip this edge if it was already swapped away because it's 5129 # a self-loop with a reciprocal whose reciprocal was 5130 # processed earlier in the loop 5131 if incoming not in self.destinationsFrom(source): 5132 continue 5133 5134 # Find corresponding outgoing edge 5135 outgoing = self.getReciprocal(source, incoming) 5136 5137 # Swap both edges to new destination 5138 newOutgoing = self.retargetTransition( 5139 source, 5140 incoming, 5141 mergeIntoID, 5142 swapReciprocal=True, 5143 errorOnNameColision=False # collisions were detected above 5144 ) 5145 # Add to our result if the name of the reciprocal was 5146 # changed 5147 if ( 5148 outgoing is not None 5149 and newOutgoing is not None 5150 and outgoing != newOutgoing 5151 ): 5152 result[outgoing] = newOutgoing 5153 5154 # Next, swap any remaining outgoing edges (which didn't have 5155 # reciprocals, or they'd already be swapped, unless they were 5156 # self-edges previously). Note that in this loop, there can't be 5157 # any self-edges remaining, although there might be connections 5158 # between the merging nodes that need to become self-edges 5159 # because they used to be a self-edge that was half-retargeted 5160 # by the previous loop. 5161 # Note: a copy is used here to avoid iterating over a changing 5162 # dictionary 5163 for stillOutgoing in copy.copy(self.destinationsFrom(mergeID)): 5164 newOutgoing = self.rebaseTransition( 5165 mergeID, 5166 stillOutgoing, 5167 mergeIntoID, 5168 swapReciprocal=True, 5169 errorOnNameColision=False # collisions were detected above 5170 ) 5171 if stillOutgoing != newOutgoing: 5172 result[stillOutgoing] = newOutgoing 5173 5174 # At this point, there shouldn't be any remaining incoming or 5175 # outgoing edges! 5176 assert self.degree(mergeID) == 0 5177 5178 # Merge tags & annotations 5179 # Note that these operations affect the underlying graph 5180 destTags = self.decisionTags(mergeIntoID) 5181 destUnvisited = 'unconfirmed' in destTags 5182 sourceTags = self.decisionTags(mergeID) 5183 sourceUnvisited = 'unconfirmed' in sourceTags 5184 # Copy over only new tags, leaving existing tags alone 5185 for key in sourceTags: 5186 if key not in destTags: 5187 destTags[key] = sourceTags[key] 5188 5189 if int(destUnvisited) + int(sourceUnvisited) == 1: 5190 del destTags['unconfirmed'] 5191 5192 self.decisionAnnotations(mergeIntoID).extend( 5193 self.decisionAnnotations(mergeID) 5194 ) 5195 5196 # Transfer zones 5197 for zone in zoneParents: 5198 self.addDecisionToZone(mergeIntoID, zone) 5199 5200 # Delete the old node 5201 self.removeDecision(mergeID) 5202 5203 return result
Merges two decisions, deleting the first after transferring all
of its incoming and outgoing edges to target the second one,
whose name is retained. The second decision will be added to any
zones that the first decision was a member of. If either decision
does not exist, a MissingDecisionError
will be raised. If
merge
and mergeInto
are the same, then nothing will be
changed.
Unless errorOnNameColision
is set to False, a
TransitionCollisionError
will be raised if the two decisions
have outgoing transitions with the same name. If
errorOnNameColision
is set to False, then such edges will be
renamed using a suffix to avoid name collisions, with edges
connected to the second decision retaining their original names
and edges that were connected to the first decision getting
renamed.
Any mechanisms located at the first decision will be moved to the merged decision.
The tags and annotations of the merged decision are added to the tags and annotations of the merge target. If there are shared tags, the values from the merge target will override those of the merged decision. If this is undesired behavior, clear/edit the tags/annotations of the merged decision before the merge.
The 'unconfirmed' tag is treated specially: if both decisions have it it will be retained, but otherwise it will be dropped even if one of the situations had it before.
The domain of the second decision is retained.
Returns a dictionary mapping each original transition name to
its new name in cases where transitions get renamed; this will
be empty when no re-naming occurs, including when
errorOnNameColision
is True. If there were any transitions
connecting the nodes that were merged, these become self-edges
of the merged node (and may be renamed if necessary).
Note that all renamed transitions were originally based on the
first (merged) node, since transitions of the second (merge
target) node are not renamed.
Example
>>> g = DecisionGraph()
>>> for fr, to, nm in [
... ('A', 'B', 'up'),
... ('A', 'B', 'up2'),
... ('B', 'A', 'down'),
... ('B', 'B', 'self'),
... ('B', 'C', 'next'),
... ('C', 'B', 'prev'),
... ('A', 'C', 'right')
... ]:
... if g.getDecision(fr) is None:
... g.addDecision(fr)
... if g.getDecision(to) is None:
... g.addDecision(to)
... g.addTransition(fr, nm, to)
0
1
2
>>> g.getDestination('A', 'up')
1
>>> g.getDestination('B', 'down')
0
>>> sorted(g)
[0, 1, 2]
>>> g.setReciprocal('A', 'up', 'down')
>>> g.setReciprocal('B', 'next', 'prev')
>>> g.mergeDecisions('C', 'B')
{}
>>> g.destinationsFrom('A')
{'up': 1, 'up2': 1, 'right': 1}
>>> g.destinationsFrom('B')
{'down': 0, 'self': 1, 'prev': 1, 'next': 1}
>>> 'C' in g
False
>>> g.mergeDecisions('A', 'A') # does nothing
{}
>>> # Can't merge non-existent decision
>>> g.mergeDecisions('A', 'Z')
Traceback (most recent call last):
...
MissingDecisionError...
>>> g.mergeDecisions('Z', 'A')
Traceback (most recent call last):
...
MissingDecisionError...
>>> # Can't merge decisions w/ shared edge names
>>> g.addDecision('D')
3
>>> g.addTransition('D', 'next', 'A')
>>> g.addTransition('A', 'prev', 'D')
>>> g.setReciprocal('D', 'next', 'prev')
>>> g.mergeDecisions('D', 'B') # both have a 'next' transition
Traceback (most recent call last):
...
TransitionCollisionError...
>>> # Auto-rename colliding edges
>>> g.mergeDecisions('D', 'B', errorOnNameColision=False)
{'next': 'next.1'}
>>> g.destination('B', 'next') # merge target unchanged
1
>>> g.destination('B', 'next.1') # merged decision name changed
0
>>> g.destination('B', 'prev') # name unchanged (no collision)
1
>>> g.getReciprocal('B', 'next') # unchanged (from B)
'prev'
>>> g.getReciprocal('B', 'next.1') # from A
'prev'
>>> g.getReciprocal('A', 'prev') # from B
'next.1'
Folding four nodes into a 2-node loop
>>> g = DecisionGraph()
>>> g.addDecision('X')
0
>>> g.addDecision('Y')
1
>>> g.addTransition('X', 'next', 'Y', 'prev')
>>> g.addDecision('preX')
2
>>> g.addDecision('postY')
3
>>> g.addTransition('preX', 'next', 'X', 'prev')
>>> g.addTransition('Y', 'next', 'postY', 'prev')
>>> g.mergeDecisions('preX', 'Y', errorOnNameColision=False)
{'next': 'next.1'}
>>> g.destinationsFrom('X')
{'next': 1, 'prev': 1}
>>> g.destinationsFrom('Y')
{'prev': 0, 'next': 3, 'next.1': 0}
>>> 2 in g
False
>>> g.destinationsFrom('postY')
{'prev': 1}
>>> g.mergeDecisions('postY', 'X', errorOnNameColision=False)
{'prev': 'prev.1'}
>>> g.destinationsFrom('X')
{'next': 1, 'prev': 1, 'prev.1': 1}
>>> g.destinationsFrom('Y') # order 'cause of 'next' re-target
{'prev': 0, 'next.1': 0, 'next': 0}
>>> 2 in g
False
>>> 3 in g
False
>>> # Reciprocals are tangled...
>>> g.getReciprocal(0, 'prev')
'next.1'
>>> g.getReciprocal(0, 'prev.1')
'next'
>>> g.getReciprocal(1, 'next')
'prev.1'
>>> g.getReciprocal(1, 'next.1')
'prev'
>>> # Note: one merge cannot handle both extra transitions
>>> # because their reciprocals are crossed (e.g., prev.1 <-> next)
>>> # (It would merge both edges but the result would retain
>>> # 'next.1' instead of retaining 'next'.)
>>> g.mergeTransitions('X', 'prev.1', 'prev', mergeReciprocal=False)
>>> g.mergeTransitions('Y', 'next.1', 'next', mergeReciprocal=True)
>>> g.destinationsFrom('X')
{'next': 1, 'prev': 1}
>>> g.destinationsFrom('Y')
{'prev': 0, 'next': 0}
>>> # Reciprocals were salvaged in second merger
>>> g.getReciprocal('X', 'prev')
'next'
>>> g.getReciprocal('Y', 'next')
'prev'
Merging with tags/requirements/annotations/consequences
>>> g = DecisionGraph()
>>> g.addDecision('X')
0
>>> g.addDecision('Y')
1
>>> g.addDecision('Z')
2
>>> g.addTransition('X', 'next', 'Y', 'prev')
>>> g.addTransition('X', 'down', 'Z', 'up')
>>> g.tagDecision('X', 'tag0', 1)
>>> g.tagDecision('Y', 'tag1', 10)
>>> g.tagDecision('Y', 'unconfirmed')
>>> g.tagDecision('Z', 'tag1', 20)
>>> g.tagDecision('Z', 'tag2', 30)
>>> g.tagTransition('X', 'next', 'ttag1', 11)
>>> g.tagTransition('Y', 'prev', 'ttag2', 22)
>>> g.tagTransition('X', 'down', 'ttag3', 33)
>>> g.tagTransition('Z', 'up', 'ttag4', 44)
>>> g.annotateDecision('Y', 'annotation 1')
>>> g.annotateDecision('Z', 'annotation 2')
>>> g.annotateDecision('Z', 'annotation 3')
>>> g.annotateTransition('Y', 'prev', 'trans annotation 1')
>>> g.annotateTransition('Y', 'prev', 'trans annotation 2')
>>> g.annotateTransition('Z', 'up', 'trans annotation 3')
>>> g.setTransitionRequirement(
... 'X',
... 'next',
... base.ReqCapability('power')
... )
>>> g.setTransitionRequirement(
... 'Y',
... 'prev',
... base.ReqTokens('token', 1)
... )
>>> g.setTransitionRequirement(
... 'X',
... 'down',
... base.ReqCapability('power2')
... )
>>> g.setTransitionRequirement(
... 'Z',
... 'up',
... base.ReqTokens('token2', 2)
... )
>>> g.setConsequence(
... 'Y',
... 'prev',
... [base.effect(gain="power2")]
... )
>>> g.mergeDecisions('Y', 'Z')
{}
>>> g.destination('X', 'next')
2
>>> g.destination('X', 'down')
2
>>> g.destination('Z', 'prev')
0
>>> g.destination('Z', 'up')
0
>>> g.decisionTags('X')
{'tag0': 1}
>>> g.decisionTags('Z') # note that 'unconfirmed' is removed
{'tag1': 20, 'tag2': 30}
>>> g.transitionTags('X', 'next')
{'ttag1': 11}
>>> g.transitionTags('X', 'down')
{'ttag3': 33}
>>> g.transitionTags('Z', 'prev')
{'ttag2': 22}
>>> g.transitionTags('Z', 'up')
{'ttag4': 44}
>>> g.decisionAnnotations('Z')
['annotation 2', 'annotation 3', 'annotation 1']
>>> g.transitionAnnotations('Z', 'prev')
['trans annotation 1', 'trans annotation 2']
>>> g.transitionAnnotations('Z', 'up')
['trans annotation 3']
>>> g.getTransitionRequirement('X', 'next')
ReqCapability('power')
>>> g.getTransitionRequirement('Z', 'prev')
ReqTokens('token', 1)
>>> g.getTransitionRequirement('X', 'down')
ReqCapability('power2')
>>> g.getTransitionRequirement('Z', 'up')
ReqTokens('token2', 2)
>>> g.getConsequence('Z', 'prev') == [
... {
... 'type': 'gain',
... 'applyTo': 'active',
... 'value': 'power2',
... 'charges': None,
... 'delay': None,
... 'hidden': False
... }
... ]
True
Merging into node without tags
>>> g = DecisionGraph()
>>> g.addDecision('X')
0
>>> g.addDecision('Y')
1
>>> g.tagDecision('Y', 'unconfirmed') # special handling
>>> g.tagDecision('Y', 'tag', 'value')
>>> g.mergeDecisions('Y', 'X')
{}
>>> g.decisionTags('X')
{'tag': 'value'}
>>> 0 in g # Second argument remains
True
>>> 1 in g # First argument is deleted
False
5205 def removeDecision(self, decision: base.AnyDecisionSpecifier) -> None: 5206 """ 5207 Deletes the specified decision from the graph, updating 5208 attendant structures like zones. Note that the ID of the deleted 5209 node will NOT be reused, unless it's specifically provided to 5210 `addIdentifiedDecision`. 5211 5212 For example: 5213 5214 >>> dg = DecisionGraph() 5215 >>> dg.addDecision('A') 5216 0 5217 >>> dg.addDecision('B') 5218 1 5219 >>> list(dg) 5220 [0, 1] 5221 >>> 1 in dg 5222 True 5223 >>> 'B' in dg.nameLookup 5224 True 5225 >>> dg.removeDecision('B') 5226 >>> 1 in dg 5227 False 5228 >>> list(dg) 5229 [0] 5230 >>> 'B' in dg.nameLookup 5231 False 5232 >>> dg.addDecision('C') # doesn't re-use ID 5233 2 5234 """ 5235 dID = self.resolveDecision(decision) 5236 5237 # Remove the target from all zones: 5238 for zone in self.zones: 5239 self.removeDecisionFromZone(dID, zone) 5240 5241 # Remove the node but record the current name 5242 name = self.nodes[dID]['name'] 5243 self.remove_node(dID) 5244 5245 # Clean up the nameLookup entry 5246 luInfo = self.nameLookup[name] 5247 luInfo.remove(dID) 5248 if len(luInfo) == 0: 5249 self.nameLookup.pop(name) 5250 5251 # TODO: Clean up edges?
Deletes the specified decision from the graph, updating
attendant structures like zones. Note that the ID of the deleted
node will NOT be reused, unless it's specifically provided to
addIdentifiedDecision
.
For example:
>>> dg = DecisionGraph()
>>> dg.addDecision('A')
0
>>> dg.addDecision('B')
1
>>> list(dg)
[0, 1]
>>> 1 in dg
True
>>> 'B' in dg.nameLookup
True
>>> dg.removeDecision('B')
>>> 1 in dg
False
>>> list(dg)
[0]
>>> 'B' in dg.nameLookup
False
>>> dg.addDecision('C') # doesn't re-use ID
2
5253 def renameDecision( 5254 self, 5255 decision: base.AnyDecisionSpecifier, 5256 newName: base.DecisionName 5257 ): 5258 """ 5259 Renames a decision. The decision retains its old ID. 5260 5261 Generates a `DecisionCollisionWarning` if a decision using the new 5262 name already exists and `WARN_OF_NAME_COLLISIONS` is enabled. 5263 5264 Example: 5265 5266 >>> g = DecisionGraph() 5267 >>> g.addDecision('one') 5268 0 5269 >>> g.addDecision('three') 5270 1 5271 >>> g.addTransition('one', '>', 'three') 5272 >>> g.addTransition('three', '<', 'one') 5273 >>> g.tagDecision('three', 'hi') 5274 >>> g.annotateDecision('three', 'note') 5275 >>> g.destination('one', '>') 5276 1 5277 >>> g.destination('three', '<') 5278 0 5279 >>> g.renameDecision('three', 'two') 5280 >>> g.resolveDecision('one') 5281 0 5282 >>> g.resolveDecision('two') 5283 1 5284 >>> g.resolveDecision('three') 5285 Traceback (most recent call last): 5286 ... 5287 exploration.core.MissingDecisionError... 5288 >>> g.destination('one', '>') 5289 1 5290 >>> g.nameFor(1) 5291 'two' 5292 >>> g.getDecision('three') is None 5293 True 5294 >>> g.destination('two', '<') 5295 0 5296 >>> g.decisionTags('two') 5297 {'hi': 1} 5298 >>> g.decisionAnnotations('two') 5299 ['note'] 5300 """ 5301 dID = self.resolveDecision(decision) 5302 5303 if newName in self.nameLookup and WARN_OF_NAME_COLLISIONS: 5304 warnings.warn( 5305 ( 5306 f"Can't rename {self.identityOf(decision)} as" 5307 f" {newName!r} because a decision with that name" 5308 f" already exists." 5309 ), 5310 DecisionCollisionWarning 5311 ) 5312 5313 # Update name in node 5314 oldName = self.nodes[dID]['name'] 5315 self.nodes[dID]['name'] = newName 5316 5317 # Update nameLookup entries 5318 oldNL = self.nameLookup[oldName] 5319 oldNL.remove(dID) 5320 if len(oldNL) == 0: 5321 self.nameLookup.pop(oldName) 5322 self.nameLookup.setdefault(newName, []).append(dID)
Renames a decision. The decision retains its old ID.
Generates a DecisionCollisionWarning
if a decision using the new
name already exists and WARN_OF_NAME_COLLISIONS
is enabled.
Example:
>>> g = DecisionGraph()
>>> g.addDecision('one')
0
>>> g.addDecision('three')
1
>>> g.addTransition('one', '>', 'three')
>>> g.addTransition('three', '<', 'one')
>>> g.tagDecision('three', 'hi')
>>> g.annotateDecision('three', 'note')
>>> g.destination('one', '>')
1
>>> g.destination('three', '<')
0
>>> g.renameDecision('three', 'two')
>>> g.resolveDecision('one')
0
>>> g.resolveDecision('two')
1
>>> g.resolveDecision('three')
Traceback (most recent call last):
...
MissingDecisionError...
>>> g.destination('one', '>')
1
>>> g.nameFor(1)
'two'
>>> g.getDecision('three') is None
True
>>> g.destination('two', '<')
0
>>> g.decisionTags('two')
{'hi': 1}
>>> g.decisionAnnotations('two')
['note']
5324 def mergeTransitions( 5325 self, 5326 fromDecision: base.AnyDecisionSpecifier, 5327 merge: base.Transition, 5328 mergeInto: base.Transition, 5329 mergeReciprocal=True 5330 ) -> None: 5331 """ 5332 Given a decision and two transitions that start at that decision, 5333 merges the first transition into the second transition, combining 5334 their transition properties (using `mergeProperties`) and 5335 deleting the first transition. By default any reciprocal of the 5336 first transition is also merged into the reciprocal of the 5337 second, although you can set `mergeReciprocal` to `False` to 5338 disable this in which case the old reciprocal will lose its 5339 reciprocal relationship, even if the transition that was merged 5340 into does not have a reciprocal. 5341 5342 If the two names provided are the same, nothing will happen. 5343 5344 If the two transitions do not share the same destination, they 5345 cannot be merged, and an `InvalidDestinationError` will result. 5346 Use `retargetTransition` beforehand to ensure that they do if you 5347 want to merge transitions with different destinations. 5348 5349 A `MissingDecisionError` or `MissingTransitionError` will result 5350 if the decision or either transition does not exist. 5351 5352 If merging reciprocal properties was requested and the first 5353 transition does not have a reciprocal, then no reciprocal 5354 properties change. However, if the second transition does not 5355 have a reciprocal and the first does, the first transition's 5356 reciprocal will be set to the reciprocal of the second 5357 transition, and that transition will not be deleted as usual. 5358 5359 ## Example 5360 5361 >>> g = DecisionGraph() 5362 >>> g.addDecision('A') 5363 0 5364 >>> g.addDecision('B') 5365 1 5366 >>> g.addTransition('A', 'up', 'B') 5367 >>> g.addTransition('B', 'down', 'A') 5368 >>> g.setReciprocal('A', 'up', 'down') 5369 >>> # Merging a transition with no reciprocal 5370 >>> g.addTransition('A', 'up2', 'B') 5371 >>> g.mergeTransitions('A', 'up2', 'up') 5372 >>> g.getDestination('A', 'up2') is None 5373 True 5374 >>> g.getDestination('A', 'up') 5375 1 5376 >>> # Merging a transition with a reciprocal & tags 5377 >>> g.addTransition('A', 'up2', 'B') 5378 >>> g.addTransition('B', 'down2', 'A') 5379 >>> g.setReciprocal('A', 'up2', 'down2') 5380 >>> g.tagTransition('A', 'up2', 'one') 5381 >>> g.tagTransition('B', 'down2', 'two') 5382 >>> g.mergeTransitions('B', 'down2', 'down') 5383 >>> g.getDestination('A', 'up2') is None 5384 True 5385 >>> g.getDestination('A', 'up') 5386 1 5387 >>> g.getDestination('B', 'down2') is None 5388 True 5389 >>> g.getDestination('B', 'down') 5390 0 5391 >>> # Merging requirements uses ReqAll (i.e., 'and' logic) 5392 >>> g.addTransition('A', 'up2', 'B') 5393 >>> g.setTransitionProperties( 5394 ... 'A', 5395 ... 'up2', 5396 ... requirement=base.ReqCapability('dash') 5397 ... ) 5398 >>> g.setTransitionProperties('A', 'up', 5399 ... requirement=base.ReqCapability('slide')) 5400 >>> g.mergeTransitions('A', 'up2', 'up') 5401 >>> g.getDestination('A', 'up2') is None 5402 True 5403 >>> repr(g.getTransitionRequirement('A', 'up')) 5404 "ReqAll([ReqCapability('dash'), ReqCapability('slide')])" 5405 >>> # Errors if destinations differ, or if something is missing 5406 >>> g.mergeTransitions('A', 'down', 'up') 5407 Traceback (most recent call last): 5408 ... 5409 exploration.core.MissingTransitionError... 5410 >>> g.mergeTransitions('Z', 'one', 'two') 5411 Traceback (most recent call last): 5412 ... 5413 exploration.core.MissingDecisionError... 5414 >>> g.addDecision('C') 5415 2 5416 >>> g.addTransition('A', 'down', 'C') 5417 >>> g.mergeTransitions('A', 'down', 'up') 5418 Traceback (most recent call last): 5419 ... 5420 exploration.core.InvalidDestinationError... 5421 >>> # Merging a reciprocal onto an edge that doesn't have one 5422 >>> g.addTransition('A', 'down2', 'C') 5423 >>> g.addTransition('C', 'up2', 'A') 5424 >>> g.setReciprocal('A', 'down2', 'up2') 5425 >>> g.tagTransition('C', 'up2', 'narrow') 5426 >>> g.getReciprocal('A', 'down') is None 5427 True 5428 >>> g.mergeTransitions('A', 'down2', 'down') 5429 >>> g.getDestination('A', 'down2') is None 5430 True 5431 >>> g.getDestination('A', 'down') 5432 2 5433 >>> g.getDestination('C', 'up2') 5434 0 5435 >>> g.getReciprocal('A', 'down') 5436 'up2' 5437 >>> g.getReciprocal('C', 'up2') 5438 'down' 5439 >>> g.transitionTags('C', 'up2') 5440 {'narrow': 1} 5441 >>> # Merging without a reciprocal 5442 >>> g.addTransition('C', 'up', 'A') 5443 >>> g.mergeTransitions('C', 'up2', 'up', mergeReciprocal=False) 5444 >>> g.getDestination('C', 'up2') is None 5445 True 5446 >>> g.getDestination('C', 'up') 5447 0 5448 >>> g.transitionTags('C', 'up') # tag gets merged 5449 {'narrow': 1} 5450 >>> g.getDestination('A', 'down') 5451 2 5452 >>> g.getReciprocal('A', 'down') is None 5453 True 5454 >>> g.getReciprocal('C', 'up') is None 5455 True 5456 >>> # Merging w/ normal reciprocals 5457 >>> g.addDecision('D') 5458 3 5459 >>> g.addDecision('E') 5460 4 5461 >>> g.addTransition('D', 'up', 'E', 'return') 5462 >>> g.addTransition('E', 'down', 'D') 5463 >>> g.mergeTransitions('E', 'return', 'down') 5464 >>> g.getDestination('D', 'up') 5465 4 5466 >>> g.getDestination('E', 'down') 5467 3 5468 >>> g.getDestination('E', 'return') is None 5469 True 5470 >>> g.getReciprocal('D', 'up') 5471 'down' 5472 >>> g.getReciprocal('E', 'down') 5473 'up' 5474 >>> # Merging w/ weird reciprocals 5475 >>> g.addTransition('E', 'return', 'D') 5476 >>> g.setReciprocal('E', 'return', 'up', setBoth=False) 5477 >>> g.getReciprocal('D', 'up') 5478 'down' 5479 >>> g.getReciprocal('E', 'down') 5480 'up' 5481 >>> g.getReciprocal('E', 'return') # shared 5482 'up' 5483 >>> g.mergeTransitions('E', 'return', 'down') 5484 >>> g.getDestination('D', 'up') 5485 4 5486 >>> g.getDestination('E', 'down') 5487 3 5488 >>> g.getDestination('E', 'return') is None 5489 True 5490 >>> g.getReciprocal('D', 'up') 5491 'down' 5492 >>> g.getReciprocal('E', 'down') 5493 'up' 5494 """ 5495 fromID = self.resolveDecision(fromDecision) 5496 5497 # Short-circuit in the no-op case 5498 if merge == mergeInto: 5499 return 5500 5501 # These lines will raise a MissingDecisionError or 5502 # MissingTransitionError if needed 5503 dest1 = self.destination(fromID, merge) 5504 dest2 = self.destination(fromID, mergeInto) 5505 5506 if dest1 != dest2: 5507 raise InvalidDestinationError( 5508 f"Cannot merge transition {merge!r} into transition" 5509 f" {mergeInto!r} from decision" 5510 f" {self.identityOf(fromDecision)} because their" 5511 f" destinations are different ({self.identityOf(dest1)}" 5512 f" and {self.identityOf(dest2)}).\nNote: you can use" 5513 f" `retargetTransition` to change the destination of a" 5514 f" transition." 5515 ) 5516 5517 # Find and the transition properties 5518 props1 = self.getTransitionProperties(fromID, merge) 5519 props2 = self.getTransitionProperties(fromID, mergeInto) 5520 merged = mergeProperties(props1, props2) 5521 # Note that this doesn't change the reciprocal: 5522 self.setTransitionProperties(fromID, mergeInto, **merged) 5523 5524 # Merge the reciprocal properties if requested 5525 # Get reciprocal to merge into 5526 reciprocal = self.getReciprocal(fromID, mergeInto) 5527 # Get reciprocal that needs cleaning up 5528 altReciprocal = self.getReciprocal(fromID, merge) 5529 # If the reciprocal to be merged actually already was the 5530 # reciprocal to merge into, there's nothing to do here 5531 if altReciprocal != reciprocal: 5532 if not mergeReciprocal: 5533 # In this case, we sever the reciprocal relationship if 5534 # there is a reciprocal 5535 if altReciprocal is not None: 5536 self.setReciprocal(dest1, altReciprocal, None) 5537 # By default setBoth takes care of the other half 5538 else: 5539 # In this case, we try to merge reciprocals 5540 # If altReciprocal is None, we don't need to do anything 5541 if altReciprocal is not None: 5542 # Was there already a reciprocal or not? 5543 if reciprocal is None: 5544 # altReciprocal becomes the new reciprocal and is 5545 # not deleted 5546 self.setReciprocal( 5547 fromID, 5548 mergeInto, 5549 altReciprocal 5550 ) 5551 else: 5552 # merge reciprocal properties 5553 props1 = self.getTransitionProperties( 5554 dest1, 5555 altReciprocal 5556 ) 5557 props2 = self.getTransitionProperties( 5558 dest2, 5559 reciprocal 5560 ) 5561 merged = mergeProperties(props1, props2) 5562 self.setTransitionProperties( 5563 dest1, 5564 reciprocal, 5565 **merged 5566 ) 5567 5568 # delete the old reciprocal transition 5569 self.remove_edge(dest1, fromID, altReciprocal) 5570 5571 # Delete the old transition (reciprocal deletion/severance is 5572 # handled above if necessary) 5573 self.remove_edge(fromID, dest1, merge)
Given a decision and two transitions that start at that decision,
merges the first transition into the second transition, combining
their transition properties (using mergeProperties
) and
deleting the first transition. By default any reciprocal of the
first transition is also merged into the reciprocal of the
second, although you can set mergeReciprocal
to False
to
disable this in which case the old reciprocal will lose its
reciprocal relationship, even if the transition that was merged
into does not have a reciprocal.
If the two names provided are the same, nothing will happen.
If the two transitions do not share the same destination, they
cannot be merged, and an InvalidDestinationError
will result.
Use retargetTransition
beforehand to ensure that they do if you
want to merge transitions with different destinations.
A MissingDecisionError
or MissingTransitionError
will result
if the decision or either transition does not exist.
If merging reciprocal properties was requested and the first transition does not have a reciprocal, then no reciprocal properties change. However, if the second transition does not have a reciprocal and the first does, the first transition's reciprocal will be set to the reciprocal of the second transition, and that transition will not be deleted as usual.
Example
>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addTransition('A', 'up', 'B')
>>> g.addTransition('B', 'down', 'A')
>>> g.setReciprocal('A', 'up', 'down')
>>> # Merging a transition with no reciprocal
>>> g.addTransition('A', 'up2', 'B')
>>> g.mergeTransitions('A', 'up2', 'up')
>>> g.getDestination('A', 'up2') is None
True
>>> g.getDestination('A', 'up')
1
>>> # Merging a transition with a reciprocal & tags
>>> g.addTransition('A', 'up2', 'B')
>>> g.addTransition('B', 'down2', 'A')
>>> g.setReciprocal('A', 'up2', 'down2')
>>> g.tagTransition('A', 'up2', 'one')
>>> g.tagTransition('B', 'down2', 'two')
>>> g.mergeTransitions('B', 'down2', 'down')
>>> g.getDestination('A', 'up2') is None
True
>>> g.getDestination('A', 'up')
1
>>> g.getDestination('B', 'down2') is None
True
>>> g.getDestination('B', 'down')
0
>>> # Merging requirements uses ReqAll (i.e., 'and' logic)
>>> g.addTransition('A', 'up2', 'B')
>>> g.setTransitionProperties(
... 'A',
... 'up2',
... requirement=base.ReqCapability('dash')
... )
>>> g.setTransitionProperties('A', 'up',
... requirement=base.ReqCapability('slide'))
>>> g.mergeTransitions('A', 'up2', 'up')
>>> g.getDestination('A', 'up2') is None
True
>>> repr(g.getTransitionRequirement('A', 'up'))
"ReqAll([ReqCapability('dash'), ReqCapability('slide')])"
>>> # Errors if destinations differ, or if something is missing
>>> g.mergeTransitions('A', 'down', 'up')
Traceback (most recent call last):
...
MissingTransitionError...
>>> g.mergeTransitions('Z', 'one', 'two')
Traceback (most recent call last):
...
MissingDecisionError...
>>> g.addDecision('C')
2
>>> g.addTransition('A', 'down', 'C')
>>> g.mergeTransitions('A', 'down', 'up')
Traceback (most recent call last):
...
InvalidDestinationError...
>>> # Merging a reciprocal onto an edge that doesn't have one
>>> g.addTransition('A', 'down2', 'C')
>>> g.addTransition('C', 'up2', 'A')
>>> g.setReciprocal('A', 'down2', 'up2')
>>> g.tagTransition('C', 'up2', 'narrow')
>>> g.getReciprocal('A', 'down') is None
True
>>> g.mergeTransitions('A', 'down2', 'down')
>>> g.getDestination('A', 'down2') is None
True
>>> g.getDestination('A', 'down')
2
>>> g.getDestination('C', 'up2')
0
>>> g.getReciprocal('A', 'down')
'up2'
>>> g.getReciprocal('C', 'up2')
'down'
>>> g.transitionTags('C', 'up2')
{'narrow': 1}
>>> # Merging without a reciprocal
>>> g.addTransition('C', 'up', 'A')
>>> g.mergeTransitions('C', 'up2', 'up', mergeReciprocal=False)
>>> g.getDestination('C', 'up2') is None
True
>>> g.getDestination('C', 'up')
0
>>> g.transitionTags('C', 'up') # tag gets merged
{'narrow': 1}
>>> g.getDestination('A', 'down')
2
>>> g.getReciprocal('A', 'down') is None
True
>>> g.getReciprocal('C', 'up') is None
True
>>> # Merging w/ normal reciprocals
>>> g.addDecision('D')
3
>>> g.addDecision('E')
4
>>> g.addTransition('D', 'up', 'E', 'return')
>>> g.addTransition('E', 'down', 'D')
>>> g.mergeTransitions('E', 'return', 'down')
>>> g.getDestination('D', 'up')
4
>>> g.getDestination('E', 'down')
3
>>> g.getDestination('E', 'return') is None
True
>>> g.getReciprocal('D', 'up')
'down'
>>> g.getReciprocal('E', 'down')
'up'
>>> # Merging w/ weird reciprocals
>>> g.addTransition('E', 'return', 'D')
>>> g.setReciprocal('E', 'return', 'up', setBoth=False)
>>> g.getReciprocal('D', 'up')
'down'
>>> g.getReciprocal('E', 'down')
'up'
>>> g.getReciprocal('E', 'return') # shared
'up'
>>> g.mergeTransitions('E', 'return', 'down')
>>> g.getDestination('D', 'up')
4
>>> g.getDestination('E', 'down')
3
>>> g.getDestination('E', 'return') is None
True
>>> g.getReciprocal('D', 'up')
'down'
>>> g.getReciprocal('E', 'down')
'up'
5575 def isConfirmed(self, decision: base.AnyDecisionSpecifier) -> bool: 5576 """ 5577 Returns `True` or `False` depending on whether or not the 5578 specified decision has been confirmed. Uses the presence or 5579 absence of the 'unconfirmed' tag to determine this. 5580 5581 Note: 'unconfirmed' is used instead of 'confirmed' so that large 5582 graphs with many confirmed nodes will be smaller when saved. 5583 """ 5584 dID = self.resolveDecision(decision) 5585 5586 return 'unconfirmed' not in self.nodes[dID]['tags']
Returns True
or False
depending on whether or not the
specified decision has been confirmed. Uses the presence or
absence of the 'unconfirmed' tag to determine this.
Note: 'unconfirmed' is used instead of 'confirmed' so that large graphs with many confirmed nodes will be smaller when saved.
5588 def replaceUnconfirmed( 5589 self, 5590 fromDecision: base.AnyDecisionSpecifier, 5591 transition: base.Transition, 5592 connectTo: Optional[base.AnyDecisionSpecifier] = None, 5593 reciprocal: Optional[base.Transition] = None, 5594 requirement: Optional[base.Requirement] = None, 5595 applyConsequence: Optional[base.Consequence] = None, 5596 placeInZone: Union[type[base.DefaultZone], base.Zone, None] = None, 5597 forceNew: bool = False, 5598 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 5599 annotations: Optional[List[base.Annotation]] = None, 5600 revRequires: Optional[base.Requirement] = None, 5601 revConsequence: Optional[base.Consequence] = None, 5602 revTags: Optional[Dict[base.Tag, base.TagValue]] = None, 5603 revAnnotations: Optional[List[base.Annotation]] = None, 5604 decisionTags: Optional[Dict[base.Tag, base.TagValue]] = None, 5605 decisionAnnotations: Optional[List[base.Annotation]] = None 5606 ) -> Tuple[ 5607 Dict[base.Transition, base.Transition], 5608 Dict[base.Transition, base.Transition] 5609 ]: 5610 """ 5611 Given a decision and an edge name in that decision, where the 5612 named edge leads to a decision with an unconfirmed exploration 5613 state (see `isConfirmed`), renames the unexplored decision on 5614 the other end of that edge using the given `connectTo` name, or 5615 if a decision using that name already exists, merges the 5616 unexplored decision into that decision. If `connectTo` is a 5617 `DecisionSpecifier` whose target doesn't exist, it will be 5618 treated as just a name, but if it's an ID and it doesn't exist, 5619 you'll get a `MissingDecisionError`. If a `reciprocal` is provided, 5620 a reciprocal edge will be added using that name connecting the 5621 `connectTo` decision back to the original decision. If this 5622 transition already exists, it must also point to a node which is 5623 also unexplored, and which will also be merged into the 5624 `fromDecision` node. 5625 5626 If `connectTo` is not given (or is set to `None` explicitly) 5627 then the name of the unexplored decision will not be changed, 5628 unless that name has the form `'_u.-n-'` where `-n-` is a positive 5629 integer (i.e., the form given to automatically-named unknown 5630 nodes). In that case, the name will be changed to `'_x.-n-'` using 5631 the same number, or a higher number if that name is already taken. 5632 5633 If the destination is being renamed or if the destination's 5634 exploration state counts as unexplored, the exploration state of 5635 the destination will be set to 'exploring'. 5636 5637 If a `placeInZone` is specified, the destination will be placed 5638 directly into that zone (even if it already existed and has zone 5639 information), and it will be removed from any other zones it had 5640 been a direct member of. If `placeInZone` is set to 5641 `DefaultZone`, then the destination will be placed into each zone 5642 which is a direct parent of the origin, but only if the 5643 destination is not an already-explored existing decision AND 5644 it is not already in any zones (in those cases no zone changes 5645 are made). This will also remove it from any previous zones it 5646 had been a part of. If `placeInZone` is left as `None` (the 5647 default) no zone changes are made. 5648 5649 If `placeInZone` is specified and that zone didn't already exist, 5650 it will be created as a new level-0 zone and will be added as a 5651 sub-zone of each zone that's a direct parent of any level-0 zone 5652 that the origin is a member of. 5653 5654 If `forceNew` is specified, then the destination will just be 5655 renamed, even if another decision with the same name already 5656 exists. It's an error to use `forceNew` with a decision ID as 5657 the destination. 5658 5659 Any additional edges pointing to or from the unknown node(s) 5660 being replaced will also be re-targeted at the now-discovered 5661 known destination(s) if necessary. These edges will retain their 5662 reciprocal names, or if this would cause a name clash, they will 5663 be renamed with a suffix (see `retargetTransition`). 5664 5665 The return value is a pair of dictionaries mapping old names to 5666 new ones that just includes the names which were changed. The 5667 first dictionary contains renamed transitions that are outgoing 5668 from the new destination node (which used to be outgoing from 5669 the unexplored node). The second dictionary contains renamed 5670 transitions that are outgoing from the source node (which used 5671 to be outgoing from the unexplored node attached to the 5672 reciprocal transition; if there was no reciprocal transition 5673 specified then this will always be an empty dictionary). 5674 5675 An `ExplorationStatusError` will be raised if the destination 5676 of the specified transition counts as visited (see 5677 `hasBeenVisited`). An `ExplorationStatusError` will also be 5678 raised if the `connectTo`'s `reciprocal` transition does not lead 5679 to an unconfirmed decision (it's okay if this second transition 5680 doesn't exist). A `TransitionCollisionError` will be raised if 5681 the unconfirmed destination decision already has an outgoing 5682 transition with the specified `reciprocal` which does not lead 5683 back to the `fromDecision`. 5684 5685 The transition properties (requirement, consequences, tags, 5686 and/or annotations) of the replaced transition will be copied 5687 over to the new transition. Transition properties from the 5688 reciprocal transition will also be copied for the newly created 5689 reciprocal edge. Properties for any additional edges to/from the 5690 unknown node will also be copied. 5691 5692 Also, any transition properties on existing forward or reciprocal 5693 edges from the destination node with the indicated reverse name 5694 will be merged with those from the target transition. Note that 5695 this merging process may introduce corruption of complex 5696 transition consequences. TODO: Fix that! 5697 5698 Any tags and annotations are added to copied tags/annotations, 5699 but specified requirements, and/or consequences will replace 5700 previous requirements/consequences, rather than being added to 5701 them. 5702 5703 ## Example 5704 5705 >>> g = DecisionGraph() 5706 >>> g.addDecision('A') 5707 0 5708 >>> g.addUnexploredEdge('A', 'up') 5709 1 5710 >>> g.destination('A', 'up') 5711 1 5712 >>> g.destination('_u.0', 'return') 5713 0 5714 >>> g.replaceUnconfirmed('A', 'up', 'B', 'down') 5715 ({}, {}) 5716 >>> g.destination('A', 'up') 5717 1 5718 >>> g.nameFor(1) 5719 'B' 5720 >>> g.destination('B', 'down') 5721 0 5722 >>> g.getDestination('B', 'return') is None 5723 True 5724 >>> '_u.0' in g.nameLookup 5725 False 5726 >>> g.getReciprocal('A', 'up') 5727 'down' 5728 >>> g.getReciprocal('B', 'down') 5729 'up' 5730 >>> # Two unexplored edges to the same node: 5731 >>> g.addDecision('C') 5732 2 5733 >>> g.addTransition('B', 'next', 'C') 5734 >>> g.addTransition('C', 'prev', 'B') 5735 >>> g.setReciprocal('B', 'next', 'prev') 5736 >>> g.addUnexploredEdge('A', 'next', 'D', 'prev') 5737 3 5738 >>> g.addTransition('C', 'down', 'D') 5739 >>> g.addTransition('D', 'up', 'C') 5740 >>> g.setReciprocal('C', 'down', 'up') 5741 >>> g.replaceUnconfirmed('C', 'down') 5742 ({}, {}) 5743 >>> g.destination('C', 'down') 5744 3 5745 >>> g.destination('A', 'next') 5746 3 5747 >>> g.destinationsFrom('D') 5748 {'prev': 0, 'up': 2} 5749 >>> g.decisionTags('D') 5750 {} 5751 >>> # An unexplored transition which turns out to connect to a 5752 >>> # known decision, with name collisions 5753 >>> g.addUnexploredEdge('D', 'next', reciprocal='prev') 5754 4 5755 >>> g.tagDecision('_u.2', 'wet') 5756 >>> g.addUnexploredEdge('B', 'next', reciprocal='prev') # edge taken 5757 Traceback (most recent call last): 5758 ... 5759 exploration.core.TransitionCollisionError... 5760 >>> g.addUnexploredEdge('A', 'prev', reciprocal='next') 5761 5 5762 >>> g.tagDecision('_u.3', 'dry') 5763 >>> # Add transitions that will collide when merged 5764 >>> g.addUnexploredEdge('_u.2', 'up') # collides with A/up 5765 6 5766 >>> g.addUnexploredEdge('_u.3', 'prev') # collides with D/prev 5767 7 5768 >>> g.getReciprocal('A', 'prev') 5769 'next' 5770 >>> g.replaceUnconfirmed('A', 'prev', 'D', 'next') # two gone 5771 ({'prev': 'prev.1'}, {'up': 'up.1'}) 5772 >>> g.destination('A', 'prev') 5773 3 5774 >>> g.destination('D', 'next') 5775 0 5776 >>> g.getReciprocal('A', 'prev') 5777 'next' 5778 >>> g.getReciprocal('D', 'next') 5779 'prev' 5780 >>> # Note that further unexplored structures are NOT merged 5781 >>> # even if they match against existing structures... 5782 >>> g.destination('A', 'up.1') 5783 6 5784 >>> g.destination('D', 'prev.1') 5785 7 5786 >>> '_u.2' in g.nameLookup 5787 False 5788 >>> '_u.3' in g.nameLookup 5789 False 5790 >>> g.decisionTags('D') # tags are merged 5791 {'dry': 1} 5792 >>> g.decisionTags('A') 5793 {'wet': 1} 5794 >>> # Auto-renaming an anonymous unexplored node 5795 >>> g.addUnexploredEdge('B', 'out') 5796 8 5797 >>> g.replaceUnconfirmed('B', 'out') 5798 ({}, {}) 5799 >>> '_u.6' in g 5800 False 5801 >>> g.destination('B', 'out') 5802 8 5803 >>> g.nameFor(8) 5804 '_x.6' 5805 >>> g.destination('_x.6', 'return') 5806 1 5807 >>> # Placing a node into a zone 5808 >>> g.addUnexploredEdge('B', 'through') 5809 9 5810 >>> g.getDecision('E') is None 5811 True 5812 >>> g.replaceUnconfirmed( 5813 ... 'B', 5814 ... 'through', 5815 ... 'E', 5816 ... 'back', 5817 ... placeInZone='Zone' 5818 ... ) 5819 ({}, {}) 5820 >>> g.getDecision('E') 5821 9 5822 >>> g.destination('B', 'through') 5823 9 5824 >>> g.destination('E', 'back') 5825 1 5826 >>> g.zoneParents(9) 5827 {'Zone'} 5828 >>> g.addUnexploredEdge('E', 'farther') 5829 10 5830 >>> g.replaceUnconfirmed( 5831 ... 'E', 5832 ... 'farther', 5833 ... 'F', 5834 ... 'closer', 5835 ... placeInZone=base.DefaultZone 5836 ... ) 5837 ({}, {}) 5838 >>> g.destination('E', 'farther') 5839 10 5840 >>> g.destination('F', 'closer') 5841 9 5842 >>> g.zoneParents(10) 5843 {'Zone'} 5844 >>> g.addUnexploredEdge('F', 'backwards', placeInZone='Enoz') 5845 11 5846 >>> g.replaceUnconfirmed( 5847 ... 'F', 5848 ... 'backwards', 5849 ... 'G', 5850 ... 'forwards', 5851 ... placeInZone=base.DefaultZone 5852 ... ) 5853 ({}, {}) 5854 >>> g.destination('F', 'backwards') 5855 11 5856 >>> g.destination('G', 'forwards') 5857 10 5858 >>> g.zoneParents(11) # not changed since it already had a zone 5859 {'Enoz'} 5860 >>> # TODO: forceNew example 5861 """ 5862 5863 # Defaults 5864 if tags is None: 5865 tags = {} 5866 if annotations is None: 5867 annotations = [] 5868 if revTags is None: 5869 revTags = {} 5870 if revAnnotations is None: 5871 revAnnotations = [] 5872 if decisionTags is None: 5873 decisionTags = {} 5874 if decisionAnnotations is None: 5875 decisionAnnotations = [] 5876 5877 # Resolve source 5878 fromID = self.resolveDecision(fromDecision) 5879 5880 # Figure out destination decision 5881 oldUnexplored = self.destination(fromID, transition) 5882 if self.isConfirmed(oldUnexplored): 5883 raise ExplorationStatusError( 5884 f"Transition {transition!r} from" 5885 f" {self.identityOf(fromDecision)} does not lead to an" 5886 f" unconfirmed decision (it leads to" 5887 f" {self.identityOf(oldUnexplored)} which is not tagged" 5888 f" 'unconfirmed')." 5889 ) 5890 5891 # Resolve destination 5892 newName: Optional[base.DecisionName] = None 5893 connectID: Optional[base.DecisionID] = None 5894 if forceNew: 5895 if isinstance(connectTo, base.DecisionID): 5896 raise TypeError( 5897 f"connectTo cannot be a decision ID when forceNew" 5898 f" is True. Got: {self.identityOf(connectTo)}" 5899 ) 5900 elif isinstance(connectTo, base.DecisionSpecifier): 5901 newName = connectTo.name 5902 elif isinstance(connectTo, base.DecisionName): 5903 newName = connectTo 5904 elif connectTo is None: 5905 oldName = self.nameFor(oldUnexplored) 5906 if ( 5907 oldName.startswith('_u.') 5908 and oldName[3:].isdigit() 5909 ): 5910 newName = utils.uniqueName('_x.' + oldName[3:], self) 5911 else: 5912 newName = oldName 5913 else: 5914 raise TypeError( 5915 f"Invalid connectTo value: {connectTo!r}" 5916 ) 5917 elif connectTo is not None: 5918 try: 5919 connectID = self.resolveDecision(connectTo) 5920 # leave newName as None 5921 except MissingDecisionError: 5922 if isinstance(connectTo, int): 5923 raise 5924 elif isinstance(connectTo, base.DecisionSpecifier): 5925 newName = connectTo.name 5926 # The domain & zone are ignored here 5927 else: # Must just be a string 5928 assert isinstance(connectTo, str) 5929 newName = connectTo 5930 else: 5931 # If connectTo name wasn't specified, use current name of 5932 # unknown node unless it's a default name 5933 oldName = self.nameFor(oldUnexplored) 5934 if ( 5935 oldName.startswith('_u.') 5936 and oldName[3:].isdigit() 5937 ): 5938 newName = utils.uniqueName('_x.' + oldName[3:], self) 5939 else: 5940 newName = oldName 5941 5942 # One or the other should be valid at this point 5943 assert connectID is not None or newName is not None 5944 5945 # Check that the old unknown doesn't have a reciprocal edge that 5946 # would collide with the specified return edge 5947 if reciprocal is not None: 5948 revFromUnknown = self.getDestination(oldUnexplored, reciprocal) 5949 if revFromUnknown not in (None, fromID): 5950 raise TransitionCollisionError( 5951 f"Transition {reciprocal!r} from" 5952 f" {self.identityOf(oldUnexplored)} exists and does" 5953 f" not lead back to {self.identityOf(fromDecision)}" 5954 f" (it leads to {self.identityOf(revFromUnknown)})." 5955 ) 5956 5957 # Remember old reciprocal edge for future merging in case 5958 # it's not reciprocal 5959 oldReciprocal = self.getReciprocal(fromID, transition) 5960 5961 # Apply any new tags or annotations, or create a new node 5962 needsZoneInfo = False 5963 if connectID is not None: 5964 # Before applying tags, check if we need to error out 5965 # because of a reciprocal edge that points to a known 5966 # destination: 5967 if reciprocal is not None: 5968 otherOldUnknown: Optional[ 5969 base.DecisionID 5970 ] = self.getDestination( 5971 connectID, 5972 reciprocal 5973 ) 5974 if ( 5975 otherOldUnknown is not None 5976 and self.isConfirmed(otherOldUnknown) 5977 ): 5978 raise ExplorationStatusError( 5979 f"Reciprocal transition {reciprocal!r} from" 5980 f" {self.identityOf(connectTo)} does not lead" 5981 f" to an unconfirmed decision (it leads to" 5982 f" {self.identityOf(otherOldUnknown)})." 5983 ) 5984 self.tagDecision(connectID, decisionTags) 5985 self.annotateDecision(connectID, decisionAnnotations) 5986 # Still needs zone info if the place we're connecting to was 5987 # unconfirmed up until now, since unconfirmed nodes don't 5988 # normally get zone info when they're created. 5989 if not self.isConfirmed(connectID): 5990 needsZoneInfo = True 5991 5992 # First, merge the old unknown with the connectTo node... 5993 destRenames = self.mergeDecisions( 5994 oldUnexplored, 5995 connectID, 5996 errorOnNameColision=False 5997 ) 5998 else: 5999 needsZoneInfo = True 6000 if len(self.zoneParents(oldUnexplored)) > 0: 6001 needsZoneInfo = False 6002 assert newName is not None 6003 self.renameDecision(oldUnexplored, newName) 6004 connectID = oldUnexplored 6005 # In this case there can't be an other old unknown 6006 otherOldUnknown = None 6007 destRenames = {} # empty 6008 6009 # Check for domain mismatch to stifle zone updates: 6010 fromDomain = self.domainFor(fromID) 6011 if connectID is None: 6012 destDomain = self.domainFor(oldUnexplored) 6013 else: 6014 destDomain = self.domainFor(connectID) 6015 6016 # Stifle zone updates if there's a mismatch 6017 if fromDomain != destDomain: 6018 needsZoneInfo = False 6019 6020 # Records renames that happen at the source (from node) 6021 sourceRenames = {} # empty for now 6022 6023 assert connectID is not None 6024 6025 # Apply the new zone if there is one 6026 if placeInZone is not None: 6027 if placeInZone is base.DefaultZone: 6028 # When using DefaultZone, changes are only made for new 6029 # destinations which don't already have any zones and 6030 # which are in the same domain as the departing node: 6031 # they get placed into each zone parent of the source 6032 # decision. 6033 if needsZoneInfo: 6034 # Remove destination from all current parents 6035 removeFrom = set(self.zoneParents(connectID)) # copy 6036 for parent in removeFrom: 6037 self.removeDecisionFromZone(connectID, parent) 6038 # Add it to parents of origin 6039 for parent in self.zoneParents(fromID): 6040 self.addDecisionToZone(connectID, parent) 6041 else: 6042 placeInZone = cast(base.Zone, placeInZone) 6043 # Create the zone if it doesn't already exist 6044 if self.getZoneInfo(placeInZone) is None: 6045 self.createZone(placeInZone, 0) 6046 # Add it to each grandparent of the from decision 6047 for parent in self.zoneParents(fromID): 6048 for grandparent in self.zoneParents(parent): 6049 self.addZoneToZone(placeInZone, grandparent) 6050 # Remove destination from all current parents 6051 for parent in set(self.zoneParents(connectID)): 6052 self.removeDecisionFromZone(connectID, parent) 6053 # Add it to the specified zone 6054 self.addDecisionToZone(connectID, placeInZone) 6055 6056 # Next, if there is a reciprocal name specified, we do more... 6057 if reciprocal is not None: 6058 # Figure out what kind of merging needs to happen 6059 if otherOldUnknown is None: 6060 if revFromUnknown is None: 6061 # Just create the desired reciprocal transition, which 6062 # we know does not already exist 6063 self.addTransition(connectID, reciprocal, fromID) 6064 otherOldReciprocal = None 6065 else: 6066 # Reciprocal exists, as revFromUnknown 6067 otherOldReciprocal = None 6068 else: 6069 otherOldReciprocal = self.getReciprocal( 6070 connectID, 6071 reciprocal 6072 ) 6073 # we need to merge otherOldUnknown into our fromDecision 6074 sourceRenames = self.mergeDecisions( 6075 otherOldUnknown, 6076 fromID, 6077 errorOnNameColision=False 6078 ) 6079 # Unvisited tag after merge only if both were 6080 6081 # No matter what happened we ensure the reciprocal 6082 # relationship is set up: 6083 self.setReciprocal(fromID, transition, reciprocal) 6084 6085 # Now we might need to merge some transitions: 6086 # - Any reciprocal of the target transition should be merged 6087 # with reciprocal (if it was already reciprocal, that's a 6088 # no-op). 6089 # - Any reciprocal of the reciprocal transition from the target 6090 # node (leading to otherOldUnknown) should be merged with 6091 # the target transition, even if it shared a name and was 6092 # renamed as a result. 6093 # - If reciprocal was renamed during the initial merge, those 6094 # transitions should be merged. 6095 6096 # Merge old reciprocal into reciprocal 6097 if oldReciprocal is not None: 6098 oldRev = destRenames.get(oldReciprocal, oldReciprocal) 6099 if self.getDestination(connectID, oldRev) is not None: 6100 # Note that we don't want to auto-merge the reciprocal, 6101 # which is the target transition 6102 self.mergeTransitions( 6103 connectID, 6104 oldRev, 6105 reciprocal, 6106 mergeReciprocal=False 6107 ) 6108 # Remove it from the renames map 6109 if oldReciprocal in destRenames: 6110 del destRenames[oldReciprocal] 6111 6112 # Merge reciprocal reciprocal from otherOldUnknown 6113 if otherOldReciprocal is not None: 6114 otherOldRev = sourceRenames.get( 6115 otherOldReciprocal, 6116 otherOldReciprocal 6117 ) 6118 # Note that the reciprocal is reciprocal, which we don't 6119 # need to merge 6120 self.mergeTransitions( 6121 fromID, 6122 otherOldRev, 6123 transition, 6124 mergeReciprocal=False 6125 ) 6126 # Remove it from the renames map 6127 if otherOldReciprocal in sourceRenames: 6128 del sourceRenames[otherOldReciprocal] 6129 6130 # Merge any renamed reciprocal onto reciprocal 6131 if reciprocal in destRenames: 6132 extraRev = destRenames[reciprocal] 6133 self.mergeTransitions( 6134 connectID, 6135 extraRev, 6136 reciprocal, 6137 mergeReciprocal=False 6138 ) 6139 # Remove it from the renames map 6140 del destRenames[reciprocal] 6141 6142 # Accumulate new tags & annotations for the transitions 6143 self.tagTransition(fromID, transition, tags) 6144 self.annotateTransition(fromID, transition, annotations) 6145 6146 if reciprocal is not None: 6147 self.tagTransition(connectID, reciprocal, revTags) 6148 self.annotateTransition(connectID, reciprocal, revAnnotations) 6149 6150 # Override copied requirement/consequences for the transitions 6151 if requirement is not None: 6152 self.setTransitionRequirement( 6153 fromID, 6154 transition, 6155 requirement 6156 ) 6157 if applyConsequence is not None: 6158 self.setConsequence( 6159 fromID, 6160 transition, 6161 applyConsequence 6162 ) 6163 6164 if reciprocal is not None: 6165 if revRequires is not None: 6166 self.setTransitionRequirement( 6167 connectID, 6168 reciprocal, 6169 revRequires 6170 ) 6171 if revConsequence is not None: 6172 self.setConsequence( 6173 connectID, 6174 reciprocal, 6175 revConsequence 6176 ) 6177 6178 # Remove 'unconfirmed' tag if it was present 6179 self.untagDecision(connectID, 'unconfirmed') 6180 6181 # Final checks 6182 assert self.getDestination(fromDecision, transition) == connectID 6183 useConnect: base.AnyDecisionSpecifier 6184 useRev: Optional[str] 6185 if connectTo is None: 6186 useConnect = connectID 6187 else: 6188 useConnect = connectTo 6189 if reciprocal is None: 6190 useRev = self.getReciprocal(fromDecision, transition) 6191 else: 6192 useRev = reciprocal 6193 if useRev is not None: 6194 try: 6195 assert self.getDestination(useConnect, useRev) == fromID 6196 except AmbiguousDecisionSpecifierError: 6197 assert self.getDestination(connectID, useRev) == fromID 6198 6199 # Return our final rename dictionaries 6200 return (destRenames, sourceRenames)
Given a decision and an edge name in that decision, where the
named edge leads to a decision with an unconfirmed exploration
state (see isConfirmed
), renames the unexplored decision on
the other end of that edge using the given connectTo
name, or
if a decision using that name already exists, merges the
unexplored decision into that decision. If connectTo
is a
DecisionSpecifier
whose target doesn't exist, it will be
treated as just a name, but if it's an ID and it doesn't exist,
you'll get a MissingDecisionError
. If a reciprocal
is provided,
a reciprocal edge will be added using that name connecting the
connectTo
decision back to the original decision. If this
transition already exists, it must also point to a node which is
also unexplored, and which will also be merged into the
fromDecision
node.
If connectTo
is not given (or is set to None
explicitly)
then the name of the unexplored decision will not be changed,
unless that name has the form '_u.-n-'
where -n-
is a positive
integer (i.e., the form given to automatically-named unknown
nodes). In that case, the name will be changed to '_x.-n-'
using
the same number, or a higher number if that name is already taken.
If the destination is being renamed or if the destination's exploration state counts as unexplored, the exploration state of the destination will be set to 'exploring'.
If a placeInZone
is specified, the destination will be placed
directly into that zone (even if it already existed and has zone
information), and it will be removed from any other zones it had
been a direct member of. If placeInZone
is set to
DefaultZone
, then the destination will be placed into each zone
which is a direct parent of the origin, but only if the
destination is not an already-explored existing decision AND
it is not already in any zones (in those cases no zone changes
are made). This will also remove it from any previous zones it
had been a part of. If placeInZone
is left as None
(the
default) no zone changes are made.
If placeInZone
is specified and that zone didn't already exist,
it will be created as a new level-0 zone and will be added as a
sub-zone of each zone that's a direct parent of any level-0 zone
that the origin is a member of.
If forceNew
is specified, then the destination will just be
renamed, even if another decision with the same name already
exists. It's an error to use forceNew
with a decision ID as
the destination.
Any additional edges pointing to or from the unknown node(s)
being replaced will also be re-targeted at the now-discovered
known destination(s) if necessary. These edges will retain their
reciprocal names, or if this would cause a name clash, they will
be renamed with a suffix (see retargetTransition
).
The return value is a pair of dictionaries mapping old names to new ones that just includes the names which were changed. The first dictionary contains renamed transitions that are outgoing from the new destination node (which used to be outgoing from the unexplored node). The second dictionary contains renamed transitions that are outgoing from the source node (which used to be outgoing from the unexplored node attached to the reciprocal transition; if there was no reciprocal transition specified then this will always be an empty dictionary).
An ExplorationStatusError
will be raised if the destination
of the specified transition counts as visited (see
hasBeenVisited
). An ExplorationStatusError
will also be
raised if the connectTo
's reciprocal
transition does not lead
to an unconfirmed decision (it's okay if this second transition
doesn't exist). A TransitionCollisionError
will be raised if
the unconfirmed destination decision already has an outgoing
transition with the specified reciprocal
which does not lead
back to the fromDecision
.
The transition properties (requirement, consequences, tags, and/or annotations) of the replaced transition will be copied over to the new transition. Transition properties from the reciprocal transition will also be copied for the newly created reciprocal edge. Properties for any additional edges to/from the unknown node will also be copied.
Also, any transition properties on existing forward or reciprocal edges from the destination node with the indicated reverse name will be merged with those from the target transition. Note that this merging process may introduce corruption of complex transition consequences. TODO: Fix that!
Any tags and annotations are added to copied tags/annotations, but specified requirements, and/or consequences will replace previous requirements/consequences, rather than being added to them.
Example
>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addUnexploredEdge('A', 'up')
1
>>> g.destination('A', 'up')
1
>>> g.destination('_u.0', 'return')
0
>>> g.replaceUnconfirmed('A', 'up', 'B', 'down')
({}, {})
>>> g.destination('A', 'up')
1
>>> g.nameFor(1)
'B'
>>> g.destination('B', 'down')
0
>>> g.getDestination('B', 'return') is None
True
>>> '_u.0' in g.nameLookup
False
>>> g.getReciprocal('A', 'up')
'down'
>>> g.getReciprocal('B', 'down')
'up'
>>> # Two unexplored edges to the same node:
>>> g.addDecision('C')
2
>>> g.addTransition('B', 'next', 'C')
>>> g.addTransition('C', 'prev', 'B')
>>> g.setReciprocal('B', 'next', 'prev')
>>> g.addUnexploredEdge('A', 'next', 'D', 'prev')
3
>>> g.addTransition('C', 'down', 'D')
>>> g.addTransition('D', 'up', 'C')
>>> g.setReciprocal('C', 'down', 'up')
>>> g.replaceUnconfirmed('C', 'down')
({}, {})
>>> g.destination('C', 'down')
3
>>> g.destination('A', 'next')
3
>>> g.destinationsFrom('D')
{'prev': 0, 'up': 2}
>>> g.decisionTags('D')
{}
>>> # An unexplored transition which turns out to connect to a
>>> # known decision, with name collisions
>>> g.addUnexploredEdge('D', 'next', reciprocal='prev')
4
>>> g.tagDecision('_u.2', 'wet')
>>> g.addUnexploredEdge('B', 'next', reciprocal='prev') # edge taken
Traceback (most recent call last):
...
TransitionCollisionError...
>>> g.addUnexploredEdge('A', 'prev', reciprocal='next')
5
>>> g.tagDecision('_u.3', 'dry')
>>> # Add transitions that will collide when merged
>>> g.addUnexploredEdge('_u.2', 'up') # collides with A/up
6
>>> g.addUnexploredEdge('_u.3', 'prev') # collides with D/prev
7
>>> g.getReciprocal('A', 'prev')
'next'
>>> g.replaceUnconfirmed('A', 'prev', 'D', 'next') # two gone
({'prev': 'prev.1'}, {'up': 'up.1'})
>>> g.destination('A', 'prev')
3
>>> g.destination('D', 'next')
0
>>> g.getReciprocal('A', 'prev')
'next'
>>> g.getReciprocal('D', 'next')
'prev'
>>> # Note that further unexplored structures are NOT merged
>>> # even if they match against existing structures...
>>> g.destination('A', 'up.1')
6
>>> g.destination('D', 'prev.1')
7
>>> '_u.2' in g.nameLookup
False
>>> '_u.3' in g.nameLookup
False
>>> g.decisionTags('D') # tags are merged
{'dry': 1}
>>> g.decisionTags('A')
{'wet': 1}
>>> # Auto-renaming an anonymous unexplored node
>>> g.addUnexploredEdge('B', 'out')
8
>>> g.replaceUnconfirmed('B', 'out')
({}, {})
>>> '_u.6' in g
False
>>> g.destination('B', 'out')
8
>>> g.nameFor(8)
'_x.6'
>>> g.destination('_x.6', 'return')
1
>>> # Placing a node into a zone
>>> g.addUnexploredEdge('B', 'through')
9
>>> g.getDecision('E') is None
True
>>> g.replaceUnconfirmed(
... 'B',
... 'through',
... 'E',
... 'back',
... placeInZone='Zone'
... )
({}, {})
>>> g.getDecision('E')
9
>>> g.destination('B', 'through')
9
>>> g.destination('E', 'back')
1
>>> g.zoneParents(9)
{'Zone'}
>>> g.addUnexploredEdge('E', 'farther')
10
>>> g.replaceUnconfirmed(
... 'E',
... 'farther',
... 'F',
... 'closer',
... placeInZone=base.DefaultZone
... )
({}, {})
>>> g.destination('E', 'farther')
10
>>> g.destination('F', 'closer')
9
>>> g.zoneParents(10)
{'Zone'}
>>> g.addUnexploredEdge('F', 'backwards', placeInZone='Enoz')
11
>>> g.replaceUnconfirmed(
... 'F',
... 'backwards',
... 'G',
... 'forwards',
... placeInZone=base.DefaultZone
... )
({}, {})
>>> g.destination('F', 'backwards')
11
>>> g.destination('G', 'forwards')
10
>>> g.zoneParents(11) # not changed since it already had a zone
{'Enoz'}
>>> # TODO: forceNew example
6202 def endingID(self, name: base.DecisionName) -> base.DecisionID: 6203 """ 6204 Returns the decision ID for the ending with the specified name. 6205 Endings are disconnected decisions in the `ENDINGS_DOMAIN`; they 6206 don't normally include any zone information. If no ending with 6207 the specified name already existed, then a new ending with that 6208 name will be created and its Decision ID will be returned. 6209 6210 If a new decision is created, it will be tagged as unconfirmed. 6211 6212 Note that endings mostly aren't special: they're normal 6213 decisions in a separate singular-focalized domain. However, some 6214 parts of the exploration and journal machinery treat them 6215 differently (in particular, taking certain actions via 6216 `advanceSituation` while any decision in the `ENDINGS_DOMAIN` is 6217 active is an error. 6218 """ 6219 # Create our new ending decision if we need to 6220 try: 6221 endID = self.resolveDecision( 6222 base.DecisionSpecifier(ENDINGS_DOMAIN, None, name) 6223 ) 6224 except MissingDecisionError: 6225 # Create a new decision for the ending 6226 endID = self.addDecision(name, domain=ENDINGS_DOMAIN) 6227 # Tag it as unconfirmed 6228 self.tagDecision(endID, 'unconfirmed') 6229 6230 return endID
Returns the decision ID for the ending with the specified name.
Endings are disconnected decisions in the ENDINGS_DOMAIN
; they
don't normally include any zone information. If no ending with
the specified name already existed, then a new ending with that
name will be created and its Decision ID will be returned.
If a new decision is created, it will be tagged as unconfirmed.
Note that endings mostly aren't special: they're normal
decisions in a separate singular-focalized domain. However, some
parts of the exploration and journal machinery treat them
differently (in particular, taking certain actions via
advanceSituation
while any decision in the ENDINGS_DOMAIN
is
active is an error.
6232 def triggerGroupID(self, name: base.DecisionName) -> base.DecisionID: 6233 """ 6234 Given the name of a trigger group, returns the ID of the special 6235 node representing that trigger group in the `TRIGGERS_DOMAIN`. 6236 If the specified group didn't already exist, it will be created. 6237 6238 Trigger group decisions are not special: they just exist in a 6239 separate spreading-focalized domain and have a few API methods to 6240 access them, but all the normal decision-related API methods 6241 still work. Their intended use is for sets of global triggers, 6242 by attaching actions with the 'trigger' tag to them and then 6243 activating or deactivating them as needed. 6244 """ 6245 result = self.getDecision( 6246 base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name) 6247 ) 6248 if result is None: 6249 return self.addDecision(name, domain=TRIGGERS_DOMAIN) 6250 else: 6251 return result
Given the name of a trigger group, returns the ID of the special
node representing that trigger group in the TRIGGERS_DOMAIN
.
If the specified group didn't already exist, it will be created.
Trigger group decisions are not special: they just exist in a separate spreading-focalized domain and have a few API methods to access them, but all the normal decision-related API methods still work. Their intended use is for sets of global triggers, by attaching actions with the 'trigger' tag to them and then activating or deactivating them as needed.
6253 @staticmethod 6254 def example(which: Literal['simple', 'abc']) -> 'DecisionGraph': 6255 """ 6256 Returns one of a number of example decision graphs, depending on 6257 the string given. It returns a fresh copy each time. The graphs 6258 are: 6259 6260 - 'simple': Three nodes named 'A', 'B', and 'C' with IDs 0, 1, 6261 and 2, each connected to the next in the sequence by a 6262 'next' transition with reciprocal 'prev'. In other words, a 6263 simple little triangle. There are no tags, annotations, 6264 requirements, consequences, mechanisms, or equivalences. 6265 - 'abc': A more complicated 3-node setup that introduces a 6266 little bit of everything. In this graph, we have the same 6267 three nodes, but different transitions: 6268 6269 * From A you can go 'left' to B with reciprocal 'right'. 6270 * From A you can also go 'up_left' to B with reciprocal 6271 'up_right'. These transitions both require the 6272 'grate' mechanism (which is at decision A) to be in 6273 state 'open'. 6274 * From A you can go 'down' to C with reciprocal 'up'. 6275 6276 (In this graph, B and C are not directly connected to each 6277 other.) 6278 6279 The graph has two level-0 zones 'zoneA' and 'zoneB', along 6280 with a level-1 zone 'upZone'. Decisions A and C are in 6281 zoneA while B is in zoneB; zoneA is in upZone, but zoneB is 6282 not. 6283 6284 The decision A has annotation: 6285 6286 'This is a multi-word "annotation."' 6287 6288 The transition 'down' from A has annotation: 6289 6290 "Transition 'annotation.'" 6291 6292 Decision B has tags 'b' with value 1 and 'tag2' with value 6293 '"value"'. 6294 6295 Decision C has tag 'aw"ful' with value "ha'ha'". 6296 6297 Transition 'up' from C has tag 'fast' with value 1. 6298 6299 At decision C there are actions 'grab_helmet' and 6300 'pull_lever'. 6301 6302 The 'grab_helmet' transition requires that you don't have 6303 the 'helmet' capability, and gives you that capability, 6304 deactivating with delay 3. 6305 6306 The 'pull_lever' transition requires that you do have the 6307 'helmet' capability, and takes away that capability, but it 6308 also gives you 1 token, and if you have 2 tokens (before 6309 getting the one extra), it sets the 'grate' mechanism (which 6310 is a decision A) to state 'open' and deactivates. 6311 6312 The graph has an equivalence: having the 'helmet' capability 6313 satisfies requirements for the 'grate' mechanism to be in the 6314 'open' state. 6315 6316 """ 6317 result = DecisionGraph() 6318 if which == 'simple': 6319 result.addDecision('A') # id 0 6320 result.addDecision('B') # id 1 6321 result.addDecision('C') # id 2 6322 result.addTransition('A', 'next', 'B', 'prev') 6323 result.addTransition('B', 'next', 'C', 'prev') 6324 result.addTransition('C', 'next', 'A', 'prev') 6325 elif which == 'abc': 6326 result.addDecision('A') # id 0 6327 result.addDecision('B') # id 1 6328 result.addDecision('C') # id 2 6329 result.createZone('zoneA', 0) 6330 result.createZone('zoneB', 0) 6331 result.createZone('upZone', 1) 6332 result.addZoneToZone('zoneA', 'upZone') 6333 result.addDecisionToZone('A', 'zoneA') 6334 result.addDecisionToZone('B', 'zoneB') 6335 result.addDecisionToZone('C', 'zoneA') 6336 result.addTransition('A', 'left', 'B', 'right') 6337 result.addTransition('A', 'up_left', 'B', 'up_right') 6338 result.addTransition('A', 'down', 'C', 'up') 6339 result.setTransitionRequirement( 6340 'A', 6341 'up_left', 6342 base.ReqMechanism('grate', 'open') 6343 ) 6344 result.setTransitionRequirement( 6345 'B', 6346 'up_right', 6347 base.ReqMechanism('grate', 'open') 6348 ) 6349 result.annotateDecision('A', 'This is a multi-word "annotation."') 6350 result.annotateTransition('A', 'down', "Transition 'annotation.'") 6351 result.tagDecision('B', 'b') 6352 result.tagDecision('B', 'tag2', '"value"') 6353 result.tagDecision('C', 'aw"ful', "ha'ha") 6354 result.tagTransition('C', 'up', 'fast') 6355 result.addMechanism('grate', 'A') 6356 result.addAction( 6357 'C', 6358 'grab_helmet', 6359 base.ReqNot(base.ReqCapability('helmet')), 6360 [ 6361 base.effect(gain='helmet'), 6362 base.effect(deactivate=True, delay=3) 6363 ] 6364 ) 6365 result.addAction( 6366 'C', 6367 'pull_lever', 6368 base.ReqCapability('helmet'), 6369 [ 6370 base.effect(lose='helmet'), 6371 base.effect(gain=('token', 1)), 6372 base.condition( 6373 base.ReqTokens('token', 2), 6374 [ 6375 base.effect(set=('grate', 'open')), 6376 base.effect(deactivate=True) 6377 ] 6378 ) 6379 ] 6380 ) 6381 result.addEquivalence( 6382 base.ReqCapability('helmet'), 6383 (0, 'open') 6384 ) 6385 else: 6386 raise ValueError(f"Invalid example name: {which!r}") 6387 6388 return result
Returns one of a number of example decision graphs, depending on the string given. It returns a fresh copy each time. The graphs are:
- 'simple': Three nodes named 'A', 'B', and 'C' with IDs 0, 1, and 2, each connected to the next in the sequence by a 'next' transition with reciprocal 'prev'. In other words, a simple little triangle. There are no tags, annotations, requirements, consequences, mechanisms, or equivalences.
'abc': A more complicated 3-node setup that introduces a little bit of everything. In this graph, we have the same three nodes, but different transitions:
* From A you can go 'left' to B with reciprocal 'right'. * From A you can also go 'up_left' to B with reciprocal 'up_right'. These transitions both require the 'grate' mechanism (which is at decision A) to be in state 'open'. * From A you can go 'down' to C with reciprocal 'up'.
(In this graph, B and C are not directly connected to each other.)
The graph has two level-0 zones 'zoneA' and 'zoneB', along with a level-1 zone 'upZone'. Decisions A and C are in zoneA while B is in zoneB; zoneA is in upZone, but zoneB is not.
The decision A has annotation:
'This is a multi-word "annotation."'
The transition 'down' from A has annotation:
"Transition 'annotation.'"
Decision B has tags 'b' with value 1 and 'tag2' with value '"value"'.
Decision C has tag 'aw"ful' with value "ha'ha'".
Transition 'up' from C has tag 'fast' with value 1.
At decision C there are actions 'grab_helmet' and 'pull_lever'.
The 'grab_helmet' transition requires that you don't have the 'helmet' capability, and gives you that capability, deactivating with delay 3.
The 'pull_lever' transition requires that you do have the 'helmet' capability, and takes away that capability, but it also gives you 1 token, and if you have 2 tokens (before getting the one extra), it sets the 'grate' mechanism (which is a decision A) to state 'open' and deactivates.
The graph has an equivalence: having the 'helmet' capability satisfies requirements for the 'grate' mechanism to be in the 'open' state.
Inherited Members
- exploration.graphs.UniqueExitsGraph
- new_edge_key
- add_node
- add_nodes_from
- remove_node
- remove_nodes_from
- add_edge
- add_edges_from
- remove_edge
- remove_edges_from
- clear
- clear_edges
- reverse
- removeEdgeByKey
- removeEdgesByKey
- allEdgesTo
- textMapObj
- networkx.classes.multidigraph.MultiDiGraph
- edge_key_dict_factory
- adj
- succ
- pred
- edges
- out_edges
- in_edges
- degree
- in_degree
- out_degree
- is_multigraph
- is_directed
- to_undirected
- networkx.classes.multigraph.MultiGraph
- to_directed_class
- to_undirected_class
- has_edge
- get_edge_data
- copy
- to_directed
- number_of_edges
- networkx.classes.digraph.DiGraph
- graph
- has_successor
- has_predecessor
- successors
- neighbors
- predecessors
- networkx.classes.graph.Graph
- node_dict_factory
- node_attr_dict_factory
- adjlist_outer_dict_factory
- adjlist_inner_dict_factory
- edge_attr_dict_factory
- graph_attr_dict_factory
- name
- nodes
- number_of_nodes
- order
- has_node
- add_weighted_edges_from
- update
- adjacency
- subgraph
- edge_subgraph
- size
- nbunch_iter
6395def emptySituation() -> base.Situation: 6396 """ 6397 Creates and returns an empty situation: A situation that has an 6398 empty `DecisionGraph`, an empty `State`, a 'pending' decision type 6399 with `None` as the action taken, no tags, and no annotations. 6400 """ 6401 return base.Situation( 6402 graph=DecisionGraph(), 6403 state=base.emptyState(), 6404 type='pending', 6405 action=None, 6406 saves={}, 6407 tags={}, 6408 annotations=[] 6409 )
Creates and returns an empty situation: A situation that has an
empty DecisionGraph
, an empty State
, a 'pending' decision type
with None
as the action taken, no tags, and no annotations.
6412class DiscreteExploration: 6413 """ 6414 A list of `Situations` each of which contains a `DecisionGraph` 6415 representing exploration over time, with `States` containing 6416 `FocalContext` information for each step and 'taken' values for the 6417 transition selected (at a particular decision) in that step. Each 6418 decision graph represents a new state of the world (and/or new 6419 knowledge about a persisting state of the world), and the 'taken' 6420 transition in one situation transition indicates which option was 6421 selected, or what event happened to cause update(s). Depending on the 6422 resolution, it could represent a close record of every decision made 6423 or a more coarse set of snapshots from gameplay with more time in 6424 between. 6425 6426 The steps of the exploration can also be tagged and annotated (see 6427 `tagStep` and `annotateStep`). 6428 6429 When a new `DiscreteExploration` is created, it starts out with an 6430 empty `Situation` that contains an empty `DecisionGraph`. Use the 6431 `start` method to name the starting decision point and set things up 6432 for other methods. 6433 6434 Tracking of player goals and destinations is also possible (see the 6435 `quest`, `progress`, `complete`, `destination`, and `arrive` methods). 6436 TODO: That 6437 """ 6438 def __init__(self) -> None: 6439 self.situations: List[base.Situation] = [ 6440 base.Situation( 6441 graph=DecisionGraph(), 6442 state=base.emptyState(), 6443 type='pending', 6444 action=None, 6445 saves={}, 6446 tags={}, 6447 annotations=[] 6448 ) 6449 ] 6450 6451 # Note: not hashable 6452 6453 def __eq__(self, other): 6454 """ 6455 Equality checker. `DiscreteExploration`s can only be equal to 6456 other `DiscreteExploration`s, not to other kinds of things. 6457 """ 6458 if not isinstance(other, DiscreteExploration): 6459 return False 6460 else: 6461 return self.situations == other.situations 6462 6463 @staticmethod 6464 def fromGraph( 6465 graph: DecisionGraph, 6466 state: Optional[base.State] = None 6467 ) -> 'DiscreteExploration': 6468 """ 6469 Creates an exploration which has just a single step whose graph 6470 is the entire specified graph, with the specified decision as 6471 the primary decision (if any). The graph is copied, so that 6472 changes to the exploration will not modify it. A starting state 6473 may also be specified if desired, although if not an empty state 6474 will be used (a provided starting state is NOT copied, but used 6475 directly). 6476 6477 Example: 6478 6479 >>> g = DecisionGraph() 6480 >>> g.addDecision('Room1') 6481 0 6482 >>> g.addDecision('Room2') 6483 1 6484 >>> g.addTransition('Room1', 'door', 'Room2', 'door') 6485 >>> e = DiscreteExploration.fromGraph(g) 6486 >>> len(e) 6487 1 6488 >>> e.getSituation().graph == g 6489 True 6490 >>> e.getActiveDecisions() 6491 set() 6492 >>> e.primaryDecision() is None 6493 True 6494 >>> e.observe('Room1', 'hatch') 6495 2 6496 >>> e.getSituation().graph == g 6497 False 6498 >>> e.getSituation().graph.destinationsFrom('Room1') 6499 {'door': 1, 'hatch': 2} 6500 >>> g.destinationsFrom('Room1') 6501 {'door': 1} 6502 """ 6503 result = DiscreteExploration() 6504 result.situations[0] = base.Situation( 6505 graph=copy.deepcopy(graph), 6506 state=base.emptyState() if state is None else state, 6507 type='pending', 6508 action=None, 6509 saves={}, 6510 tags={}, 6511 annotations=[] 6512 ) 6513 return result 6514 6515 def __len__(self) -> int: 6516 """ 6517 The 'length' of an exploration is the number of steps. 6518 """ 6519 return len(self.situations) 6520 6521 def __getitem__(self, i: int) -> base.Situation: 6522 """ 6523 Indexing an exploration returns the situation at that step. 6524 """ 6525 return self.situations[i] 6526 6527 def __iter__(self) -> Iterator[base.Situation]: 6528 """ 6529 Iterating over an exploration yields each `Situation` in order. 6530 """ 6531 for i in range(len(self)): 6532 yield self[i] 6533 6534 def getSituation(self, step: int = -1) -> base.Situation: 6535 """ 6536 Returns a `base.Situation` named tuple detailing the state of 6537 the exploration at a given step (or at the current step if no 6538 argument is given). Note that this method works the same 6539 way as indexing the exploration: see `__getitem__`. 6540 6541 Raises an `IndexError` if asked for a step that's out-of-range. 6542 """ 6543 return self[step] 6544 6545 def primaryDecision(self, step: int = -1) -> Optional[base.DecisionID]: 6546 """ 6547 Returns the current primary `base.DecisionID`, or the primary 6548 decision from a specific step if one is specified. This may be 6549 `None` for some steps, but mostly it's the destination of the 6550 transition taken in the previous step. 6551 """ 6552 return self[step].state['primaryDecision'] 6553 6554 def effectiveCapabilities( 6555 self, 6556 step: int = -1 6557 ) -> base.CapabilitySet: 6558 """ 6559 Returns the effective capability set for the specified step 6560 (default is the last/current step). See 6561 `base.effectiveCapabilities`. 6562 """ 6563 return base.effectiveCapabilitySet(self.getSituation(step).state) 6564 6565 def getCommonContext( 6566 self, 6567 step: Optional[int] = None 6568 ) -> base.FocalContext: 6569 """ 6570 Returns the common `FocalContext` at the specified step, or at 6571 the current step if no argument is given. Raises an `IndexError` 6572 if an invalid step is specified. 6573 """ 6574 if step is None: 6575 step = -1 6576 state = self.getSituation(step).state 6577 return state['common'] 6578 6579 def getActiveContext( 6580 self, 6581 step: Optional[int] = None 6582 ) -> base.FocalContext: 6583 """ 6584 Returns the active `FocalContext` at the specified step, or at 6585 the current step if no argument is provided. Raises an 6586 `IndexError` if an invalid step is specified. 6587 """ 6588 if step is None: 6589 step = -1 6590 state = self.getSituation(step).state 6591 return state['contexts'][state['activeContext']] 6592 6593 def addFocalContext(self, name: base.FocalContextName) -> None: 6594 """ 6595 Adds a new empty focal context to our set of focal contexts (see 6596 `emptyFocalContext`). Use `setActiveContext` to swap to it. 6597 Raises a `FocalContextCollisionError` if the name is already in 6598 use. 6599 """ 6600 contextMap = self.getSituation().state['contexts'] 6601 if name in contextMap: 6602 raise FocalContextCollisionError( 6603 f"Cannot add focal context {name!r}: a focal context" 6604 f" with that name already exists." 6605 ) 6606 contextMap[name] = base.emptyFocalContext() 6607 6608 def setActiveContext(self, which: base.FocalContextName) -> None: 6609 """ 6610 Sets the active context to the named focal context, creating it 6611 if it did not already exist (makes changes to the current 6612 situation only). Does not add an exploration step (use 6613 `advanceSituation` with a 'swap' action for that). 6614 """ 6615 state = self.getSituation().state 6616 contextMap = state['contexts'] 6617 if which not in contextMap: 6618 self.addFocalContext(which) 6619 state['activeContext'] = which 6620 6621 def createDomain( 6622 self, 6623 name: base.Domain, 6624 focalization: base.DomainFocalization = 'singular', 6625 makeActive: bool = False, 6626 inCommon: Union[bool, Literal["both"]] = "both" 6627 ) -> None: 6628 """ 6629 Creates a new domain with the given focalization type, in either 6630 the common context (`inCommon` = `True`) the active context 6631 (`inCommon` = `False`) or both (the default; `inCommon` = 'both'). 6632 The domain's focalization will be set to the given 6633 `focalization` value (default 'singular') and it will have no 6634 active decisions. Raises a `DomainCollisionError` if a domain 6635 with the specified name already exists. 6636 6637 Creates the domain in the current situation. 6638 6639 If `makeActive` is set to `True` (default is `False`) then the 6640 domain will be made active in whichever context(s) it's created 6641 in. 6642 """ 6643 now = self.getSituation() 6644 state = now.state 6645 modify = [] 6646 if inCommon in (True, "both"): 6647 modify.append(('common', state['common'])) 6648 if inCommon in (False, "both"): 6649 acName = state['activeContext'] 6650 modify.append( 6651 ('current ({repr(acName)})', state['contexts'][acName]) 6652 ) 6653 6654 for (fcType, fc) in modify: 6655 if name in fc['focalization']: 6656 raise DomainCollisionError( 6657 f"Cannot create domain {repr(name)} because a" 6658 f" domain with that name already exists in the" 6659 f" {fcType} focal context." 6660 ) 6661 fc['focalization'][name] = focalization 6662 if makeActive: 6663 fc['activeDomains'].add(name) 6664 if focalization == "spreading": 6665 fc['activeDecisions'][name] = set() 6666 elif focalization == "plural": 6667 fc['activeDecisions'][name] = {} 6668 else: 6669 fc['activeDecisions'][name] = None 6670 6671 def activateDomain( 6672 self, 6673 domain: base.Domain, 6674 activate: bool = True, 6675 inContext: base.ContextSpecifier = "active" 6676 ) -> None: 6677 """ 6678 Sets the given domain as active (or inactive if 'activate' is 6679 given as `False`) in the specified context (default "active"). 6680 6681 Modifies the current situation. 6682 """ 6683 fc: base.FocalContext 6684 if inContext == "active": 6685 fc = self.getActiveContext() 6686 elif inContext == "common": 6687 fc = self.getCommonContext() 6688 6689 if activate: 6690 fc['activeDomains'].add(domain) 6691 else: 6692 try: 6693 fc['activeDomains'].remove(domain) 6694 except KeyError: 6695 pass 6696 6697 def createTriggerGroup( 6698 self, 6699 name: base.DecisionName 6700 ) -> base.DecisionID: 6701 """ 6702 Creates a new trigger group with the given name, returning the 6703 decision ID for that trigger group. If this is the first trigger 6704 group being created, also creates the `TRIGGERS_DOMAIN` domain 6705 as a spreading-focalized domain that's active in the common 6706 context (but does NOT set the created trigger group as an active 6707 decision in that domain). 6708 6709 You can use 'goto' effects to activate trigger domains via 6710 consequences, and 'retreat' effects to deactivate them. 6711 6712 Creating a second trigger group with the same name as another 6713 results in a `ValueError`. 6714 6715 TODO: Retreat effects 6716 """ 6717 ctx = self.getCommonContext() 6718 if TRIGGERS_DOMAIN not in ctx['focalization']: 6719 self.createDomain( 6720 TRIGGERS_DOMAIN, 6721 focalization='spreading', 6722 makeActive=True, 6723 inCommon=True 6724 ) 6725 6726 graph = self.getSituation().graph 6727 if graph.getDecision( 6728 base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name) 6729 ) is not None: 6730 raise ValueError( 6731 f"Cannot create trigger group {name!r}: a trigger group" 6732 f" with that name already exists." 6733 ) 6734 6735 return self.getSituation().graph.triggerGroupID(name) 6736 6737 def toggleTriggerGroup( 6738 self, 6739 name: base.DecisionName, 6740 setActive: Union[bool, None] = None 6741 ): 6742 """ 6743 Toggles whether the specified trigger group (a decision in the 6744 `TRIGGERS_DOMAIN`) is active or not. Pass `True` or `False` as 6745 the `setActive` argument (instead of the default `None`) to set 6746 the state directly instead of toggling it. 6747 6748 Note that trigger groups are decisions in a spreading-focalized 6749 domain, so they can be activated or deactivated by the 'goto' 6750 and 'retreat' effects as well. 6751 6752 This does not affect whether the `TRIGGERS_DOMAIN` itself is 6753 active (normally it would always be active). 6754 6755 Raises a `MissingDecisionError` if the specified trigger group 6756 does not exist yet, including when the entire `TRIGGERS_DOMAIN` 6757 does not exist. Raises a `KeyError` if the target group exists 6758 but the `TRIGGERS_DOMAIN` has not been set up properly. 6759 """ 6760 ctx = self.getCommonContext() 6761 tID = self.getSituation().graph.resolveDecision( 6762 base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name) 6763 ) 6764 activeGroups = ctx['activeDecisions'][TRIGGERS_DOMAIN] 6765 assert isinstance(activeGroups, set) 6766 if tID in activeGroups: 6767 if setActive is not True: 6768 activeGroups.remove(tID) 6769 else: 6770 if setActive is not False: 6771 activeGroups.add(tID) 6772 6773 def getActiveDecisions( 6774 self, 6775 step: Optional[int] = None, 6776 inCommon: Union[bool, Literal["both"]] = "both" 6777 ) -> Set[base.DecisionID]: 6778 """ 6779 Returns the set of active decisions at the given step index, or 6780 at the current step if no step is specified. Raises an 6781 `IndexError` if the step index is out of bounds (see `__len__`). 6782 May return an empty set if no decisions are active. 6783 6784 If `inCommon` is set to "both" (the default) then decisions 6785 active in either the common or active context are returned. Set 6786 it to `True` or `False` to return only decisions active in the 6787 common (when `True`) or active (when `False`) context. 6788 """ 6789 if step is None: 6790 step = -1 6791 state = self.getSituation(step).state 6792 if inCommon == "both": 6793 return base.combinedDecisionSet(state) 6794 elif inCommon is True: 6795 return base.activeDecisionSet(state['common']) 6796 elif inCommon is False: 6797 return base.activeDecisionSet( 6798 state['contexts'][state['activeContext']] 6799 ) 6800 else: 6801 raise ValueError( 6802 f"Invalid inCommon value {repr(inCommon)} (must be" 6803 f" 'both', True, or False)." 6804 ) 6805 6806 def setActiveDecisionsAtStep( 6807 self, 6808 step: int, 6809 domain: base.Domain, 6810 activate: Union[ 6811 base.DecisionID, 6812 Dict[base.FocalPointName, Optional[base.DecisionID]], 6813 Set[base.DecisionID] 6814 ], 6815 inCommon: bool = False 6816 ) -> None: 6817 """ 6818 Changes the activation status of decisions in the active 6819 `FocalContext` at the specified step, for the specified domain 6820 (see `currentActiveContext`). Does this without adding an 6821 exploration step, which is unusual: normally you should use 6822 another method like `warp` to update active decisions. 6823 6824 Note that this does not change which domains are active, and 6825 setting active decisions in inactive domains does not make those 6826 decisions active overall. 6827 6828 Which decisions to activate or deactivate are specified as 6829 either a single `DecisionID`, a list of them, or a set of them, 6830 depending on the `DomainFocalization` setting in the selected 6831 `FocalContext` for the specified domain. A `TypeError` will be 6832 raised if the wrong kind of decision information is provided. If 6833 the focalization context does not have any focalization value for 6834 the domain in question, it will be set based on the kind of 6835 active decision information specified. 6836 6837 A `MissingDecisionError` will be raised if a decision is 6838 included which is not part of the current `DecisionGraph`. 6839 The provided information will overwrite the previous active 6840 decision information. 6841 6842 If `inCommon` is set to `True`, then decisions are activated or 6843 deactivated in the common context, instead of in the active 6844 context. 6845 6846 Example: 6847 6848 >>> e = DiscreteExploration() 6849 >>> e.getActiveDecisions() 6850 set() 6851 >>> graph = e.getSituation().graph 6852 >>> graph.addDecision('A') 6853 0 6854 >>> graph.addDecision('B') 6855 1 6856 >>> graph.addDecision('C') 6857 2 6858 >>> e.setActiveDecisionsAtStep(0, 'main', 0) 6859 >>> e.getActiveDecisions() 6860 {0} 6861 >>> e.setActiveDecisionsAtStep(0, 'main', 1) 6862 >>> e.getActiveDecisions() 6863 {1} 6864 >>> graph = e.getSituation().graph 6865 >>> graph.addDecision('One', domain='numbers') 6866 3 6867 >>> graph.addDecision('Two', domain='numbers') 6868 4 6869 >>> graph.addDecision('Three', domain='numbers') 6870 5 6871 >>> graph.addDecision('Bear', domain='animals') 6872 6 6873 >>> graph.addDecision('Spider', domain='animals') 6874 7 6875 >>> graph.addDecision('Eel', domain='animals') 6876 8 6877 >>> ac = e.getActiveContext() 6878 >>> ac['focalization']['numbers'] = 'plural' 6879 >>> ac['focalization']['animals'] = 'spreading' 6880 >>> ac['activeDecisions']['numbers'] = {'a': None, 'b': None} 6881 >>> ac['activeDecisions']['animals'] = set() 6882 >>> cc = e.getCommonContext() 6883 >>> cc['focalization']['numbers'] = 'plural' 6884 >>> cc['focalization']['animals'] = 'spreading' 6885 >>> cc['activeDecisions']['numbers'] = {'z': None} 6886 >>> cc['activeDecisions']['animals'] = set() 6887 >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 3, 'b': 3}) 6888 >>> e.getActiveDecisions() 6889 {1} 6890 >>> e.activateDomain('numbers') 6891 >>> e.getActiveDecisions() 6892 {1, 3} 6893 >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 4, 'b': None}) 6894 >>> e.getActiveDecisions() 6895 {1, 4} 6896 >>> # Wrong domain for the decision ID: 6897 >>> e.setActiveDecisionsAtStep(0, 'main', 3) 6898 Traceback (most recent call last): 6899 ... 6900 ValueError... 6901 >>> # Wrong domain for one of the decision IDs: 6902 >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 2, 'b': None}) 6903 Traceback (most recent call last): 6904 ... 6905 ValueError... 6906 >>> # Wrong kind of decision information provided. 6907 >>> e.setActiveDecisionsAtStep(0, 'numbers', 3) 6908 Traceback (most recent call last): 6909 ... 6910 TypeError... 6911 >>> e.getActiveDecisions() 6912 {1, 4} 6913 >>> e.setActiveDecisionsAtStep(0, 'animals', {6, 7}) 6914 >>> e.getActiveDecisions() 6915 {1, 4} 6916 >>> e.activateDomain('animals') 6917 >>> e.getActiveDecisions() 6918 {1, 4, 6, 7} 6919 >>> e.setActiveDecisionsAtStep(0, 'animals', {8}) 6920 >>> e.getActiveDecisions() 6921 {8, 1, 4} 6922 >>> e.setActiveDecisionsAtStep(1, 'main', 2) # invalid step 6923 Traceback (most recent call last): 6924 ... 6925 IndexError... 6926 >>> e.setActiveDecisionsAtStep(0, 'novel', 0) # domain mismatch 6927 Traceback (most recent call last): 6928 ... 6929 ValueError... 6930 6931 Example of active/common contexts: 6932 6933 >>> e = DiscreteExploration() 6934 >>> graph = e.getSituation().graph 6935 >>> graph.addDecision('A') 6936 0 6937 >>> graph.addDecision('B') 6938 1 6939 >>> e.activateDomain('main', inContext="common") 6940 >>> e.setActiveDecisionsAtStep(0, 'main', 0, inCommon=True) 6941 >>> e.getActiveDecisions() 6942 {0} 6943 >>> e.setActiveDecisionsAtStep(0, 'main', None) 6944 >>> e.getActiveDecisions() 6945 {0} 6946 >>> # (Still active since it's active in the common context) 6947 >>> e.setActiveDecisionsAtStep(0, 'main', 1) 6948 >>> e.getActiveDecisions() 6949 {0, 1} 6950 >>> e.setActiveDecisionsAtStep(0, 'main', 1, inCommon=True) 6951 >>> e.getActiveDecisions() 6952 {1} 6953 >>> e.setActiveDecisionsAtStep(0, 'main', None, inCommon=True) 6954 >>> e.getActiveDecisions() 6955 {1} 6956 >>> # (Still active since it's active in the active context) 6957 >>> e.setActiveDecisionsAtStep(0, 'main', None) 6958 >>> e.getActiveDecisions() 6959 set() 6960 """ 6961 now = self.getSituation(step) 6962 graph = now.graph 6963 if inCommon: 6964 context = self.getCommonContext(step) 6965 else: 6966 context = self.getActiveContext(step) 6967 6968 defaultFocalization: base.DomainFocalization = 'singular' 6969 if isinstance(activate, base.DecisionID): 6970 defaultFocalization = 'singular' 6971 elif isinstance(activate, dict): 6972 defaultFocalization = 'plural' 6973 elif isinstance(activate, set): 6974 defaultFocalization = 'spreading' 6975 elif domain not in context['focalization']: 6976 raise TypeError( 6977 f"Domain {domain!r} has no focalization in the" 6978 f" {'common' if inCommon else 'active'} context," 6979 f" and the specified position doesn't imply one." 6980 ) 6981 6982 focalization = base.getDomainFocalization( 6983 context, 6984 domain, 6985 defaultFocalization 6986 ) 6987 6988 # Check domain & existence of decision(s) in question 6989 if activate is None: 6990 pass 6991 elif isinstance(activate, base.DecisionID): 6992 if activate not in graph: 6993 raise MissingDecisionError( 6994 f"There is no decision {activate} at step {step}." 6995 ) 6996 if graph.domainFor(activate) != domain: 6997 raise ValueError( 6998 f"Can't set active decisions in domain {domain!r}" 6999 f" to decision {graph.identityOf(activate)} because" 7000 f" that decision is in actually in domain" 7001 f" {graph.domainFor(activate)!r}." 7002 ) 7003 elif isinstance(activate, dict): 7004 for fpName, pos in activate.items(): 7005 if pos is None: 7006 continue 7007 if pos not in graph: 7008 raise MissingDecisionError( 7009 f"There is no decision {pos} at step {step}." 7010 ) 7011 if graph.domainFor(pos) != domain: 7012 raise ValueError( 7013 f"Can't set active decision for focal point" 7014 f" {fpName!r} in domain {domain!r}" 7015 f" to decision {graph.identityOf(pos)} because" 7016 f" that decision is in actually in domain" 7017 f" {graph.domainFor(pos)!r}." 7018 ) 7019 elif isinstance(activate, set): 7020 for pos in activate: 7021 if pos not in graph: 7022 raise MissingDecisionError( 7023 f"There is no decision {pos} at step {step}." 7024 ) 7025 if graph.domainFor(pos) != domain: 7026 raise ValueError( 7027 f"Can't set {graph.identityOf(pos)} as an" 7028 f" active decision in domain {domain!r} to" 7029 f" decision because that decision is in" 7030 f" actually in domain {graph.domainFor(pos)!r}." 7031 ) 7032 else: 7033 raise TypeError( 7034 f"Domain {domain!r} has no focalization in the" 7035 f" {'common' if inCommon else 'active'} context," 7036 f" and the specified position doesn't imply one:" 7037 f"\n{activate!r}" 7038 ) 7039 7040 if focalization == 'singular': 7041 if activate is None or isinstance(activate, base.DecisionID): 7042 if activate is not None: 7043 targetDomain = graph.domainFor(activate) 7044 if activate not in graph: 7045 raise MissingDecisionError( 7046 f"There is no decision {activate} in the" 7047 f" graph at step {step}." 7048 ) 7049 elif targetDomain != domain: 7050 raise ValueError( 7051 f"At step {step}, decision {activate} cannot" 7052 f" be the active decision for domain" 7053 f" {repr(domain)} because it is in a" 7054 f" different domain ({repr(targetDomain)})." 7055 ) 7056 context['activeDecisions'][domain] = activate 7057 else: 7058 raise TypeError( 7059 f"{'Common' if inCommon else 'Active'} focal" 7060 f" context at step {step} has {repr(focalization)}" 7061 f" focalization for domain {repr(domain)}, so the" 7062 f" active decision must be a single decision or" 7063 f" None.\n(You provided: {repr(activate)})" 7064 ) 7065 elif focalization == 'plural': 7066 if ( 7067 isinstance(activate, dict) 7068 and all( 7069 isinstance(k, base.FocalPointName) 7070 for k in activate.keys() 7071 ) 7072 and all( 7073 v is None or isinstance(v, base.DecisionID) 7074 for v in activate.values() 7075 ) 7076 ): 7077 for v in activate.values(): 7078 if v is not None: 7079 targetDomain = graph.domainFor(v) 7080 if v not in graph: 7081 raise MissingDecisionError( 7082 f"There is no decision {v} in the graph" 7083 f" at step {step}." 7084 ) 7085 elif targetDomain != domain: 7086 raise ValueError( 7087 f"At step {step}, decision {activate}" 7088 f" cannot be an active decision for" 7089 f" domain {repr(domain)} because it is" 7090 f" in a different domain" 7091 f" ({repr(targetDomain)})." 7092 ) 7093 context['activeDecisions'][domain] = activate 7094 else: 7095 raise TypeError( 7096 f"{'Common' if inCommon else 'Active'} focal" 7097 f" context at step {step} has {repr(focalization)}" 7098 f" focalization for domain {repr(domain)}, so the" 7099 f" active decision must be a dictionary mapping" 7100 f" focal point names to decision IDs (or Nones)." 7101 f"\n(You provided: {repr(activate)})" 7102 ) 7103 elif focalization == 'spreading': 7104 if ( 7105 isinstance(activate, set) 7106 and all(isinstance(x, base.DecisionID) for x in activate) 7107 ): 7108 for x in activate: 7109 targetDomain = graph.domainFor(x) 7110 if x not in graph: 7111 raise MissingDecisionError( 7112 f"There is no decision {x} in the graph" 7113 f" at step {step}." 7114 ) 7115 elif targetDomain != domain: 7116 raise ValueError( 7117 f"At step {step}, decision {activate}" 7118 f" cannot be an active decision for" 7119 f" domain {repr(domain)} because it is" 7120 f" in a different domain" 7121 f" ({repr(targetDomain)})." 7122 ) 7123 context['activeDecisions'][domain] = activate 7124 else: 7125 raise TypeError( 7126 f"{'Common' if inCommon else 'Active'} focal" 7127 f" context at step {step} has {repr(focalization)}" 7128 f" focalization for domain {repr(domain)}, so the" 7129 f" active decision must be a set of decision IDs" 7130 f"\n(You provided: {repr(activate)})" 7131 ) 7132 else: 7133 raise RuntimeError( 7134 f"Invalid focalization value {repr(focalization)} for" 7135 f" domain {repr(domain)} at step {step}." 7136 ) 7137 7138 def movementAtStep(self, step: int = -1) -> Tuple[ 7139 Union[base.DecisionID, Set[base.DecisionID], None], 7140 Optional[base.Transition], 7141 Union[base.DecisionID, Set[base.DecisionID], None] 7142 ]: 7143 """ 7144 Given a step number, returns information about the starting 7145 decision, transition taken, and destination decision for that 7146 step. Not all steps have all of those, so some items may be 7147 `None`. 7148 7149 For steps where there is no action, where a decision is still 7150 pending, or where the action type is 'focus', 'swap', 7151 or 'focalize', the result will be (`None`, `None`, `None`), 7152 unless a primary decision is available in which case the first 7153 item in the tuple will be that decision. For 'start' actions, the 7154 starting position and transition will be `None` (again unless the 7155 step had a primary decision) but the destination will be the ID 7156 of the node started at. 7157 7158 Also, if the action taken has multiple potential or actual start 7159 or end points, these may be sets of decision IDs instead of 7160 single IDs. 7161 7162 Note that the primary decision of the starting state is usually 7163 used as the from-decision, but in some cases an action dictates 7164 taking a transition from a different decision, and this function 7165 will return that decision as the from-decision. 7166 7167 TODO: Examples! 7168 7169 TODO: Account for bounce/follow/goto effects!!! 7170 """ 7171 now = self.getSituation(step) 7172 action = now.action 7173 graph = now.graph 7174 primary = now.state['primaryDecision'] 7175 7176 if action is None: 7177 return (primary, None, None) 7178 7179 aType = action[0] 7180 fromID: Optional[base.DecisionID] 7181 destID: Optional[base.DecisionID] 7182 transition: base.Transition 7183 outcomes: List[bool] 7184 7185 if aType in ('noAction', 'focus', 'swap', 'focalize'): 7186 return (primary, None, None) 7187 elif aType == 'start': 7188 assert len(action) == 7 7189 where = cast( 7190 Union[ 7191 base.DecisionID, 7192 Dict[base.FocalPointName, base.DecisionID], 7193 Set[base.DecisionID] 7194 ], 7195 action[1] 7196 ) 7197 if isinstance(where, dict): 7198 where = set(where.values()) 7199 return (primary, None, where) 7200 elif aType in ('take', 'explore'): 7201 if ( 7202 (len(action) == 4 or len(action) == 7) 7203 and isinstance(action[2], base.DecisionID) 7204 ): 7205 fromID = action[2] 7206 assert isinstance(action[3], tuple) 7207 transition, outcomes = action[3] 7208 if ( 7209 action[0] == "explore" 7210 and isinstance(action[4], base.DecisionID) 7211 ): 7212 destID = action[4] 7213 else: 7214 destID = graph.getDestination(fromID, transition) 7215 return (fromID, transition, destID) 7216 elif ( 7217 (len(action) == 3 or len(action) == 6) 7218 and isinstance(action[1], tuple) 7219 and isinstance(action[2], base.Transition) 7220 and len(action[1]) == 3 7221 and action[1][0] in get_args(base.ContextSpecifier) 7222 and isinstance(action[1][1], base.Domain) 7223 and isinstance(action[1][2], base.FocalPointName) 7224 ): 7225 fromID = base.resolvePosition(now, action[1]) 7226 if fromID is None: 7227 raise InvalidActionError( 7228 f"{aType!r} action at step {step} has position" 7229 f" {action[1]!r} which cannot be resolved to a" 7230 f" decision." 7231 ) 7232 transition, outcomes = action[2] 7233 if ( 7234 action[0] == "explore" 7235 and isinstance(action[3], base.DecisionID) 7236 ): 7237 destID = action[3] 7238 else: 7239 destID = graph.getDestination(fromID, transition) 7240 return (fromID, transition, destID) 7241 else: 7242 raise InvalidActionError( 7243 f"Malformed {aType!r} action:\n{repr(action)}" 7244 ) 7245 elif aType == 'warp': 7246 if len(action) != 3: 7247 raise InvalidActionError( 7248 f"Malformed 'warp' action:\n{repr(action)}" 7249 ) 7250 dest = action[2] 7251 assert isinstance(dest, base.DecisionID) 7252 if action[1] in get_args(base.ContextSpecifier): 7253 # Unspecified starting point; find active decisions in 7254 # same domain if primary is None 7255 if primary is not None: 7256 return (primary, None, dest) 7257 else: 7258 toDomain = now.graph.domainFor(dest) 7259 # TODO: Could check destination focalization here... 7260 active = self.getActiveDecisions(step) 7261 sameDomain = set( 7262 dID 7263 for dID in active 7264 if now.graph.domainFor(dID) == toDomain 7265 ) 7266 if len(sameDomain) == 1: 7267 return ( 7268 list(sameDomain)[0], 7269 None, 7270 dest 7271 ) 7272 else: 7273 return ( 7274 sameDomain, 7275 None, 7276 dest 7277 ) 7278 else: 7279 if ( 7280 not isinstance(action[1], tuple) 7281 or not len(action[1]) == 3 7282 or not action[1][0] in get_args(base.ContextSpecifier) 7283 or not isinstance(action[1][1], base.Domain) 7284 or not isinstance(action[1][2], base.FocalPointName) 7285 ): 7286 raise InvalidActionError( 7287 f"Malformed 'warp' action:\n{repr(action)}" 7288 ) 7289 return ( 7290 base.resolvePosition(now, action[1]), 7291 None, 7292 dest 7293 ) 7294 else: 7295 raise InvalidActionError( 7296 f"Action taken had invalid action type {repr(aType)}:" 7297 f"\n{repr(action)}" 7298 ) 7299 7300 def tagStep( 7301 self, 7302 tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]], 7303 tagValue: Union[ 7304 base.TagValue, 7305 type[base.NoTagValue] 7306 ] = base.NoTagValue, 7307 step: int = -1 7308 ) -> None: 7309 """ 7310 Adds a tag (or multiple tags) to the current step, or to a 7311 specific step if `n` is given as an integer rather than the 7312 default `None`. A tag value should be supplied when a tag is 7313 given (unless you want to use the default of `1`), but it's a 7314 `ValueError` to supply a tag value when a dictionary of tags to 7315 update is provided. 7316 """ 7317 if isinstance(tagOrTags, base.Tag): 7318 if tagValue is base.NoTagValue: 7319 tagValue = 1 7320 7321 # Not sure why this is necessary... 7322 tagValue = cast(base.TagValue, tagValue) 7323 7324 self.getSituation(step).tags.update({tagOrTags: tagValue}) 7325 else: 7326 self.getSituation(step).tags.update(tagOrTags) 7327 7328 def annotateStep( 7329 self, 7330 annotationOrAnnotations: Union[ 7331 base.Annotation, 7332 Sequence[base.Annotation] 7333 ], 7334 step: Optional[int] = None 7335 ) -> None: 7336 """ 7337 Adds an annotation to the current exploration step, or to a 7338 specific step if `n` is given as an integer rather than the 7339 default `None`. 7340 """ 7341 if step is None: 7342 step = -1 7343 if isinstance(annotationOrAnnotations, base.Annotation): 7344 self.getSituation(step).annotations.append( 7345 annotationOrAnnotations 7346 ) 7347 else: 7348 self.getSituation(step).annotations.extend( 7349 annotationOrAnnotations 7350 ) 7351 7352 def hasCapability( 7353 self, 7354 capability: base.Capability, 7355 step: Optional[int] = None, 7356 inCommon: Union[bool, Literal['both']] = "both" 7357 ) -> bool: 7358 """ 7359 Returns True if the player currently had the specified 7360 capability, at the specified exploration step, and False 7361 otherwise. Checks the current state if no step is given. Does 7362 NOT return true if the game state means that the player has an 7363 equivalent for that capability (see 7364 `hasCapabilityOrEquivalent`). 7365 7366 Normally, `inCommon` is set to 'both' by default and so if 7367 either the common `FocalContext` or the active one has the 7368 capability, this will return `True`. `inCommon` may instead be 7369 set to `True` or `False` to ask about just the common (or 7370 active) focal context. 7371 """ 7372 state = self.getSituation().state 7373 commonCapabilities = state['common']['capabilities']\ 7374 ['capabilities'] # noqa 7375 activeCapabilities = state['contexts'][state['activeContext']]\ 7376 ['capabilities']['capabilities'] # noqa 7377 7378 if inCommon == 'both': 7379 return ( 7380 capability in commonCapabilities 7381 or capability in activeCapabilities 7382 ) 7383 elif inCommon is True: 7384 return capability in commonCapabilities 7385 elif inCommon is False: 7386 return capability in activeCapabilities 7387 else: 7388 raise ValueError( 7389 f"Invalid inCommon value (must be False, True, or" 7390 f" 'both'; got {repr(inCommon)})." 7391 ) 7392 7393 def hasCapabilityOrEquivalent( 7394 self, 7395 capability: base.Capability, 7396 step: Optional[int] = None, 7397 location: Optional[Set[base.DecisionID]] = None 7398 ) -> bool: 7399 """ 7400 Works like `hasCapability`, but also returns `True` if the 7401 player counts as having the specified capability via an equivalence 7402 that's part of the current graph. As with `hasCapability`, the 7403 optional `step` argument is used to specify which step to check, 7404 with the current step being used as the default. 7405 7406 The `location` set can specify where to start looking for 7407 mechanisms; if left unspecified active decisions for that step 7408 will be used. 7409 """ 7410 if step is None: 7411 step = -1 7412 if location is None: 7413 location = self.getActiveDecisions(step) 7414 situation = self.getSituation(step) 7415 return base.hasCapabilityOrEquivalent( 7416 capability, 7417 base.RequirementContext( 7418 state=situation.state, 7419 graph=situation.graph, 7420 searchFrom=location 7421 ) 7422 ) 7423 7424 def gainCapabilityNow( 7425 self, 7426 capability: base.Capability, 7427 inCommon: bool = False 7428 ) -> None: 7429 """ 7430 Modifies the current game state to add the specified `Capability` 7431 to the player's capabilities. No changes are made to the current 7432 graph. 7433 7434 If `inCommon` is set to `True` (default is `False`) then the 7435 capability will be added to the common `FocalContext` and will 7436 therefore persist even when a focal context switch happens. 7437 Normally, it will be added to the currently-active focal 7438 context. 7439 """ 7440 state = self.getSituation().state 7441 if inCommon: 7442 context = state['common'] 7443 else: 7444 context = state['contexts'][state['activeContext']] 7445 context['capabilities']['capabilities'].add(capability) 7446 7447 def loseCapabilityNow( 7448 self, 7449 capability: base.Capability, 7450 inCommon: Union[bool, Literal['both']] = "both" 7451 ) -> None: 7452 """ 7453 Modifies the current game state to remove the specified `Capability` 7454 from the player's capabilities. Does nothing if the player 7455 doesn't already have that capability. 7456 7457 By default, this removes the capability from both the common 7458 capabilities set and the active `FocalContext`'s capabilities 7459 set, so that afterwards the player will definitely not have that 7460 capability. However, if you set `inCommon` to either `True` or 7461 `False`, it will remove the capability from just the common 7462 capabilities set (if `True`) or just the active capabilities set 7463 (if `False`). In these cases, removing the capability from just 7464 one capability set will not actually remove it in terms of the 7465 `hasCapability` result if it had been present in the other set. 7466 Set `inCommon` to "both" to use the default behavior explicitly. 7467 """ 7468 now = self.getSituation() 7469 if inCommon in ("both", True): 7470 context = now.state['common'] 7471 try: 7472 context['capabilities']['capabilities'].remove(capability) 7473 except KeyError: 7474 pass 7475 elif inCommon in ("both", False): 7476 context = now.state['contexts'][now.state['activeContext']] 7477 try: 7478 context['capabilities']['capabilities'].remove(capability) 7479 except KeyError: 7480 pass 7481 else: 7482 raise ValueError( 7483 f"Invalid inCommon value (must be False, True, or" 7484 f" 'both'; got {repr(inCommon)})." 7485 ) 7486 7487 def tokenCountNow(self, tokenType: base.Token) -> Optional[int]: 7488 """ 7489 Returns the number of tokens the player currently has of a given 7490 type. Returns `None` if the player has never acquired or lost 7491 tokens of that type. 7492 7493 This method adds together tokens from the common and active 7494 focal contexts. 7495 """ 7496 state = self.getSituation().state 7497 commonContext = state['common'] 7498 activeContext = state['contexts'][state['activeContext']] 7499 base = commonContext['capabilities']['tokens'].get(tokenType) 7500 if base is None: 7501 return activeContext['capabilities']['tokens'].get(tokenType) 7502 else: 7503 return base + activeContext['capabilities']['tokens'].get( 7504 tokenType, 7505 0 7506 ) 7507 7508 def adjustTokensNow( 7509 self, 7510 tokenType: base.Token, 7511 amount: int, 7512 inCommon: bool = False 7513 ) -> None: 7514 """ 7515 Modifies the current game state to add the specified number of 7516 `Token`s of the given type to the player's tokens. No changes are 7517 made to the current graph. Reduce the number of tokens by 7518 supplying a negative amount; note that negative token amounts 7519 are possible. 7520 7521 By default, the number of tokens for the current active 7522 `FocalContext` will be adjusted. However, if `inCommon` is set 7523 to `True`, then the number of tokens for the common context will 7524 be adjusted instead. 7525 """ 7526 # TODO: Custom token caps! 7527 state = self.getSituation().state 7528 if inCommon: 7529 context = state['common'] 7530 else: 7531 context = state['contexts'][state['activeContext']] 7532 tokens = context['capabilities']['tokens'] 7533 tokens[tokenType] = tokens.get(tokenType, 0) + amount 7534 7535 def setTokensNow( 7536 self, 7537 tokenType: base.Token, 7538 amount: int, 7539 inCommon: bool = False 7540 ) -> None: 7541 """ 7542 Modifies the current game state to set number of `Token`s of the 7543 given type to a specific amount, regardless of the old value. No 7544 changes are made to the current graph. 7545 7546 By default this sets the number of tokens for the active 7547 `FocalContext`. But if you set `inCommon` to `True`, it will 7548 set the number of tokens in the common context instead. 7549 """ 7550 # TODO: Custom token caps! 7551 state = self.getSituation().state 7552 if inCommon: 7553 context = state['common'] 7554 else: 7555 context = state['contexts'][state['activeContext']] 7556 context['capabilities']['tokens'][tokenType] = amount 7557 7558 def lookupMechanism( 7559 self, 7560 mechanism: base.MechanismName, 7561 step: Optional[int] = None, 7562 where: Union[ 7563 Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]], 7564 Collection[base.AnyDecisionSpecifier], 7565 None 7566 ] = None 7567 ) -> base.MechanismID: 7568 """ 7569 Looks up a mechanism ID by name, in the graph for the specified 7570 step. The `where` argument specifies where to start looking, 7571 which helps disambiguate. It can be a tuple with a decision 7572 specifier and `None` to start from a single decision, or with a 7573 decision specifier and a transition name to start from either 7574 end of that transition. It can also be `None` to look at global 7575 mechanisms and then all decisions directly, although this 7576 increases the chance of a `MechanismCollisionError`. Finally, it 7577 can be some other non-tuple collection of decision specifiers to 7578 start from that set. 7579 7580 If no step is specified, uses the current step. 7581 """ 7582 if step is None: 7583 step = -1 7584 situation = self.getSituation(step) 7585 graph = situation.graph 7586 searchFrom: Collection[base.AnyDecisionSpecifier] 7587 if where is None: 7588 searchFrom = set() 7589 elif isinstance(where, tuple): 7590 if len(where) != 2: 7591 raise ValueError( 7592 f"Mechanism lookup location was a tuple with an" 7593 f" invalid length (must be length-2 if it's a" 7594 f" tuple):\n {repr(where)}" 7595 ) 7596 where = cast( 7597 Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]], 7598 where 7599 ) 7600 if where[1] is None: 7601 searchFrom = {graph.resolveDecision(where[0])} 7602 else: 7603 searchFrom = graph.bothEnds(where[0], where[1]) 7604 else: # must be a collection of specifiers 7605 searchFrom = cast(Collection[base.AnyDecisionSpecifier], where) 7606 return graph.lookupMechanism(searchFrom, mechanism) 7607 7608 def mechanismState( 7609 self, 7610 mechanism: base.AnyMechanismSpecifier, 7611 where: Optional[Set[base.DecisionID]] = None, 7612 step: int = -1 7613 ) -> Optional[base.MechanismState]: 7614 """ 7615 Returns the current state for the specified mechanism (or the 7616 state at the specified step if a step index is given). `where` 7617 may be provided as a set of decision IDs to indicate where to 7618 search for the named mechanism, or a mechanism ID may be provided 7619 in the first place. Mechanism states are properties of a `State` 7620 but are not associated with focal contexts. 7621 """ 7622 situation = self.getSituation(step) 7623 mID = situation.graph.resolveMechanism(mechanism, startFrom=where) 7624 return situation.state['mechanisms'].get( 7625 mID, 7626 base.DEFAULT_MECHANISM_STATE 7627 ) 7628 7629 def setMechanismStateNow( 7630 self, 7631 mechanism: base.AnyMechanismSpecifier, 7632 toState: base.MechanismState, 7633 where: Optional[Set[base.DecisionID]] = None 7634 ) -> None: 7635 """ 7636 Sets the state of the specified mechanism to the specified 7637 state. Mechanisms can only be in one state at once, so this 7638 removes any previous states for that mechanism (note that via 7639 equivalences multiple mechanism states can count as active). 7640 7641 The mechanism can be any kind of mechanism specifier (see 7642 `base.AnyMechanismSpecifier`). If it's not a mechanism ID and 7643 doesn't have its own position information, the 'where' argument 7644 can be used to hint where to search for the mechanism. 7645 """ 7646 now = self.getSituation() 7647 mID = now.graph.resolveMechanism(mechanism, startFrom=where) 7648 if mID is None: 7649 raise MissingMechanismError( 7650 f"Couldn't find mechanism for {repr(mechanism)}." 7651 ) 7652 now.state['mechanisms'][mID] = toState 7653 7654 def skillLevel( 7655 self, 7656 skill: base.Skill, 7657 step: Optional[int] = None 7658 ) -> Optional[base.Level]: 7659 """ 7660 Returns the skill level the player had in a given skill at a 7661 given step, or for the current step if no step is specified. 7662 Returns `None` if the player had never acquired or lost levels 7663 in that skill before the specified step (skill level would count 7664 as 0 in that case). 7665 7666 This method adds together levels from the common and active 7667 focal contexts. 7668 """ 7669 if step is None: 7670 step = -1 7671 state = self.getSituation(step).state 7672 commonContext = state['common'] 7673 activeContext = state['contexts'][state['activeContext']] 7674 base = commonContext['capabilities']['skills'].get(skill) 7675 if base is None: 7676 return activeContext['capabilities']['skills'].get(skill) 7677 else: 7678 return base + activeContext['capabilities']['skills'].get( 7679 skill, 7680 0 7681 ) 7682 7683 def adjustSkillLevelNow( 7684 self, 7685 skill: base.Skill, 7686 levels: base.Level, 7687 inCommon: bool = False 7688 ) -> None: 7689 """ 7690 Modifies the current game state to add the specified number of 7691 `Level`s of the given skill. No changes are made to the current 7692 graph. Reduce the skill level by supplying negative levels; note 7693 that negative skill levels are possible. 7694 7695 By default, the skill level for the current active 7696 `FocalContext` will be adjusted. However, if `inCommon` is set 7697 to `True`, then the skill level for the common context will be 7698 adjusted instead. 7699 """ 7700 # TODO: Custom level caps? 7701 state = self.getSituation().state 7702 if inCommon: 7703 context = state['common'] 7704 else: 7705 context = state['contexts'][state['activeContext']] 7706 skills = context['capabilities']['skills'] 7707 skills[skill] = skills.get(skill, 0) + levels 7708 7709 def setSkillLevelNow( 7710 self, 7711 skill: base.Skill, 7712 level: base.Level, 7713 inCommon: bool = False 7714 ) -> None: 7715 """ 7716 Modifies the current game state to set `Skill` `Level` for the 7717 given skill, regardless of the old value. No changes are made to 7718 the current graph. 7719 7720 By default this sets the skill level for the active 7721 `FocalContext`. But if you set `inCommon` to `True`, it will set 7722 the skill level in the common context instead. 7723 """ 7724 # TODO: Custom level caps? 7725 state = self.getSituation().state 7726 if inCommon: 7727 context = state['common'] 7728 else: 7729 context = state['contexts'][state['activeContext']] 7730 skills = context['capabilities']['skills'] 7731 skills[skill] = level 7732 7733 def updateRequirementNow( 7734 self, 7735 decision: base.AnyDecisionSpecifier, 7736 transition: base.Transition, 7737 requirement: Optional[base.Requirement] 7738 ) -> None: 7739 """ 7740 Updates the requirement for a specific transition in a specific 7741 decision. Use `None` to remove the requirement for that edge. 7742 """ 7743 if requirement is None: 7744 requirement = base.ReqNothing() 7745 self.getSituation().graph.setTransitionRequirement( 7746 decision, 7747 transition, 7748 requirement 7749 ) 7750 7751 def isTraversable( 7752 self, 7753 decision: base.AnyDecisionSpecifier, 7754 transition: base.Transition, 7755 step: int = -1 7756 ) -> bool: 7757 """ 7758 Returns True if the specified transition from the specified 7759 decision had its requirement satisfied by the game state at the 7760 specified step (or at the current step if no step is specified). 7761 Raises an `IndexError` if the specified step doesn't exist, and 7762 a `KeyError` if the decision or transition specified does not 7763 exist in the `DecisionGraph` at that step. 7764 """ 7765 situation = self.getSituation(step) 7766 req = situation.graph.getTransitionRequirement(decision, transition) 7767 ctx = base.contextForTransition(situation, decision, transition) 7768 fromID = situation.graph.resolveDecision(decision) 7769 return ( 7770 req.satisfied(ctx) 7771 and (fromID, transition) not in situation.state['deactivated'] 7772 ) 7773 7774 def applyTransitionEffect( 7775 self, 7776 whichEffect: base.EffectSpecifier, 7777 moveWhich: Optional[base.FocalPointName] = None 7778 ) -> Optional[base.DecisionID]: 7779 """ 7780 Applies an effect attached to a transition, taking charges and 7781 delay into account based on the current `Situation`. 7782 Modifies the effect's trigger count (but may not actually 7783 trigger the effect if the charges and/or delay values indicate 7784 not to; see `base.doTriggerEffect`). 7785 7786 If a specific focal point in a plural-focalized domain is 7787 triggering the effect, the focal point name should be specified 7788 via `moveWhich` so that goto `Effect`s can know which focal 7789 point to move when it's not explicitly specified in the effect. 7790 TODO: Test this! 7791 7792 Returns None most of the time, but if a 'goto', 'bounce', or 7793 'follow' effect was applied, it returns the decision ID for that 7794 effect's destination, which would override a transition's normal 7795 destination. If it returns a destination ID, then the exploration 7796 state will already have been updated to set the position there, 7797 and further position updates are not needed. 7798 7799 Note that transition effects which update active decisions will 7800 also update the exploration status of those decisions to 7801 'exploring' if they had been in an unvisited status (see 7802 `updatePosition` and `hasBeenVisited`). 7803 7804 Note: callers should immediately update situation-based variables 7805 that might have been changes by a 'revert' effect. 7806 """ 7807 now = self.getSituation() 7808 effect, triggerCount = base.doTriggerEffect(now, whichEffect) 7809 if triggerCount is not None: 7810 return self.applyExtraneousEffect( 7811 effect, 7812 where=whichEffect[:2], 7813 moveWhich=moveWhich 7814 ) 7815 else: 7816 return None 7817 7818 def applyExtraneousEffect( 7819 self, 7820 effect: base.Effect, 7821 where: Optional[ 7822 Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]] 7823 ] = None, 7824 moveWhich: Optional[base.FocalPointName] = None, 7825 challengePolicy: base.ChallengePolicy = "specified" 7826 ) -> Optional[base.DecisionID]: 7827 """ 7828 Applies a single extraneous effect to the state & graph, 7829 *without* accounting for charges or delay values, since the 7830 effect is not part of the graph (use `applyTransitionEffect` to 7831 apply effects that are attached to transitions, which is almost 7832 always the function you should be using). An associated 7833 transition for the extraneous effect can be supplied using the 7834 `where` argument, and effects like 'deactivate' and 'edit' will 7835 affect it (but the effect's charges and delay values will still 7836 be ignored). 7837 7838 If the effect would change the destination of a transition, the 7839 altered destination ID is returned: 'bounce' effects return the 7840 provided decision part of `where`, 'goto' effects return their 7841 target, and 'follow' effects return the destination followed to 7842 (possibly via chained follows in the extreme case). In all other 7843 cases, `None` is returned indicating no change to a normal 7844 destination. 7845 7846 If a specific focal point in a plural-focalized domain is 7847 triggering the effect, the focal point name should be specified 7848 via `moveWhich` so that goto `Effect`s can know which focal 7849 point to move when it's not explicitly specified in the effect. 7850 TODO: Test this! 7851 7852 Note that transition effects which update active decisions will 7853 also update the exploration status of those decisions to 7854 'exploring' if they had been in an unvisited status and will 7855 remove any 'unconfirmed' tag they might still have (see 7856 `updatePosition` and `hasBeenVisited`). 7857 7858 The given `challengePolicy` is applied when traversing further 7859 transitions due to 'follow' effects. 7860 7861 Note: Anyone calling `applyExtraneousEffect` should update any 7862 situation-based variables immediately after the call, as a 7863 'revert' effect may have changed the current graph and/or state. 7864 """ 7865 typ = effect['type'] 7866 value = effect['value'] 7867 applyTo = effect['applyTo'] 7868 inCommon = applyTo == 'common' 7869 7870 now = self.getSituation() 7871 7872 if where is not None: 7873 if where[1] is not None: 7874 searchFrom = now.graph.bothEnds(where[0], where[1]) 7875 else: 7876 searchFrom = {now.graph.resolveDecision(where[0])} 7877 else: 7878 searchFrom = None 7879 7880 # Note: Delay and charges are ignored! 7881 7882 if typ in ("gain", "lose"): 7883 value = cast( 7884 Union[ 7885 base.Capability, 7886 Tuple[base.Token, base.TokenCount], 7887 Tuple[Literal['skill'], base.Skill, base.Level], 7888 ], 7889 value 7890 ) 7891 if isinstance(value, base.Capability): 7892 if typ == "gain": 7893 self.gainCapabilityNow(value, inCommon) 7894 else: 7895 self.loseCapabilityNow(value, inCommon) 7896 elif len(value) == 2: # must be a token, amount pair 7897 token, amount = cast( 7898 Tuple[base.Token, base.TokenCount], 7899 value 7900 ) 7901 if typ == "lose": 7902 amount *= -1 7903 self.adjustTokensNow(token, amount, inCommon) 7904 else: # must be a 'skill', skill, level triple 7905 _, skill, levels = cast( 7906 Tuple[Literal['skill'], base.Skill, base.Level], 7907 value 7908 ) 7909 if typ == "lose": 7910 levels *= -1 7911 self.adjustSkillLevelNow(skill, levels, inCommon) 7912 7913 elif typ == "set": 7914 value = cast( 7915 Union[ 7916 Tuple[base.Token, base.TokenCount], 7917 Tuple[base.AnyMechanismSpecifier, base.MechanismState], 7918 Tuple[Literal['skill'], base.Skill, base.Level], 7919 ], 7920 value 7921 ) 7922 if len(value) == 2: # must be a token or mechanism pair 7923 if isinstance(value[1], base.TokenCount): # token 7924 token, amount = cast( 7925 Tuple[base.Token, base.TokenCount], 7926 value 7927 ) 7928 self.setTokensNow(token, amount, inCommon) 7929 else: # mechanism 7930 mechanism, state = cast( 7931 Tuple[ 7932 base.AnyMechanismSpecifier, 7933 base.MechanismState 7934 ], 7935 value 7936 ) 7937 self.setMechanismStateNow(mechanism, state, searchFrom) 7938 else: # must be a 'skill', skill, level triple 7939 _, skill, level = cast( 7940 Tuple[Literal['skill'], base.Skill, base.Level], 7941 value 7942 ) 7943 self.setSkillLevelNow(skill, level, inCommon) 7944 7945 elif typ == "toggle": 7946 # Length-1 list just toggles a capability on/off based on current 7947 # state (not attending to equivalents): 7948 if isinstance(value, List): # capabilities list 7949 value = cast(List[base.Capability], value) 7950 if len(value) == 0: 7951 raise ValueError( 7952 "Toggle effect has empty capabilities list." 7953 ) 7954 if len(value) == 1: 7955 capability = value[0] 7956 if self.hasCapability(capability, inCommon=False): 7957 self.loseCapabilityNow(capability, inCommon=False) 7958 else: 7959 self.gainCapabilityNow(capability) 7960 else: 7961 # Otherwise toggle all powers off, then one on, 7962 # based on the first capability that's currently on. 7963 # Note we do NOT count equivalences. 7964 7965 # Find first capability that's on: 7966 firstIndex: Optional[int] = None 7967 for i, capability in enumerate(value): 7968 if self.hasCapability(capability): 7969 firstIndex = i 7970 break 7971 7972 # Turn them all off: 7973 for capability in value: 7974 self.loseCapabilityNow(capability, inCommon=False) 7975 # TODO: inCommon for the check? 7976 7977 if firstIndex is None: 7978 self.gainCapabilityNow(value[0]) 7979 else: 7980 self.gainCapabilityNow( 7981 value[(firstIndex + 1) % len(value)] 7982 ) 7983 else: # must be a mechanism w/ states list 7984 mechanism, states = cast( 7985 Tuple[ 7986 base.AnyMechanismSpecifier, 7987 List[base.MechanismState] 7988 ], 7989 value 7990 ) 7991 currentState = self.mechanismState(mechanism, where=searchFrom) 7992 if len(states) == 1: 7993 if currentState == states[0]: 7994 # default alternate state 7995 self.setMechanismStateNow( 7996 mechanism, 7997 base.DEFAULT_MECHANISM_STATE, 7998 searchFrom 7999 ) 8000 else: 8001 self.setMechanismStateNow( 8002 mechanism, 8003 states[0], 8004 searchFrom 8005 ) 8006 else: 8007 # Find our position in the list, if any 8008 try: 8009 currentIndex = states.index(cast(str, currentState)) 8010 # Cast here just because we know that None will 8011 # raise a ValueError but we'll catch it, and we 8012 # want to suppress the mypy warning about the 8013 # option 8014 except ValueError: 8015 currentIndex = len(states) - 1 8016 # Set next state in list as current state 8017 nextIndex = (currentIndex + 1) % len(states) 8018 self.setMechanismStateNow( 8019 mechanism, 8020 states[nextIndex], 8021 searchFrom 8022 ) 8023 8024 elif typ == "deactivate": 8025 if where is None or where[1] is None: 8026 raise ValueError( 8027 "Can't apply a deactivate effect without specifying" 8028 " which transition it applies to." 8029 ) 8030 8031 decision, transition = cast( 8032 Tuple[base.AnyDecisionSpecifier, base.Transition], 8033 where 8034 ) 8035 8036 dID = now.graph.resolveDecision(decision) 8037 now.state['deactivated'].add((dID, transition)) 8038 8039 elif typ == "edit": 8040 value = cast(List[List[commands.Command]], value) 8041 # If there are no blocks, do nothing 8042 if len(value) > 0: 8043 # Apply the first block of commands and then rotate the list 8044 scope: commands.Scope = {} 8045 if where is not None: 8046 here: base.DecisionID = now.graph.resolveDecision( 8047 where[0] 8048 ) 8049 outwards: Optional[base.Transition] = where[1] 8050 scope['@'] = here 8051 scope['@t'] = outwards 8052 if outwards is not None: 8053 reciprocal = now.graph.getReciprocal(here, outwards) 8054 destination = now.graph.getDestination(here, outwards) 8055 else: 8056 reciprocal = None 8057 destination = None 8058 scope['@r'] = reciprocal 8059 scope['@d'] = destination 8060 self.runCommandBlock(value[0], scope) 8061 value.append(value.pop(0)) 8062 8063 elif typ == "goto": 8064 if isinstance(value, base.DecisionSpecifier): 8065 target: base.AnyDecisionSpecifier = value 8066 # use moveWhich provided as argument 8067 elif isinstance(value, tuple): 8068 target, moveWhich = cast( 8069 Tuple[base.AnyDecisionSpecifier, base.FocalPointName], 8070 value 8071 ) 8072 else: 8073 target = cast(base.AnyDecisionSpecifier, value) 8074 # use moveWhich provided as argument 8075 8076 destID = now.graph.resolveDecision(target) 8077 base.updatePosition(now, destID, applyTo, moveWhich) 8078 return destID 8079 8080 elif typ == "bounce": 8081 # Just need to let the caller know they should cancel 8082 if where is None: 8083 raise ValueError( 8084 "Can't apply a 'bounce' effect without a position" 8085 " to apply it from." 8086 ) 8087 return now.graph.resolveDecision(where[0]) 8088 8089 elif typ == "follow": 8090 if where is None: 8091 raise ValueError( 8092 f"Can't follow transition {value!r} because there" 8093 f" is no position information when applying the" 8094 f" effect." 8095 ) 8096 if where[1] is not None: 8097 followFrom = now.graph.getDestination(where[0], where[1]) 8098 if followFrom is None: 8099 raise ValueError( 8100 f"Can't follow transition {value!r} because the" 8101 f" position information specifies transition" 8102 f" {where[1]!r} from decision" 8103 f" {now.graph.identityOf(where[0])} but that" 8104 f" transition does not exist." 8105 ) 8106 else: 8107 followFrom = now.graph.resolveDecision(where[0]) 8108 8109 following = cast(base.Transition, value) 8110 8111 followTo = now.graph.getDestination(followFrom, following) 8112 8113 if followTo is None: 8114 raise ValueError( 8115 f"Can't follow transition {following!r} because" 8116 f" that transition doesn't exist at the specified" 8117 f" destination {now.graph.identityOf(followFrom)}." 8118 ) 8119 8120 if self.isTraversable(followFrom, following): # skip if not 8121 # Perform initial position update before following new 8122 # transition: 8123 base.updatePosition( 8124 now, 8125 followFrom, 8126 applyTo, 8127 moveWhich 8128 ) 8129 8130 # Apply consequences of followed transition 8131 fullFollowTo = self.applyTransitionConsequence( 8132 followFrom, 8133 following, 8134 moveWhich, 8135 challengePolicy 8136 ) 8137 8138 # Now update to end of followed transition 8139 if fullFollowTo is None: 8140 base.updatePosition( 8141 now, 8142 followTo, 8143 applyTo, 8144 moveWhich 8145 ) 8146 fullFollowTo = followTo 8147 8148 # Skip the normal update: we've taken care of that plus more 8149 return fullFollowTo 8150 else: 8151 # Normal position updates still applies since follow 8152 # transition wasn't possible 8153 return None 8154 8155 elif typ == "save": 8156 assert isinstance(value, base.SaveSlot) 8157 now.saves[value] = copy.deepcopy((now.graph, now.state)) 8158 8159 else: 8160 raise ValueError(f"Invalid effect type {typ!r}.") 8161 8162 return None # default return value if we didn't return above 8163 8164 def applyExtraneousConsequence( 8165 self, 8166 consequence: base.Consequence, 8167 where: Optional[ 8168 Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]] 8169 ] = None, 8170 moveWhich: Optional[base.FocalPointName] = None 8171 ) -> Optional[base.DecisionID]: 8172 """ 8173 Applies an extraneous consequence not associated with a 8174 transition. Unlike `applyTransitionConsequence`, the provided 8175 `base.Consequence` must already have observed outcomes (see 8176 `base.observeChallengeOutcomes`). Returns the decision ID for a 8177 decision implied by a goto, follow, or bounce effect, or `None` 8178 if no effect implies a destination. 8179 8180 The `where` and `moveWhich` optional arguments specify which 8181 decision and/or transition to use as the application position, 8182 and/or which focal point to move. This affects mechanism lookup 8183 as well as the end position when 'follow' effects are used. 8184 Specifically: 8185 8186 - A 'follow' trigger will search for transitions to follow from 8187 the destination of the specified transition, or if only a 8188 decision was supplied, from that decision. 8189 - Mechanism lookups will start with both ends of the specified 8190 transition as their search field (or with just the specified 8191 decision if no transition is included). 8192 8193 'bounce' effects will cause an error unless position information 8194 is provided, and will set the position to the base decision 8195 provided in `where`. 8196 8197 Note: callers should update any situation-based variables 8198 immediately after calling this as a 'revert' effect could change 8199 the current graph and/or state and other changes could get lost 8200 if they get applied to a stale graph/state. 8201 8202 # TODO: Examples for goto and follow effects. 8203 """ 8204 now = self.getSituation() 8205 searchFrom = set() 8206 if where is not None: 8207 if where[1] is not None: 8208 searchFrom = now.graph.bothEnds(where[0], where[1]) 8209 else: 8210 searchFrom = {now.graph.resolveDecision(where[0])} 8211 8212 context = base.RequirementContext( 8213 state=now.state, 8214 graph=now.graph, 8215 searchFrom=searchFrom 8216 ) 8217 8218 effectIndices = base.observedEffects(context, consequence) 8219 destID = None 8220 for index in effectIndices: 8221 effect = base.consequencePart(consequence, index) 8222 if not isinstance(effect, dict) or 'value' not in effect: 8223 raise RuntimeError( 8224 f"Invalid effect index {index}: Consequence part at" 8225 f" that index is not an Effect. Got:\n{effect}" 8226 ) 8227 effect = cast(base.Effect, effect) 8228 destID = self.applyExtraneousEffect( 8229 effect, 8230 where, 8231 moveWhich 8232 ) 8233 # technically this variable is not used later in this 8234 # function, but the `applyExtraneousEffect` call means it 8235 # needs an update, so we're doing that in case someone later 8236 # adds code to this function that uses 'now' after this 8237 # point. 8238 now = self.getSituation() 8239 8240 return destID 8241 8242 def applyTransitionConsequence( 8243 self, 8244 decision: base.AnyDecisionSpecifier, 8245 transition: base.AnyTransition, 8246 moveWhich: Optional[base.FocalPointName] = None, 8247 policy: base.ChallengePolicy = "specified", 8248 fromIndex: Optional[int] = None, 8249 toIndex: Optional[int] = None 8250 ) -> Optional[base.DecisionID]: 8251 """ 8252 Applies the effects of the specified transition to the current 8253 graph and state, possibly overriding observed outcomes using 8254 outcomes specified as part of a `base.TransitionWithOutcomes`. 8255 8256 The `where` and `moveWhich` function serve the same purpose as 8257 for `applyExtraneousEffect`. If `where` is `None`, then the 8258 effects will be applied as extraneous effects, meaning that 8259 their delay and charges values will be ignored and their trigger 8260 count will not be tracked. If `where` is supplied 8261 8262 Returns either None to indicate that the position update for the 8263 transition should apply as usual, or a decision ID indicating 8264 another destination which has already been applied by a 8265 transition effect. 8266 8267 If `fromIndex` and/or `toIndex` are specified, then only effects 8268 which have indices between those two (inclusive) will be 8269 applied, and other effects will neither apply nor be updated in 8270 any way. Note that `onlyPart` does not override the challenge 8271 policy: if the effects in the specified part are not applied due 8272 to a challenge outcome, they still won't happen, including 8273 challenge outcomes outside of that part. Also, outcomes for 8274 challenges of the entire consequence are re-observed if the 8275 challenge policy implies it. 8276 8277 Note: Anyone calling this should update any situation-based 8278 variables immediately after the call, as a 'revert' effect may 8279 have changed the current graph and/or state. 8280 """ 8281 now = self.getSituation() 8282 dID = now.graph.resolveDecision(decision) 8283 8284 transitionName, outcomes = base.nameAndOutcomes(transition) 8285 8286 searchFrom = set() 8287 searchFrom = now.graph.bothEnds(dID, transitionName) 8288 8289 context = base.RequirementContext( 8290 state=now.state, 8291 graph=now.graph, 8292 searchFrom=searchFrom 8293 ) 8294 8295 consequence = now.graph.getConsequence(dID, transitionName) 8296 8297 # Make sure that challenge outcomes are known 8298 if policy != "specified": 8299 base.resetChallengeOutcomes(consequence) 8300 useUp = outcomes[:] 8301 base.observeChallengeOutcomes( 8302 context, 8303 consequence, 8304 location=searchFrom, 8305 policy=policy, 8306 knownOutcomes=useUp 8307 ) 8308 if len(useUp) > 0: 8309 raise ValueError( 8310 f"More outcomes specified than challenges observed in" 8311 f" consequence:\n{consequence}" 8312 f"\nRemaining outcomes:\n{useUp}" 8313 ) 8314 8315 # Figure out which effects apply, and apply each of them 8316 effectIndices = base.observedEffects(context, consequence) 8317 if fromIndex is None: 8318 fromIndex = 0 8319 8320 altDest = None 8321 for index in effectIndices: 8322 if ( 8323 index >= fromIndex 8324 and (toIndex is None or index <= toIndex) 8325 ): 8326 thisDest = self.applyTransitionEffect( 8327 (dID, transitionName, index), 8328 moveWhich 8329 ) 8330 if thisDest is not None: 8331 altDest = thisDest 8332 # TODO: What if this updates state with 'revert' to a 8333 # graph that doesn't contain the same effects? 8334 # TODO: Update 'now' and 'context'?! 8335 return altDest 8336 8337 def allDecisions(self) -> List[base.DecisionID]: 8338 """ 8339 Returns the list of all decisions which existed at any point 8340 within the exploration. Example: 8341 8342 >>> ex = DiscreteExploration() 8343 >>> ex.start('A') 8344 0 8345 >>> ex.observe('A', 'right') 8346 1 8347 >>> ex.explore('right', 'B', 'left') 8348 1 8349 >>> ex.observe('B', 'right') 8350 2 8351 >>> ex.allDecisions() # 'A', 'B', and the unnamed 'right of B' 8352 [0, 1, 2] 8353 """ 8354 seen = set() 8355 result = [] 8356 for situation in self: 8357 for decision in situation.graph: 8358 if decision not in seen: 8359 result.append(decision) 8360 seen.add(decision) 8361 8362 return result 8363 8364 def allExploredDecisions(self) -> List[base.DecisionID]: 8365 """ 8366 Returns the list of all decisions which existed at any point 8367 within the exploration, excluding decisions whose highest 8368 exploration status was `noticed` or lower. May still include 8369 decisions which don't exist in the final situation's graph due to 8370 things like decision merging. Example: 8371 8372 >>> ex = DiscreteExploration() 8373 >>> ex.start('A') 8374 0 8375 >>> ex.observe('A', 'right') 8376 1 8377 >>> ex.explore('right', 'B', 'left') 8378 1 8379 >>> ex.observe('B', 'right') 8380 2 8381 >>> graph = ex.getSituation().graph 8382 >>> graph.addDecision('C') # add isolated decision; doesn't set status 8383 3 8384 >>> ex.hasBeenVisited('C') 8385 False 8386 >>> ex.allExploredDecisions() 8387 [0, 1] 8388 >>> ex.setExplorationStatus('C', 'exploring') 8389 >>> ex.allExploredDecisions() # 2 is the decision right from 'B' 8390 [0, 1, 3] 8391 >>> ex.setExplorationStatus('A', 'explored') 8392 >>> ex.allExploredDecisions() 8393 [0, 1, 3] 8394 >>> ex.setExplorationStatus('A', 'unknown') 8395 >>> # remains visisted in an earlier step 8396 >>> ex.allExploredDecisions() 8397 [0, 1, 3] 8398 >>> ex.setExplorationStatus('C', 'unknown') # not explored earlier 8399 >>> ex.allExploredDecisions() # 2 is the decision right from 'B' 8400 [0, 1] 8401 """ 8402 seen = set() 8403 result = [] 8404 for situation in self: 8405 graph = situation.graph 8406 for decision in graph: 8407 if ( 8408 decision not in seen 8409 and base.hasBeenVisited(situation, decision) 8410 ): 8411 result.append(decision) 8412 seen.add(decision) 8413 8414 return result 8415 8416 def allVisitedDecisions(self) -> List[base.DecisionID]: 8417 """ 8418 Returns the list of all decisions which existed at any point 8419 within the exploration and which were visited at least once. 8420 Usually all of these will be present in the final situation's 8421 graph, but sometimes merging or other factors means there might 8422 be some that won't be. Being present on the game state's 'active' 8423 list in a step for its domain is what counts as "being visited," 8424 which means that nodes which were passed through directly via a 8425 'follow' effect won't be counted, for example. 8426 8427 This should usually correspond with the absence of the 8428 'unconfirmed' tag. 8429 8430 Example: 8431 8432 >>> ex = DiscreteExploration() 8433 >>> ex.start('A') 8434 0 8435 >>> ex.observe('A', 'right') 8436 1 8437 >>> ex.explore('right', 'B', 'left') 8438 1 8439 >>> ex.observe('B', 'right') 8440 2 8441 >>> ex.getSituation().graph.addDecision('C') # add isolated decision 8442 3 8443 >>> av = ex.allVisitedDecisions() 8444 >>> av 8445 [0, 1] 8446 >>> all( # no decisions in the 'visited' list are tagged 8447 ... 'unconfirmed' not in ex.getSituation().graph.decisionTags(d) 8448 ... for d in av 8449 ... ) 8450 True 8451 >>> graph = ex.getSituation().graph 8452 >>> 'unconfirmed' in graph.decisionTags(0) 8453 False 8454 >>> 'unconfirmed' in graph.decisionTags(1) 8455 False 8456 >>> 'unconfirmed' in graph.decisionTags(2) 8457 True 8458 >>> 'unconfirmed' in graph.decisionTags(3) # not tagged; not explored 8459 False 8460 """ 8461 seen = set() 8462 result = [] 8463 for step in range(len(self)): 8464 active = self.getActiveDecisions(step) 8465 for dID in active: 8466 if dID not in seen: 8467 result.append(dID) 8468 seen.add(dID) 8469 8470 return result 8471 8472 def start( 8473 self, 8474 decision: base.AnyDecisionSpecifier, 8475 startCapabilities: Optional[base.CapabilitySet] = None, 8476 setMechanismStates: Optional[ 8477 Dict[base.MechanismID, base.MechanismState] 8478 ] = None, 8479 setCustomState: Optional[dict] = None, 8480 decisionType: base.DecisionType = "imposed" 8481 ) -> base.DecisionID: 8482 """ 8483 Sets the initial position information for a newly-relevant 8484 domain for the current focal context. Creates a new decision 8485 if the decision is specified by name or `DecisionSpecifier` and 8486 that decision doesn't already exist. Returns the decision ID for 8487 the newly-placed decision (or for the specified decision if it 8488 already existed). 8489 8490 Raises a `BadStart` error if the current focal context already 8491 has position information for the specified domain. 8492 8493 - The given `startCapabilities` replaces any existing 8494 capabilities for the current focal context, although you can 8495 leave it as the default `None` to avoid that and retain any 8496 capabilities that have been set up already. 8497 - The given `setMechanismStates` and `setCustomState` 8498 dictionaries override all previous mechanism states & custom 8499 states in the new situation. Leave these as the default 8500 `None` to maintain those states. 8501 - If created, the decision will be placed in the DEFAULT_DOMAIN 8502 domain unless it's specified as a `base.DecisionSpecifier` 8503 with a domain part, in which case that domain is used. 8504 - If specified as a `base.DecisionSpecifier` with a zone part 8505 and a new decision needs to be created, the decision will be 8506 added to that zone, creating it at level 0 if necessary, 8507 although otherwise no zone information will be changed. 8508 - Resets the decision type to "pending" and the action taken to 8509 `None`. Sets the decision type of the previous situation to 8510 'imposed' (or the specified `decisionType`) and sets an 8511 appropriate 'start' action for that situation. 8512 - Tags the step with 'start'. 8513 - Even in a plural- or spreading-focalized domain, you still need 8514 to pick one decision to start at. 8515 """ 8516 now = self.getSituation() 8517 8518 startID = now.graph.getDecision(decision) 8519 zone = None 8520 domain = base.DEFAULT_DOMAIN 8521 if startID is None: 8522 if isinstance(decision, base.DecisionID): 8523 raise MissingDecisionError( 8524 f"Cannot start at decision {decision} because no" 8525 f" decision with that ID exists. Supply a name or" 8526 f" DecisionSpecifier if you need the start decision" 8527 f" to be created automatically." 8528 ) 8529 elif isinstance(decision, base.DecisionName): 8530 decision = base.DecisionSpecifier( 8531 domain=None, 8532 zone=None, 8533 name=decision 8534 ) 8535 startID = now.graph.addDecision( 8536 decision.name, 8537 domain=decision.domain 8538 ) 8539 zone = decision.zone 8540 if decision.domain is not None: 8541 domain = decision.domain 8542 8543 if zone is not None: 8544 if now.graph.getZoneInfo(zone) is None: 8545 now.graph.createZone(zone, 0) 8546 now.graph.addDecisionToZone(startID, zone) 8547 8548 action: base.ExplorationAction = ( 8549 'start', 8550 startID, 8551 startID, 8552 domain, 8553 startCapabilities, 8554 setMechanismStates, 8555 setCustomState 8556 ) 8557 8558 self.advanceSituation(action, decisionType) 8559 8560 return startID 8561 8562 def hasBeenVisited( 8563 self, 8564 decision: base.AnyDecisionSpecifier, 8565 step: int = -1 8566 ): 8567 """ 8568 Returns whether or not the specified decision has been visited in 8569 the specified step (default current step). 8570 """ 8571 return base.hasBeenVisited(self.getSituation(step), decision) 8572 8573 def setExplorationStatus( 8574 self, 8575 decision: base.AnyDecisionSpecifier, 8576 status: base.ExplorationStatus, 8577 upgradeOnly: bool = False 8578 ): 8579 """ 8580 Updates the current exploration status of a specific decision in 8581 the current situation. If `upgradeOnly` is true (default is 8582 `False` then the update will only apply if the new exploration 8583 status counts as 'more-explored' than the old one (see 8584 `base.moreExplored`). 8585 """ 8586 base.setExplorationStatus( 8587 self.getSituation(), 8588 decision, 8589 status, 8590 upgradeOnly 8591 ) 8592 8593 def getExplorationStatus( 8594 self, 8595 decision: base.AnyDecisionSpecifier, 8596 step: int = -1 8597 ): 8598 """ 8599 Returns the exploration status of the specified decision at the 8600 specified step (default is last step). Decisions whose 8601 exploration status has never been set will have a default status 8602 of 'unknown'. 8603 """ 8604 situation = self.getSituation(step) 8605 dID = situation.graph.resolveDecision(decision) 8606 return situation.state['exploration'].get(dID, 'unknown') 8607 8608 def deduceTransitionDetailsAtStep( 8609 self, 8610 step: int, 8611 transition: base.Transition, 8612 fromDecision: Optional[base.AnyDecisionSpecifier] = None, 8613 whichFocus: Optional[base.FocalPointSpecifier] = None, 8614 inCommon: Union[bool, Literal["auto"]] = "auto" 8615 ) -> Tuple[ 8616 base.ContextSpecifier, 8617 base.DecisionID, 8618 base.DecisionID, 8619 Optional[base.FocalPointSpecifier] 8620 ]: 8621 """ 8622 Given just a transition name which the player intends to take in 8623 a specific step, deduces the `ContextSpecifier` for which 8624 context should be updated, the source and destination 8625 `DecisionID`s for the transition, and if the destination 8626 decision's domain is plural-focalized, the `FocalPointName` 8627 specifying which focal point should be moved. 8628 8629 Because many of those things are ambiguous, you may get an 8630 `AmbiguousTransitionError` when things are underspecified, and 8631 there are options for specifying some of the extra information 8632 directly: 8633 8634 - `fromDecision` may be used to specify the source decision. 8635 - `whichFocus` may be used to specify the focal point (within a 8636 particular context/domain) being updated. When focal point 8637 ambiguity remains and this is unspecified, the 8638 alphabetically-earliest relevant focal point will be used 8639 (either among all focal points which activate the source 8640 decision, if there are any, or among all focal points for 8641 the entire domain of the destination decision). 8642 - `inCommon` (a `ContextSpecifier`) may be used to specify which 8643 context to update. The default of "auto" will cause the 8644 active context to be selected unless it does not activate 8645 the source decision, in which case the common context will 8646 be selected. 8647 8648 A `MissingDecisionError` will be raised if there are no current 8649 active decisions (e.g., before `start` has been called), and a 8650 `MissingTransitionError` will be raised if the listed transition 8651 does not exist from any active decision (or from the specified 8652 decision if `fromDecision` is used). 8653 """ 8654 now = self.getSituation(step) 8655 active = self.getActiveDecisions(step) 8656 if len(active) == 0: 8657 raise MissingDecisionError( 8658 f"There are no active decisions from which transition" 8659 f" {repr(transition)} could be taken at step {step}." 8660 ) 8661 8662 # All source/destination decision pairs for transitions with the 8663 # given transition name. 8664 allDecisionPairs: Dict[base.DecisionID, base.DecisionID] = {} 8665 8666 # TODO: When should we be trimming the active decisions to match 8667 # any alterations to the graph? 8668 for dID in active: 8669 outgoing = now.graph.destinationsFrom(dID) 8670 if transition in outgoing: 8671 allDecisionPairs[dID] = outgoing[transition] 8672 8673 if len(allDecisionPairs) == 0: 8674 raise MissingTransitionError( 8675 f"No transitions named {repr(transition)} are outgoing" 8676 f" from active decisions at step {step}." 8677 f"\nActive decisions are:" 8678 f"\n{now.graph.namesListing(active)}" 8679 ) 8680 8681 if ( 8682 fromDecision is not None 8683 and fromDecision not in allDecisionPairs 8684 ): 8685 raise MissingTransitionError( 8686 f"{fromDecision} was specified as the source decision" 8687 f" for traversing transition {repr(transition)} but" 8688 f" there is no transition of that name from that" 8689 f" decision at step {step}." 8690 f"\nValid source decisions are:" 8691 f"\n{now.graph.namesListing(allDecisionPairs)}" 8692 ) 8693 elif fromDecision is not None: 8694 fromID = now.graph.resolveDecision(fromDecision) 8695 destID = allDecisionPairs[fromID] 8696 fromDomain = now.graph.domainFor(fromID) 8697 elif len(allDecisionPairs) == 1: 8698 fromID, destID = list(allDecisionPairs.items())[0] 8699 fromDomain = now.graph.domainFor(fromID) 8700 else: 8701 fromID = None 8702 destID = None 8703 fromDomain = None 8704 # Still ambiguous; resolve this below 8705 8706 # Use whichFocus if provided 8707 if whichFocus is not None: 8708 # Type/value check for whichFocus 8709 if ( 8710 not isinstance(whichFocus, tuple) 8711 or len(whichFocus) != 3 8712 or whichFocus[0] not in ("active", "common") 8713 or not isinstance(whichFocus[1], base.Domain) 8714 or not isinstance(whichFocus[2], base.FocalPointName) 8715 ): 8716 raise ValueError( 8717 f"Invalid whichFocus value {repr(whichFocus)}." 8718 f"\nMust be a length-3 tuple with 'active' or 'common'" 8719 f" as the first element, a Domain as the second" 8720 f" element, and a FocalPointName as the third" 8721 f" element." 8722 ) 8723 8724 # Resolve focal point specified 8725 fromID = base.resolvePosition( 8726 now, 8727 whichFocus 8728 ) 8729 if fromID is None: 8730 raise MissingTransitionError( 8731 f"Focal point {repr(whichFocus)} was specified as" 8732 f" the transition source, but that focal point does" 8733 f" not have a position." 8734 ) 8735 else: 8736 destID = now.graph.destination(fromID, transition) 8737 fromDomain = now.graph.domainFor(fromID) 8738 8739 elif fromID is None: # whichFocus is None, so it can't disambiguate 8740 raise AmbiguousTransitionError( 8741 f"Transition {repr(transition)} was selected for" 8742 f" disambiguation, but there are multiple transitions" 8743 f" with that name from currently-active decisions, and" 8744 f" neither fromDecision nor whichFocus adequately" 8745 f" disambiguates the specific transition taken." 8746 f"\nValid source decisions at step {step} are:" 8747 f"\n{now.graph.namesListing(allDecisionPairs)}" 8748 ) 8749 8750 # At this point, fromID, destID, and fromDomain have 8751 # been resolved. 8752 if fromID is None or destID is None or fromDomain is None: 8753 raise RuntimeError( 8754 f"One of fromID, destID, or fromDomain was None after" 8755 f" disambiguation was finished:" 8756 f"\nfromID: {fromID}, destID: {destID}, fromDomain:" 8757 f" {repr(fromDomain)}" 8758 ) 8759 8760 # Now figure out which context activated the source so we know 8761 # which focal point we're moving: 8762 context = self.getActiveContext() 8763 active = base.activeDecisionSet(context) 8764 using: base.ContextSpecifier = "active" 8765 if fromID not in active: 8766 context = self.getCommonContext(step) 8767 using = "common" 8768 8769 destDomain = now.graph.domainFor(destID) 8770 if ( 8771 whichFocus is None 8772 and base.getDomainFocalization(context, destDomain) == 'plural' 8773 ): 8774 # Need to figure out which focal point is moving; use the 8775 # alphabetically earliest one that's positioned at the 8776 # fromID, or just the earliest one overall if none of them 8777 # are there. 8778 contextFocalPoints: Dict[ 8779 base.FocalPointName, 8780 Optional[base.DecisionID] 8781 ] = cast( 8782 Dict[base.FocalPointName, Optional[base.DecisionID]], 8783 context['activeDecisions'][destDomain] 8784 ) 8785 if not isinstance(contextFocalPoints, dict): 8786 raise RuntimeError( 8787 f"Active decisions specifier for domain" 8788 f" {repr(destDomain)} with plural focalization has" 8789 f" a non-dictionary value." 8790 ) 8791 8792 if fromDomain == destDomain: 8793 focalCandidates = [ 8794 fp 8795 for fp, pos in contextFocalPoints.items() 8796 if pos == fromID 8797 ] 8798 else: 8799 focalCandidates = list(contextFocalPoints) 8800 8801 whichFocus = (using, destDomain, min(focalCandidates)) 8802 8803 # Now whichFocus has been set if it wasn't already specified; 8804 # might still be None if it's not relevant. 8805 return (using, fromID, destID, whichFocus) 8806 8807 def advanceSituation( 8808 self, 8809 action: base.ExplorationAction, 8810 decisionType: base.DecisionType = "active", 8811 challengePolicy: base.ChallengePolicy = "specified" 8812 ) -> Tuple[base.Situation, Set[base.DecisionID]]: 8813 """ 8814 Given an `ExplorationAction`, sets that as the action taken in 8815 the current situation, and adds a new situation with the results 8816 of that action. A `DoubleActionError` will be raised if the 8817 current situation already has an action specified, and/or has a 8818 decision type other than 'pending'. By default the type of the 8819 decision will be 'active' but another `DecisionType` can be 8820 specified via the `decisionType` parameter. 8821 8822 If the action specified is `('noAction',)`, then the new 8823 situation will be a copy of the old one; this represents waiting 8824 or being at an ending (a decision type other than 'pending' 8825 should be used). 8826 8827 Although `None` can appear as the action entry in situations 8828 with pending decisions, you cannot call `advanceSituation` with 8829 `None` as the action. 8830 8831 If the action includes taking a transition whose requirements 8832 are not satisfied, the transition will still be taken (and any 8833 consequences applied) but a `TransitionBlockedWarning` will be 8834 issued. 8835 8836 A `ChallengePolicy` may be specified, the default is 'specified' 8837 which requires that outcomes are pre-specified. If any other 8838 policy is set, the challenge outcomes will be reset before 8839 re-resolving them according to the provided policy. 8840 8841 The new situation will have decision type 'pending' and `None` 8842 as the action. 8843 8844 The new situation created as a result of the action is returned, 8845 along with the set of destination decision IDs, including 8846 possibly a modified destination via 'bounce', 'goto', and/or 8847 'follow' effects. For actions that don't have a destination, the 8848 second part of the returned tuple will be an empty set. Multiple 8849 IDs may be in the set when using a start action in a plural- or 8850 spreading-focalized domain, for example. 8851 8852 If the action updates active decisions (including via transition 8853 effects) this will also update the exploration status of those 8854 decisions to 'exploring' if they had been in an unvisited 8855 status (see `updatePosition` and `hasBeenVisited`). This 8856 includes decisions traveled through but not ultimately arrived 8857 at via 'follow' effects. 8858 8859 If any decisions are active in the `ENDINGS_DOMAIN`, attempting 8860 to 'warp', 'explore', 'take', or 'start' will raise an 8861 `InvalidActionError`. 8862 """ 8863 now = self.getSituation() 8864 if now.type != 'pending' or now.action is not None: 8865 raise DoubleActionError( 8866 f"Attempted to take action {repr(action)} at step" 8867 f" {len(self) - 1}, but an action and/or decision type" 8868 f" had already been specified:" 8869 f"\nAction: {repr(now.action)}" 8870 f"\nType: {repr(now.type)}" 8871 ) 8872 8873 # Update the now situation to add in the decision type and 8874 # action taken: 8875 revised = base.Situation( 8876 now.graph, 8877 now.state, 8878 decisionType, 8879 action, 8880 now.saves, 8881 now.tags, 8882 now.annotations 8883 ) 8884 self.situations[-1] = revised 8885 8886 # Separate update process when reverting (this branch returns) 8887 if ( 8888 action is not None 8889 and isinstance(action, tuple) 8890 and len(action) == 3 8891 and action[0] == 'revertTo' 8892 and isinstance(action[1], base.SaveSlot) 8893 and isinstance(action[2], set) 8894 and all(isinstance(x, str) for x in action[2]) 8895 ): 8896 _, slot, aspects = action 8897 if slot not in now.saves: 8898 raise KeyError( 8899 f"Cannot load save slot {slot!r} because no save" 8900 f" data has been established for that slot." 8901 ) 8902 load = now.saves[slot] 8903 rGraph, rState = base.revertedState( 8904 (now.graph, now.state), 8905 load, 8906 aspects 8907 ) 8908 reverted = base.Situation( 8909 graph=rGraph, 8910 state=rState, 8911 type='pending', 8912 action=None, 8913 saves=copy.deepcopy(now.saves), 8914 tags={}, 8915 annotations=[] 8916 ) 8917 self.situations.append(reverted) 8918 # Apply any active triggers (edits reverted) 8919 self.applyActiveTriggers() 8920 # Figure out destinations set to return 8921 newDestinations = set() 8922 newPr = rState['primaryDecision'] 8923 if newPr is not None: 8924 newDestinations.add(newPr) 8925 return (reverted, newDestinations) 8926 8927 # TODO: These deep copies are expensive time-wise. Can we avoid 8928 # them? Probably not. 8929 newGraph = copy.deepcopy(now.graph) 8930 newState = copy.deepcopy(now.state) 8931 newSaves = copy.copy(now.saves) # a shallow copy 8932 newTags: Dict[base.Tag, base.TagValue] = {} 8933 newAnnotations: List[base.Annotation] = [] 8934 updated = base.Situation( 8935 graph=newGraph, 8936 state=newState, 8937 type='pending', 8938 action=None, 8939 saves=newSaves, 8940 tags=newTags, 8941 annotations=newAnnotations 8942 ) 8943 8944 targetContext: base.FocalContext 8945 8946 # Now that action effects have been imprinted into the updated 8947 # situation, append it to our situations list 8948 self.situations.append(updated) 8949 8950 # Figure out effects of the action: 8951 if action is None: 8952 raise InvalidActionError( 8953 "None cannot be used as an action when advancing the" 8954 " situation." 8955 ) 8956 8957 aLen = len(action) 8958 8959 destIDs = set() 8960 8961 if ( 8962 action[0] in ('start', 'take', 'explore', 'warp') 8963 and any( 8964 newGraph.domainFor(d) == ENDINGS_DOMAIN 8965 for d in self.getActiveDecisions() 8966 ) 8967 ): 8968 activeEndings = [ 8969 d 8970 for d in self.getActiveDecisions() 8971 if newGraph.domainFor(d) == ENDINGS_DOMAIN 8972 ] 8973 raise InvalidActionError( 8974 f"Attempted to {action[0]!r} while an ending was" 8975 f" active. Active endings are:" 8976 f"\n{newGraph.namesListing(activeEndings)}" 8977 ) 8978 8979 if action == ('noAction',): 8980 # No updates needed 8981 pass 8982 8983 elif ( 8984 not isinstance(action, tuple) 8985 or (action[0] not in get_args(base.ExplorationActionType)) 8986 or not (2 <= aLen <= 7) 8987 ): 8988 raise InvalidActionError( 8989 f"Invalid ExplorationAction tuple (must be a tuple that" 8990 f" starts with an ExplorationActionType and has 2-6" 8991 f" entries if it's not ('noAction',)):" 8992 f"\n{repr(action)}" 8993 ) 8994 8995 elif action[0] == 'start': 8996 ( 8997 _, 8998 positionSpecifier, 8999 primary, 9000 domain, 9001 capabilities, 9002 mechanismStates, 9003 customState 9004 ) = cast( 9005 Tuple[ 9006 Literal['start'], 9007 Union[ 9008 base.DecisionID, 9009 Dict[base.FocalPointName, base.DecisionID], 9010 Set[base.DecisionID] 9011 ], 9012 Optional[base.DecisionID], 9013 base.Domain, 9014 Optional[base.CapabilitySet], 9015 Optional[Dict[base.MechanismID, base.MechanismState]], 9016 Optional[dict] 9017 ], 9018 action 9019 ) 9020 targetContext = newState['contexts'][ 9021 newState['activeContext'] 9022 ] 9023 9024 targetFocalization = base.getDomainFocalization( 9025 targetContext, 9026 domain 9027 ) # sets up 'singular' as default if 9028 9029 # Check if there are any already-active decisions. 9030 if targetContext['activeDecisions'][domain] is not None: 9031 raise BadStart( 9032 f"Cannot start in domain {repr(domain)} because" 9033 f" that domain already has a position. 'start' may" 9034 f" only be used with domains that don't yet have" 9035 f" any position information." 9036 ) 9037 9038 # Make the domain active 9039 if domain not in targetContext['activeDomains']: 9040 targetContext['activeDomains'].add(domain) 9041 9042 # Check position info matches focalization type and update 9043 # exploration statuses 9044 if isinstance(positionSpecifier, base.DecisionID): 9045 if targetFocalization != 'singular': 9046 raise BadStart( 9047 f"Invalid position specifier" 9048 f" {repr(positionSpecifier)} (type" 9049 f" {type(positionSpecifier)}). Domain" 9050 f" {repr(domain)} has {targetFocalization}" 9051 f" focalization." 9052 ) 9053 base.setExplorationStatus( 9054 updated, 9055 positionSpecifier, 9056 'exploring', 9057 upgradeOnly=True 9058 ) 9059 destIDs.add(positionSpecifier) 9060 elif isinstance(positionSpecifier, dict): 9061 if targetFocalization != 'plural': 9062 raise BadStart( 9063 f"Invalid position specifier" 9064 f" {repr(positionSpecifier)} (type" 9065 f" {type(positionSpecifier)}). Domain" 9066 f" {repr(domain)} has {targetFocalization}" 9067 f" focalization." 9068 ) 9069 destIDs |= set(positionSpecifier.values()) 9070 elif isinstance(positionSpecifier, set): 9071 if targetFocalization != 'spreading': 9072 raise BadStart( 9073 f"Invalid position specifier" 9074 f" {repr(positionSpecifier)} (type" 9075 f" {type(positionSpecifier)}). Domain" 9076 f" {repr(domain)} has {targetFocalization}" 9077 f" focalization." 9078 ) 9079 destIDs |= positionSpecifier 9080 else: 9081 raise TypeError( 9082 f"Invalid position specifier" 9083 f" {repr(positionSpecifier)} (type" 9084 f" {type(positionSpecifier)}). It must be a" 9085 f" DecisionID, a dictionary from FocalPointNames to" 9086 f" DecisionIDs, or a set of DecisionIDs, according" 9087 f" to the focalization of the relevant domain." 9088 ) 9089 9090 # Put specified position(s) in place 9091 # TODO: This cast is really silly... 9092 targetContext['activeDecisions'][domain] = cast( 9093 Union[ 9094 None, 9095 base.DecisionID, 9096 Dict[base.FocalPointName, Optional[base.DecisionID]], 9097 Set[base.DecisionID] 9098 ], 9099 positionSpecifier 9100 ) 9101 9102 # Set primary decision 9103 newState['primaryDecision'] = primary 9104 9105 # Set capabilities 9106 if capabilities is not None: 9107 targetContext['capabilities'] = capabilities 9108 9109 # Set mechanism states 9110 if mechanismStates is not None: 9111 newState['mechanisms'] = mechanismStates 9112 9113 # Set custom state 9114 if customState is not None: 9115 newState['custom'] = customState 9116 9117 elif action[0] in ('explore', 'take', 'warp'): # similar handling 9118 assert ( 9119 len(action) == 3 9120 or len(action) == 4 9121 or len(action) == 6 9122 or len(action) == 7 9123 ) 9124 # Set up necessary variables 9125 cSpec: base.ContextSpecifier = "active" 9126 fromID: Optional[base.DecisionID] = None 9127 takeTransition: Optional[base.Transition] = None 9128 outcomes: List[bool] = [] 9129 destID: base.DecisionID # No starting value as it's not optional 9130 moveInDomain: Optional[base.Domain] = None 9131 moveWhich: Optional[base.FocalPointName] = None 9132 9133 # Figure out target context 9134 if isinstance(action[1], str): 9135 if action[1] not in get_args(base.ContextSpecifier): 9136 raise InvalidActionError( 9137 f"Action specifies {repr(action[1])} context," 9138 f" but that's not a valid context specifier." 9139 f" The valid options are:" 9140 f"\n{repr(get_args(base.ContextSpecifier))}" 9141 ) 9142 else: 9143 cSpec = cast(base.ContextSpecifier, action[1]) 9144 else: # Must be a `FocalPointSpecifier` 9145 cSpec, moveInDomain, moveWhich = cast( 9146 base.FocalPointSpecifier, 9147 action[1] 9148 ) 9149 assert moveInDomain is not None 9150 9151 # Grab target context to work in 9152 if cSpec == 'common': 9153 targetContext = newState['common'] 9154 else: 9155 targetContext = newState['contexts'][ 9156 newState['activeContext'] 9157 ] 9158 9159 # Check focalization of the target domain 9160 if moveInDomain is not None: 9161 fType = base.getDomainFocalization( 9162 targetContext, 9163 moveInDomain 9164 ) 9165 if ( 9166 ( 9167 isinstance(action[1], str) 9168 and fType == 'plural' 9169 ) or ( 9170 not isinstance(action[1], str) 9171 and fType != 'plural' 9172 ) 9173 ): 9174 raise ImpossibleActionError( 9175 f"Invalid ExplorationAction (moves in" 9176 f" plural-focalized domains must include a" 9177 f" FocalPointSpecifier, while moves in" 9178 f" non-plural-focalized domains must not." 9179 f" Domain {repr(moveInDomain)} is" 9180 f" {fType}-focalized):" 9181 f"\n{repr(action)}" 9182 ) 9183 9184 if action[0] == "warp": 9185 # It's a warp, so destination is specified directly 9186 if not isinstance(action[2], base.DecisionID): 9187 raise TypeError( 9188 f"Invalid ExplorationAction tuple (third part" 9189 f" must be a decision ID for 'warp' actions):" 9190 f"\n{repr(action)}" 9191 ) 9192 else: 9193 destID = cast(base.DecisionID, action[2]) 9194 9195 elif aLen == 4 or aLen == 7: 9196 # direct 'take' or 'explore' 9197 fromID = cast(base.DecisionID, action[2]) 9198 takeTransition, outcomes = cast( 9199 base.TransitionWithOutcomes, 9200 action[3] # type: ignore [misc] 9201 ) 9202 if ( 9203 not isinstance(fromID, base.DecisionID) 9204 or not isinstance(takeTransition, base.Transition) 9205 ): 9206 raise InvalidActionError( 9207 f"Invalid ExplorationAction tuple (for 'take' or" 9208 f" 'explore', if the length is 4/7, parts 2-4" 9209 f" must be a context specifier, a decision ID, and a" 9210 f" transition name. Got:" 9211 f"\n{repr(action)}" 9212 ) 9213 9214 try: 9215 destID = newGraph.destination(fromID, takeTransition) 9216 except MissingDecisionError: 9217 raise ImpossibleActionError( 9218 f"Invalid ExplorationAction: move from decision" 9219 f" {fromID} is invalid because there is no" 9220 f" decision with that ID in the current" 9221 f" graph." 9222 f"\nValid decisions are:" 9223 f"\n{newGraph.namesListing(newGraph)}" 9224 ) 9225 except MissingTransitionError: 9226 valid = newGraph.destinationsFrom(fromID) 9227 listing = newGraph.destinationsListing(valid) 9228 raise ImpossibleActionError( 9229 f"Invalid ExplorationAction: move from decision" 9230 f" {newGraph.identityOf(fromID)}" 9231 f" along transition {repr(takeTransition)} is" 9232 f" invalid because there is no such transition" 9233 f" at that decision." 9234 f"\nValid transitions there are:" 9235 f"\n{listing}" 9236 ) 9237 targetActive = targetContext['activeDecisions'] 9238 if moveInDomain is not None: 9239 activeInDomain = targetActive[moveInDomain] 9240 if ( 9241 ( 9242 isinstance(activeInDomain, base.DecisionID) 9243 and fromID != activeInDomain 9244 ) 9245 or ( 9246 isinstance(activeInDomain, set) 9247 and fromID not in activeInDomain 9248 ) 9249 or ( 9250 isinstance(activeInDomain, dict) 9251 and fromID not in activeInDomain.values() 9252 ) 9253 ): 9254 raise ImpossibleActionError( 9255 f"Invalid ExplorationAction: move from" 9256 f" decision {fromID} is invalid because" 9257 f" that decision is not active in domain" 9258 f" {repr(moveInDomain)} in the current" 9259 f" graph." 9260 f"\nValid decisions are:" 9261 f"\n{newGraph.namesListing(newGraph)}" 9262 ) 9263 9264 elif aLen == 3 or aLen == 6: 9265 # 'take' or 'explore' focal point 9266 # We know that moveInDomain is not None here. 9267 assert moveInDomain is not None 9268 if not isinstance(action[2], base.Transition): 9269 raise InvalidActionError( 9270 f"Invalid ExplorationAction tuple (for 'take'" 9271 f" actions if the second part is a" 9272 f" FocalPointSpecifier the third part must be a" 9273 f" transition name):" 9274 f"\n{repr(action)}" 9275 ) 9276 9277 takeTransition, outcomes = cast( 9278 base.TransitionWithOutcomes, 9279 action[2] 9280 ) 9281 targetActive = targetContext['activeDecisions'] 9282 activeInDomain = cast( 9283 Dict[base.FocalPointName, Optional[base.DecisionID]], 9284 targetActive[moveInDomain] 9285 ) 9286 if ( 9287 moveInDomain is not None 9288 and ( 9289 not isinstance(activeInDomain, dict) 9290 or moveWhich not in activeInDomain 9291 ) 9292 ): 9293 raise ImpossibleActionError( 9294 f"Invalid ExplorationAction: move of focal" 9295 f" point {repr(moveWhich)} in domain" 9296 f" {repr(moveInDomain)} is invalid because" 9297 f" that domain does not have a focal point" 9298 f" with that name." 9299 ) 9300 fromID = activeInDomain[moveWhich] 9301 if fromID is None: 9302 raise ImpossibleActionError( 9303 f"Invalid ExplorationAction: move of focal" 9304 f" point {repr(moveWhich)} in domain" 9305 f" {repr(moveInDomain)} is invalid because" 9306 f" that focal point does not have a position" 9307 f" at this step." 9308 ) 9309 try: 9310 destID = newGraph.destination(fromID, takeTransition) 9311 except MissingDecisionError: 9312 raise ImpossibleActionError( 9313 f"Invalid exploration state: focal point" 9314 f" {repr(moveWhich)} in domain" 9315 f" {repr(moveInDomain)} specifies decision" 9316 f" {fromID} as the current position, but" 9317 f" that decision does not exist!" 9318 ) 9319 except MissingTransitionError: 9320 valid = newGraph.destinationsFrom(fromID) 9321 listing = newGraph.destinationsListing(valid) 9322 raise ImpossibleActionError( 9323 f"Invalid ExplorationAction: move of focal" 9324 f" point {repr(moveWhich)} in domain" 9325 f" {repr(moveInDomain)} along transition" 9326 f" {repr(takeTransition)} is invalid because" 9327 f" that focal point is at decision" 9328 f" {newGraph.identityOf(fromID)} and that" 9329 f" decision does not have an outgoing" 9330 f" transition with that name.\nValid" 9331 f" transitions from that decision are:" 9332 f"\n{listing}" 9333 ) 9334 9335 else: 9336 raise InvalidActionError( 9337 f"Invalid ExplorationAction: unrecognized" 9338 f" 'explore', 'take' or 'warp' format:" 9339 f"\n{action}" 9340 ) 9341 9342 # If we're exploring, update information for the destination 9343 if action[0] == 'explore': 9344 zone = cast( 9345 Union[base.Zone, None, type[base.DefaultZone]], 9346 action[-1] 9347 ) 9348 recipName = cast(Optional[base.Transition], action[-2]) 9349 destOrName = cast( 9350 Union[base.DecisionName, base.DecisionID, None], 9351 action[-3] 9352 ) 9353 if isinstance(destOrName, base.DecisionID): 9354 destID = destOrName 9355 9356 if fromID is None or takeTransition is None: 9357 raise ImpossibleActionError( 9358 f"Invalid ExplorationAction: exploration" 9359 f" has unclear origin decision or transition." 9360 f" Got:\n{action}" 9361 ) 9362 9363 currentDest = newGraph.destination(fromID, takeTransition) 9364 if not newGraph.isConfirmed(currentDest): 9365 newGraph.replaceUnconfirmed( 9366 fromID, 9367 takeTransition, 9368 destOrName, 9369 recipName, 9370 placeInZone=zone, 9371 forceNew=not isinstance(destOrName, base.DecisionID) 9372 ) 9373 else: 9374 # Otherwise, since the destination already existed 9375 # and was hooked up at the right decision, no graph 9376 # edits need to be made, unless we need to rename 9377 # the reciprocal. 9378 # TODO: Do we care about zones here? 9379 if recipName is not None: 9380 oldReciprocal = newGraph.getReciprocal( 9381 fromID, 9382 takeTransition 9383 ) 9384 if ( 9385 oldReciprocal is not None 9386 and oldReciprocal != recipName 9387 ): 9388 newGraph.addTransition( 9389 destID, 9390 recipName, 9391 fromID, 9392 None 9393 ) 9394 newGraph.setReciprocal( 9395 destID, 9396 recipName, 9397 takeTransition, 9398 setBoth=True 9399 ) 9400 newGraph.mergeTransitions( 9401 destID, 9402 oldReciprocal, 9403 recipName 9404 ) 9405 9406 # If we are moving along a transition, check requirements 9407 # and apply transition effects *before* updating our 9408 # position, and check that they don't cancel the normal 9409 # position update 9410 finalDest = None 9411 if takeTransition is not None: 9412 assert fromID is not None # both or neither 9413 if not self.isTraversable(fromID, takeTransition): 9414 req = now.graph.getTransitionRequirement( 9415 fromID, 9416 takeTransition 9417 ) 9418 # TODO: Alter warning message if transition is 9419 # deactivated vs. requirement not satisfied 9420 warnings.warn( 9421 ( 9422 f"The requirements for transition" 9423 f" {takeTransition!r} from decision" 9424 f" {now.graph.identityOf(fromID)} are" 9425 f" not met at step {len(self) - 1} (or that" 9426 f" transition has been deactivated):\n{req}" 9427 ), 9428 TransitionBlockedWarning 9429 ) 9430 9431 # Apply transition consequences to our new state and 9432 # figure out if we need to skip our normal update or not 9433 finalDest = self.applyTransitionConsequence( 9434 fromID, 9435 (takeTransition, outcomes), 9436 moveWhich, 9437 challengePolicy 9438 ) 9439 9440 # Check moveInDomain 9441 destDomain = newGraph.domainFor(destID) 9442 if moveInDomain is not None and moveInDomain != destDomain: 9443 raise ImpossibleActionError( 9444 f"Invalid ExplorationAction: move specified" 9445 f" domain {repr(moveInDomain)} as the domain of" 9446 f" the focal point to move, but the destination" 9447 f" of the move is {now.graph.identityOf(destID)}" 9448 f" which is in domain {repr(destDomain)}, so focal" 9449 f" point {repr(moveWhich)} cannot be moved there." 9450 ) 9451 9452 # Now that we know where we're going, update position 9453 # information (assuming it wasn't already set): 9454 if finalDest is None: 9455 finalDest = destID 9456 base.updatePosition( 9457 updated, 9458 destID, 9459 cSpec, 9460 moveWhich 9461 ) 9462 9463 destIDs.add(finalDest) 9464 9465 elif action[0] == "focus": 9466 # Figure out target context 9467 action = cast( 9468 Tuple[ 9469 Literal['focus'], 9470 base.ContextSpecifier, 9471 Set[base.Domain], 9472 Set[base.Domain] 9473 ], 9474 action 9475 ) 9476 contextSpecifier: base.ContextSpecifier = action[1] 9477 if contextSpecifier == 'common': 9478 targetContext = newState['common'] 9479 else: 9480 targetContext = newState['contexts'][ 9481 newState['activeContext'] 9482 ] 9483 9484 # Just need to swap out active domains 9485 goingOut, comingIn = cast( 9486 Tuple[Set[base.Domain], Set[base.Domain]], 9487 action[2:] 9488 ) 9489 if ( 9490 not isinstance(goingOut, set) 9491 or not isinstance(comingIn, set) 9492 or not all(isinstance(d, base.Domain) for d in goingOut) 9493 or not all(isinstance(d, base.Domain) for d in comingIn) 9494 ): 9495 raise InvalidActionError( 9496 f"Invalid ExplorationAction tuple (must have 4" 9497 f" parts if the first part is 'focus' and" 9498 f" the third and fourth parts must be sets of" 9499 f" domains):" 9500 f"\n{repr(action)}" 9501 ) 9502 activeSet = targetContext['activeDomains'] 9503 for dom in goingOut: 9504 try: 9505 activeSet.remove(dom) 9506 except KeyError: 9507 warnings.warn( 9508 ( 9509 f"Domain {repr(dom)} was deactivated at" 9510 f" step {len(self)} but it was already" 9511 f" inactive at that point." 9512 ), 9513 InactiveDomainWarning 9514 ) 9515 # TODO: Also warn for doubly-activated domains? 9516 activeSet |= comingIn 9517 9518 # destIDs remains empty in this case 9519 9520 elif action[0] == 'swap': # update which `FocalContext` is active 9521 newContext = cast(base.FocalContextName, action[1]) 9522 if newContext not in newState['contexts']: 9523 raise MissingFocalContextError( 9524 f"'swap' action with target {repr(newContext)} is" 9525 f" invalid because no context with that name" 9526 f" exists." 9527 ) 9528 newState['activeContext'] = newContext 9529 9530 # destIDs remains empty in this case 9531 9532 elif action[0] == 'focalize': # create new `FocalContext` 9533 newContext = cast(base.FocalContextName, action[1]) 9534 if newContext in newState['contexts']: 9535 raise FocalContextCollisionError( 9536 f"'focalize' action with target {repr(newContext)}" 9537 f" is invalid because a context with that name" 9538 f" already exists." 9539 ) 9540 newState['contexts'][newContext] = base.emptyFocalContext() 9541 newState['activeContext'] = newContext 9542 9543 # destIDs remains empty in this case 9544 9545 # revertTo is handled above 9546 else: 9547 raise InvalidActionError( 9548 f"Invalid ExplorationAction tuple (first item must be" 9549 f" an ExplorationActionType, and tuple must be length-1" 9550 f" if the action type is 'noAction'):" 9551 f"\n{repr(action)}" 9552 ) 9553 9554 # Apply any active triggers 9555 followTo = self.applyActiveTriggers() 9556 if followTo is not None: 9557 destIDs.add(followTo) 9558 # TODO: Re-work to work with multiple position updates in 9559 # different focal contexts, domains, and/or for different 9560 # focal points in plural-focalized domains. 9561 9562 return (updated, destIDs) 9563 9564 def applyActiveTriggers(self) -> Optional[base.DecisionID]: 9565 """ 9566 Finds all actions with the 'trigger' tag attached to currently 9567 active decisions, and applies their effects if their requirements 9568 are met (ordered by decision-ID with ties broken alphabetically 9569 by action name). 9570 9571 'bounce', 'goto' and 'follow' effects may apply. However, any 9572 new triggers that would be activated because of decisions 9573 reached by such effects will not apply. Note that 'bounce' 9574 effects update position to the decision where the action was 9575 attached, which is usually a no-op. This function returns the 9576 decision ID of the decision reached by the last decision-moving 9577 effect applied, or `None` if no such effects triggered. 9578 9579 TODO: What about situations where positions are updated in 9580 multiple domains or multiple foal points in a plural domain are 9581 independently updated? 9582 9583 TODO: Tests for this! 9584 """ 9585 active = self.getActiveDecisions() 9586 now = self.getSituation() 9587 graph = now.graph 9588 finalFollow = None 9589 for decision in sorted(active): 9590 for action in graph.decisionActions(decision): 9591 if ( 9592 'trigger' in graph.transitionTags(decision, action) 9593 and self.isTraversable(decision, action) 9594 ): 9595 followTo = self.applyTransitionConsequence( 9596 decision, 9597 action 9598 ) 9599 if followTo is not None: 9600 # TODO: How will triggers interact with 9601 # plural-focalized domains? Probably need to fix 9602 # this to detect moveWhich based on which focal 9603 # points are at the decision where the transition 9604 # is, and then apply this to each of them? 9605 base.updatePosition(now, followTo) 9606 finalFollow = followTo 9607 9608 return finalFollow 9609 9610 def explore( 9611 self, 9612 transition: base.AnyTransition, 9613 destination: Union[base.DecisionName, base.DecisionID, None], 9614 reciprocal: Optional[base.Transition] = None, 9615 zone: Union[ 9616 base.Zone, 9617 type[base.DefaultZone], 9618 None 9619 ] = base.DefaultZone, 9620 fromDecision: Optional[base.AnyDecisionSpecifier] = None, 9621 whichFocus: Optional[base.FocalPointSpecifier] = None, 9622 inCommon: Union[bool, Literal["auto"]] = "auto", 9623 decisionType: base.DecisionType = "active", 9624 challengePolicy: base.ChallengePolicy = "specified" 9625 ) -> base.DecisionID: 9626 """ 9627 Adds a new situation to the exploration representing the 9628 traversal of the specified transition (possibly with outcomes 9629 specified for challenges among that transitions consequences). 9630 Uses `deduceTransitionDetailsAtStep` to figure out from the 9631 transition name which specific transition is taken (and which 9632 focal point is updated if necessary). This uses the 9633 `fromDecision`, `whichFocus`, and `inCommon` optional 9634 parameters, and also determines whether to update the common or 9635 the active `FocalContext`. Sets the exploration status of the 9636 decision explored to 'exploring'. Returns the decision ID for 9637 the destination reached, accounting for goto/bounce/follow 9638 effects that might have triggered. 9639 9640 The `destination` will be used to name the newly-explored 9641 decision, except when it's a `DecisionID`, in which case that 9642 decision must be unvisited, and we'll connect the specified 9643 transition to that decision. 9644 9645 The focalization of the destination domain in the context to be 9646 updated determines how active decisions are changed: 9647 9648 - If the destination domain is focalized as 'single', then in 9649 the subsequent `Situation`, the destination decision will 9650 become the single active decision in that domain. 9651 - If it's focalized as 'plural', then one of the 9652 `FocalPointName`s for that domain will be moved to activate 9653 that decision; which one can be specified using `whichFocus` 9654 or if left unspecified, will be deduced: if the starting 9655 decision is in the same domain, then the 9656 alphabetically-earliest focal point which is at the starting 9657 decision will be moved. If the starting position is in a 9658 different domain, then the alphabetically earliest focal 9659 point among all focal points in the destination domain will 9660 be moved. 9661 - If it's focalized as 'spreading', then the destination 9662 decision will be added to the set of active decisions in 9663 that domain, without removing any. 9664 9665 The transition named must have been pointing to an unvisited 9666 decision (see `hasBeenVisited`), and the name of that decision 9667 will be updated if a `destination` value is given (a 9668 `DecisionCollisionWarning` will be issued if the destination 9669 name is a duplicate of another name in the graph, although this 9670 is not an error). Additionally: 9671 9672 - If a `reciprocal` name is specified, the reciprocal transition 9673 will be renamed using that name, or created with that name if 9674 it didn't already exist. If reciprocal is left as `None` (the 9675 default) then no change will be made to the reciprocal 9676 transition, and it will not be created if it doesn't exist. 9677 - If a `zone` is specified, the newly-explored decision will be 9678 added to that zone (and that zone will be created at level 0 9679 if it didn't already exist). If `zone` is set to `None` then 9680 it will not be added to any new zones. If `zone` is left as 9681 the default (the `DefaultZone` class) then the explored 9682 decision will be added to each zone that the decision it was 9683 explored from is a part of. If a zone needs to be created, 9684 that zone will be added as a sub-zone of each zone which is a 9685 parent of a zone that directly contains the origin decision. 9686 - An `ExplorationStatusError` will be raised if the specified 9687 transition leads to a decision whose `ExplorationStatus` is 9688 'exploring' or higher (i.e., `hasBeenVisited`). (Use 9689 `returnTo` instead to adjust things when a transition to an 9690 unknown destination turns out to lead to an already-known 9691 destination.) 9692 - A `TransitionBlockedWarning` will be issued if the specified 9693 transition is not traversable given the current game state 9694 (but in that last case the step will still be taken). 9695 - By default, the decision type for the new step will be 9696 'active', but a `decisionType` value can be specified to 9697 override that. 9698 - By default, the 'mostLikely' `ChallengePolicy` will be used to 9699 resolve challenges in the consequence of the transition 9700 taken, but an alternate policy can be supplied using the 9701 `challengePolicy` argument. 9702 """ 9703 now = self.getSituation() 9704 9705 transitionName, outcomes = base.nameAndOutcomes(transition) 9706 9707 # Deduce transition details from the name + optional specifiers 9708 ( 9709 using, 9710 fromID, 9711 destID, 9712 whichFocus 9713 ) = self.deduceTransitionDetailsAtStep( 9714 -1, 9715 transitionName, 9716 fromDecision, 9717 whichFocus, 9718 inCommon 9719 ) 9720 9721 # Issue a warning if the destination name is already in use 9722 if destination is not None: 9723 if isinstance(destination, base.DecisionName): 9724 try: 9725 existingID = now.graph.resolveDecision(destination) 9726 collision = existingID != destID 9727 except MissingDecisionError: 9728 collision = False 9729 except AmbiguousDecisionSpecifierError: 9730 collision = True 9731 9732 if collision and WARN_OF_NAME_COLLISIONS: 9733 warnings.warn( 9734 ( 9735 f"The destination name {repr(destination)} is" 9736 f" already in use when exploring transition" 9737 f" {repr(transition)} from decision" 9738 f" {now.graph.identityOf(fromID)} at step" 9739 f" {len(self) - 1}." 9740 ), 9741 DecisionCollisionWarning 9742 ) 9743 9744 # TODO: Different terminology for "exploration state above 9745 # noticed" vs. "DG thinks it's been visited"... 9746 if ( 9747 self.hasBeenVisited(destID) 9748 ): 9749 raise ExplorationStatusError( 9750 f"Cannot explore to decision" 9751 f" {now.graph.identityOf(destID)} because it has" 9752 f" already been visited. Use returnTo instead of" 9753 f" explore when discovering a connection back to a" 9754 f" previously-explored decision." 9755 ) 9756 9757 if ( 9758 isinstance(destination, base.DecisionID) 9759 and self.hasBeenVisited(destination) 9760 ): 9761 raise ExplorationStatusError( 9762 f"Cannot explore to decision" 9763 f" {now.graph.identityOf(destination)} because it has" 9764 f" already been visited. Use returnTo instead of" 9765 f" explore when discovering a connection back to a" 9766 f" previously-explored decision." 9767 ) 9768 9769 actionTaken: base.ExplorationAction = ( 9770 'explore', 9771 using, 9772 fromID, 9773 (transitionName, outcomes), 9774 destination, 9775 reciprocal, 9776 zone 9777 ) 9778 if whichFocus is not None: 9779 # A move-from-specific-focal-point action 9780 actionTaken = ( 9781 'explore', 9782 whichFocus, 9783 (transitionName, outcomes), 9784 destination, 9785 reciprocal, 9786 zone 9787 ) 9788 9789 # Advance the situation, applying transition effects and 9790 # updating the destination decision. 9791 _, finalDest = self.advanceSituation( 9792 actionTaken, 9793 decisionType, 9794 challengePolicy 9795 ) 9796 9797 # TODO: Is this assertion always valid? 9798 assert len(finalDest) == 1 9799 return next(x for x in finalDest) 9800 9801 def returnTo( 9802 self, 9803 transition: base.AnyTransition, 9804 destination: base.AnyDecisionSpecifier, 9805 reciprocal: Optional[base.Transition] = None, 9806 fromDecision: Optional[base.AnyDecisionSpecifier] = None, 9807 whichFocus: Optional[base.FocalPointSpecifier] = None, 9808 inCommon: Union[bool, Literal["auto"]] = "auto", 9809 decisionType: base.DecisionType = "active", 9810 challengePolicy: base.ChallengePolicy = "specified" 9811 ) -> base.DecisionID: 9812 """ 9813 Adds a new graph to the exploration that replaces the given 9814 transition at the current position (which must lead to an unknown 9815 node, or a `MissingDecisionError` will result). The new 9816 transition will connect back to the specified destination, which 9817 must already exist (or a different `ValueError` will be raised). 9818 Returns the decision ID for the destination reached. 9819 9820 Deduces transition details using the optional `fromDecision`, 9821 `whichFocus`, and `inCommon` arguments in addition to the 9822 `transition` value; see `deduceTransitionDetailsAtStep`. 9823 9824 If a `reciprocal` transition is specified, that transition must 9825 either not already exist in the destination decision or lead to 9826 an unknown region; it will be replaced (or added) as an edge 9827 leading back to the current position. 9828 9829 The `decisionType` and `challengePolicy` optional arguments are 9830 used for `advanceSituation`. 9831 9832 A `TransitionBlockedWarning` will be issued if the requirements 9833 for the transition are not met, but the step will still be taken. 9834 Raises a `MissingDecisionError` if there is no current 9835 transition. 9836 """ 9837 now = self.getSituation() 9838 9839 transitionName, outcomes = base.nameAndOutcomes(transition) 9840 9841 # Deduce transition details from the name + optional specifiers 9842 ( 9843 using, 9844 fromID, 9845 destID, 9846 whichFocus 9847 ) = self.deduceTransitionDetailsAtStep( 9848 -1, 9849 transitionName, 9850 fromDecision, 9851 whichFocus, 9852 inCommon 9853 ) 9854 9855 # Replace with connection to existing destination 9856 destID = now.graph.resolveDecision(destination) 9857 if not self.hasBeenVisited(destID): 9858 raise ExplorationStatusError( 9859 f"Cannot return to decision" 9860 f" {now.graph.identityOf(destID)} because it has NOT" 9861 f" already been at least partially explored. Use" 9862 f" explore instead of returnTo when discovering a" 9863 f" connection to a previously-unexplored decision." 9864 ) 9865 9866 now.graph.replaceUnconfirmed( 9867 fromID, 9868 transitionName, 9869 destID, 9870 reciprocal 9871 ) 9872 9873 # A move-from-decision action 9874 actionTaken: base.ExplorationAction = ( 9875 'take', 9876 using, 9877 fromID, 9878 (transitionName, outcomes) 9879 ) 9880 if whichFocus is not None: 9881 # A move-from-specific-focal-point action 9882 actionTaken = ('take', whichFocus, (transitionName, outcomes)) 9883 9884 # Next, advance the situation, applying transition effects 9885 _, finalDest = self.advanceSituation( 9886 actionTaken, 9887 decisionType, 9888 challengePolicy 9889 ) 9890 9891 assert len(finalDest) == 1 9892 return next(x for x in finalDest) 9893 9894 def takeAction( 9895 self, 9896 action: base.AnyTransition, 9897 requires: Optional[base.Requirement] = None, 9898 consequence: Optional[base.Consequence] = None, 9899 fromDecision: Optional[base.AnyDecisionSpecifier] = None, 9900 whichFocus: Optional[base.FocalPointSpecifier] = None, 9901 inCommon: Union[bool, Literal["auto"]] = "auto", 9902 decisionType: base.DecisionType = "active", 9903 challengePolicy: base.ChallengePolicy = "specified" 9904 ) -> base.DecisionID: 9905 """ 9906 Adds a new graph to the exploration based on taking the given 9907 action, which must be a self-transition in the graph. If the 9908 action does not already exist in the graph, it will be created. 9909 Either way if requirements and/or a consequence are supplied, 9910 the requirements and consequence of the action will be updated 9911 to match them, and those are the requirements/consequence that 9912 will count. 9913 9914 Returns the decision ID for the decision reached, which normally 9915 is the same action you were just at, but which might be altered 9916 by goto, bounce, and/or follow effects. 9917 9918 Issues a `TransitionBlockedWarning` if the current game state 9919 doesn't satisfy the requirements for the action. 9920 9921 The `fromDecision`, `whichFocus`, and `inCommon` arguments are 9922 used for `deduceTransitionDetailsAtStep`, while `decisionType` 9923 and `challengePolicy` are used for `advanceSituation`. 9924 9925 When an action is being created, `fromDecision` (or 9926 `whichFocus`) must be specified, since the source decision won't 9927 be deducible from the transition name. Note that if a transition 9928 with the given name exists from *any* active decision, it will 9929 be used instead of creating a new action (possibly resulting in 9930 an error if it's not a self-loop transition). Also, you may get 9931 an `AmbiguousTransitionError` if several transitions with that 9932 name exist; in that case use `fromDecision` and/or `whichFocus` 9933 to disambiguate. 9934 """ 9935 now = self.getSituation() 9936 graph = now.graph 9937 9938 actionName, outcomes = base.nameAndOutcomes(action) 9939 9940 try: 9941 ( 9942 using, 9943 fromID, 9944 destID, 9945 whichFocus 9946 ) = self.deduceTransitionDetailsAtStep( 9947 -1, 9948 actionName, 9949 fromDecision, 9950 whichFocus, 9951 inCommon 9952 ) 9953 9954 if destID != fromID: 9955 raise ValueError( 9956 f"Cannot take action {repr(action)} because it's a" 9957 f" transition to another decision, not an action" 9958 f" (use explore, returnTo, and/or retrace instead)." 9959 ) 9960 9961 except MissingTransitionError: 9962 using = 'active' 9963 if inCommon is True: 9964 using = 'common' 9965 9966 if fromDecision is not None: 9967 fromID = graph.resolveDecision(fromDecision) 9968 elif whichFocus is not None: 9969 maybeFromID = base.resolvePosition(now, whichFocus) 9970 if maybeFromID is None: 9971 raise MissingDecisionError( 9972 f"Focal point {repr(whichFocus)} was specified" 9973 f" in takeAction but that focal point doesn't" 9974 f" have a position." 9975 ) 9976 else: 9977 fromID = maybeFromID 9978 else: 9979 raise AmbiguousTransitionError( 9980 f"Taking action {repr(action)} is ambiguous because" 9981 f" the source decision has not been specified via" 9982 f" either fromDecision or whichFocus, and we" 9983 f" couldn't find an existing action with that name." 9984 ) 9985 9986 # Since the action doesn't exist, add it: 9987 graph.addAction(fromID, actionName, requires, consequence) 9988 9989 # Update the transition requirement/consequence if requested 9990 # (before the action is taken) 9991 if requires is not None: 9992 graph.setTransitionRequirement(fromID, actionName, requires) 9993 if consequence is not None: 9994 graph.setConsequence(fromID, actionName, consequence) 9995 9996 # A move-from-decision action 9997 actionTaken: base.ExplorationAction = ( 9998 'take', 9999 using, 10000 fromID, 10001 (actionName, outcomes) 10002 ) 10003 if whichFocus is not None: 10004 # A move-from-specific-focal-point action 10005 actionTaken = ('take', whichFocus, (actionName, outcomes)) 10006 10007 _, finalDest = self.advanceSituation( 10008 actionTaken, 10009 decisionType, 10010 challengePolicy 10011 ) 10012 10013 assert len(finalDest) in (0, 1) 10014 if len(finalDest) == 1: 10015 return next(x for x in finalDest) 10016 else: 10017 return fromID 10018 10019 def retrace( 10020 self, 10021 transition: base.AnyTransition, 10022 fromDecision: Optional[base.AnyDecisionSpecifier] = None, 10023 whichFocus: Optional[base.FocalPointSpecifier] = None, 10024 inCommon: Union[bool, Literal["auto"]] = "auto", 10025 decisionType: base.DecisionType = "active", 10026 challengePolicy: base.ChallengePolicy = "specified" 10027 ) -> base.DecisionID: 10028 """ 10029 Adds a new graph to the exploration based on taking the given 10030 transition, which must already exist and which must not lead to 10031 an unknown region. Returns the ID of the destination decision, 10032 accounting for goto, bounce, and/or follow effects. 10033 10034 Issues a `TransitionBlockedWarning` if the current game state 10035 doesn't satisfy the requirements for the transition. 10036 10037 The `fromDecision`, `whichFocus`, and `inCommon` arguments are 10038 used for `deduceTransitionDetailsAtStep`, while `decisionType` 10039 and `challengePolicy` are used for `advanceSituation`. 10040 """ 10041 now = self.getSituation() 10042 10043 transitionName, outcomes = base.nameAndOutcomes(transition) 10044 10045 ( 10046 using, 10047 fromID, 10048 destID, 10049 whichFocus 10050 ) = self.deduceTransitionDetailsAtStep( 10051 -1, 10052 transitionName, 10053 fromDecision, 10054 whichFocus, 10055 inCommon 10056 ) 10057 10058 visited = self.hasBeenVisited(destID) 10059 confirmed = now.graph.isConfirmed(destID) 10060 if not confirmed: 10061 raise ExplorationStatusError( 10062 f"Cannot retrace transition {transition!r} from" 10063 f" decision {now.graph.identityOf(fromID)} because it" 10064 f" leads to an unconfirmed decision.\nUse" 10065 f" `DiscreteExploration.explore` and provide" 10066 f" destination decision details instead." 10067 ) 10068 if not visited: 10069 raise ExplorationStatusError( 10070 f"Cannot retrace transition {transition!r} from" 10071 f" decision {now.graph.identityOf(fromID)} because it" 10072 f" leads to an unvisited decision.\nUse" 10073 f" `DiscreteExploration.explore` and provide" 10074 f" destination decision details instead." 10075 ) 10076 10077 # A move-from-decision action 10078 actionTaken: base.ExplorationAction = ( 10079 'take', 10080 using, 10081 fromID, 10082 (transitionName, outcomes) 10083 ) 10084 if whichFocus is not None: 10085 # A move-from-specific-focal-point action 10086 actionTaken = ('take', whichFocus, (transitionName, outcomes)) 10087 10088 _, finalDest = self.advanceSituation( 10089 actionTaken, 10090 decisionType, 10091 challengePolicy 10092 ) 10093 10094 assert len(finalDest) == 1 10095 return next(x for x in finalDest) 10096 10097 def warp( 10098 self, 10099 destination: base.AnyDecisionSpecifier, 10100 consequence: Optional[base.Consequence] = None, 10101 domain: Optional[base.Domain] = None, 10102 zone: Union[ 10103 base.Zone, 10104 type[base.DefaultZone], 10105 None 10106 ] = base.DefaultZone, 10107 whichFocus: Optional[base.FocalPointSpecifier] = None, 10108 inCommon: Union[bool] = False, 10109 decisionType: base.DecisionType = "active", 10110 challengePolicy: base.ChallengePolicy = "specified" 10111 ) -> base.DecisionID: 10112 """ 10113 Adds a new graph to the exploration that's a copy of the current 10114 graph, with the position updated to be at the destination without 10115 actually creating a transition from the old position to the new 10116 one. Returns the ID of the decision warped to (accounting for 10117 any goto or follow effects triggered). 10118 10119 Any provided consequences are applied, but are not associated 10120 with any transition (so any delays and charges are ignored, and 10121 'bounce' effects don't actually cancel the warp). 'goto' or 10122 'follow' effects might change the warp destination; 'follow' 10123 effects take the original destination as their starting point. 10124 Any mechanisms mentioned in extra consequences will be found 10125 based on the destination. Outcomes in supplied challenges should 10126 be pre-specified, or else they will be resolved with the 10127 `challengePolicy`. 10128 10129 `whichFocus` may be specified when the destination domain's 10130 focalization is 'plural' but for 'singular' or 'spreading' 10131 destination domains it is not allowed. `inCommon` determines 10132 whether the common or the active focal context is updated 10133 (default is to update the active context). The `decisionType` 10134 and `challengePolicy` are used for `advanceSituation`. 10135 10136 - If the destination did not already exist, it will be created. 10137 Initially, it will be disconnected from all other decisions. 10138 In this case, the `domain` value can be used to put it in a 10139 non-default domain. 10140 - The position is set to the specified destination, and if a 10141 `consequence` is specified it is applied. Note that 10142 'deactivate' effects are NOT allowed, and 'edit' effects 10143 must establish their own transition target because there is 10144 no transition that the effects are being applied to. 10145 - If the destination had been unexplored, its exploration status 10146 will be set to 'exploring'. 10147 - If a `zone` is specified, the destination will be added to that 10148 zone (even if the destination already existed) and that zone 10149 will be created (as a level-0 zone) if need be. If `zone` is 10150 set to `None`, then no zone will be applied. If `zone` is 10151 left as the default (`DefaultZone`) and the focalization of 10152 the destination domain is 'singular' or 'plural' and the 10153 destination is newly created and there is an origin and the 10154 origin is in the same domain as the destination, then the 10155 destination will be added to all zones that the origin was a 10156 part of if the destination is newly created, but otherwise 10157 the destination will not be added to any zones. If the 10158 specified zone has to be created and there's an origin 10159 decision, it will be added as a sub-zone to all parents of 10160 zones directly containing the origin, as long as the origin 10161 is in the same domain as the destination. 10162 """ 10163 now = self.getSituation() 10164 graph = now.graph 10165 10166 fromID: Optional[base.DecisionID] 10167 10168 new = False 10169 try: 10170 destID = graph.resolveDecision(destination) 10171 except MissingDecisionError: 10172 if isinstance(destination, tuple): 10173 # just the name; ignore zone/domain 10174 destination = destination[-1] 10175 10176 if not isinstance(destination, base.DecisionName): 10177 raise TypeError( 10178 f"Warp destination {repr(destination)} does not" 10179 f" exist, and cannot be created as it is not a" 10180 f" decision name." 10181 ) 10182 destID = graph.addDecision(destination, domain) 10183 graph.tagDecision(destID, 'unconfirmed') 10184 self.setExplorationStatus(destID, 'unknown') 10185 new = True 10186 10187 using: base.ContextSpecifier 10188 if inCommon: 10189 targetContext = self.getCommonContext() 10190 using = "common" 10191 else: 10192 targetContext = self.getActiveContext() 10193 using = "active" 10194 10195 destDomain = graph.domainFor(destID) 10196 targetFocalization = base.getDomainFocalization( 10197 targetContext, 10198 destDomain 10199 ) 10200 if targetFocalization == 'singular': 10201 targetActive = targetContext['activeDecisions'] 10202 if destDomain in targetActive: 10203 fromID = cast( 10204 base.DecisionID, 10205 targetContext['activeDecisions'][destDomain] 10206 ) 10207 else: 10208 fromID = None 10209 elif targetFocalization == 'plural': 10210 if whichFocus is None: 10211 raise AmbiguousTransitionError( 10212 f"Warping to {repr(destination)} is ambiguous" 10213 f" becuase domain {repr(destDomain)} has plural" 10214 f" focalization, and no whichFocus value was" 10215 f" specified." 10216 ) 10217 10218 fromID = base.resolvePosition( 10219 self.getSituation(), 10220 whichFocus 10221 ) 10222 else: 10223 fromID = None 10224 10225 # Handle zones 10226 if zone is base.DefaultZone: 10227 if ( 10228 new 10229 and fromID is not None 10230 and graph.domainFor(fromID) == destDomain 10231 ): 10232 for prevZone in graph.zoneParents(fromID): 10233 graph.addDecisionToZone(destination, prevZone) 10234 # Otherwise don't update zones 10235 elif zone is not None: 10236 # Newness is ignored when a zone is specified 10237 zone = cast(base.Zone, zone) 10238 # Create the zone at level 0 if it didn't already exist 10239 if graph.getZoneInfo(zone) is None: 10240 graph.createZone(zone, 0) 10241 # Add the newly created zone to each 2nd-level parent of 10242 # the previous decision if there is one and it's in the 10243 # same domain 10244 if ( 10245 fromID is not None 10246 and graph.domainFor(fromID) == destDomain 10247 ): 10248 for prevZone in graph.zoneParents(fromID): 10249 for prevUpper in graph.zoneParents(prevZone): 10250 graph.addZoneToZone(zone, prevUpper) 10251 # Finally add the destination to the (maybe new) zone 10252 graph.addDecisionToZone(destID, zone) 10253 # else don't touch zones 10254 10255 # Encode the action taken 10256 actionTaken: base.ExplorationAction 10257 if whichFocus is None: 10258 actionTaken = ( 10259 'warp', 10260 using, 10261 destID 10262 ) 10263 else: 10264 actionTaken = ( 10265 'warp', 10266 whichFocus, 10267 destID 10268 ) 10269 10270 # Advance the situation 10271 _, finalDests = self.advanceSituation( 10272 actionTaken, 10273 decisionType, 10274 challengePolicy 10275 ) 10276 now = self.getSituation() # updating just in case 10277 10278 assert len(finalDests) == 1 10279 finalDest = next(x for x in finalDests) 10280 10281 # Apply additional consequences: 10282 if consequence is not None: 10283 altDest = self.applyExtraneousConsequence( 10284 consequence, 10285 where=(destID, None), 10286 # TODO: Mechanism search from both ends? 10287 moveWhich=( 10288 whichFocus[-1] 10289 if whichFocus is not None 10290 else None 10291 ) 10292 ) 10293 if altDest is not None: 10294 finalDest = altDest 10295 now = self.getSituation() # updating just in case 10296 10297 return finalDest 10298 10299 def wait( 10300 self, 10301 consequence: Optional[base.Consequence] = None, 10302 decisionType: base.DecisionType = "active", 10303 challengePolicy: base.ChallengePolicy = "specified" 10304 ) -> Optional[base.DecisionID]: 10305 """ 10306 Adds a wait step. If a consequence is specified, it is applied, 10307 although it will not have any position/transition information 10308 available during resolution/application. 10309 10310 A decision type other than "active" and/or a challenge policy 10311 other than "specified" can be included (see `advanceSituation`). 10312 10313 The "pending" decision type may not be used, a `ValueError` will 10314 result. This allows None as the action for waiting while 10315 preserving the pending/None type/action combination for 10316 unresolved situations. 10317 10318 If a goto or follow effect in the applied consequence implies a 10319 position update, this will return the new destination ID; 10320 otherwise it will return `None`. Triggering a 'bounce' effect 10321 will be an error, because there is no position information for 10322 the effect. 10323 """ 10324 if decisionType == "pending": 10325 raise ValueError( 10326 "The 'pending' decision type may not be used for" 10327 " wait actions." 10328 ) 10329 self.advanceSituation(('noAction',), decisionType, challengePolicy) 10330 now = self.getSituation() 10331 if consequence is not None: 10332 if challengePolicy != "specified": 10333 base.resetChallengeOutcomes(consequence) 10334 observed = base.observeChallengeOutcomes( 10335 base.RequirementContext( 10336 state=now.state, 10337 graph=now.graph, 10338 searchFrom=set() 10339 ), 10340 consequence, 10341 location=None, # No position info 10342 policy=challengePolicy, 10343 knownOutcomes=None # bake outcomes into the consequence 10344 ) 10345 # No location information since we might have multiple 10346 # active decisions and there's no indication of which one 10347 # we're "waiting at." 10348 finalDest = self.applyExtraneousConsequence(observed) 10349 now = self.getSituation() # updating just in case 10350 10351 return finalDest 10352 else: 10353 return None 10354 10355 def revert( 10356 self, 10357 slot: base.SaveSlot = base.DEFAULT_SAVE_SLOT, 10358 aspects: Optional[Set[str]] = None, 10359 decisionType: base.DecisionType = "active" 10360 ) -> None: 10361 """ 10362 Reverts the game state to a previously-saved game state (saved 10363 via a 'save' effect). The save slot name and set of aspects to 10364 revert are required. By default, all aspects except the graph 10365 are reverted. 10366 """ 10367 if aspects is None: 10368 aspects = set() 10369 10370 action: base.ExplorationAction = ("revertTo", slot, aspects) 10371 10372 self.advanceSituation(action, decisionType) 10373 10374 def observeAll( 10375 self, 10376 where: base.AnyDecisionSpecifier, 10377 *transitions: Union[ 10378 base.Transition, 10379 Tuple[base.Transition, base.AnyDecisionSpecifier], 10380 Tuple[ 10381 base.Transition, 10382 base.AnyDecisionSpecifier, 10383 base.Transition 10384 ] 10385 ] 10386 ) -> List[base.DecisionID]: 10387 """ 10388 Observes one or more new transitions, applying changes to the 10389 current graph. The transitions can be specified in one of three 10390 ways: 10391 10392 1. A transition name. The transition will be created and will 10393 point to a new unexplored node. 10394 2. A pair containing a transition name and a destination 10395 specifier. If the destination does not exist it will be 10396 created as an unexplored node, although in that case the 10397 decision specifier may not be an ID. 10398 3. A triple containing a transition name, a destination 10399 specifier, and a reciprocal name. Works the same as the pair 10400 case but also specifies the name for the reciprocal 10401 transition. 10402 10403 The new transitions are outgoing from specified decision. 10404 10405 Yields the ID of each decision connected to, whether those are 10406 new or existing decisions. 10407 """ 10408 now = self.getSituation() 10409 fromID = now.graph.resolveDecision(where) 10410 result = [] 10411 for entry in transitions: 10412 if isinstance(entry, base.Transition): 10413 result.append(self.observe(fromID, entry)) 10414 else: 10415 result.append(self.observe(fromID, *entry)) 10416 return result 10417 10418 def observe( 10419 self, 10420 where: base.AnyDecisionSpecifier, 10421 transition: base.Transition, 10422 destination: Optional[base.AnyDecisionSpecifier] = None, 10423 reciprocal: Optional[base.Transition] = None 10424 ) -> base.DecisionID: 10425 """ 10426 Observes a single new outgoing transition from the specified 10427 decision. If specified the transition connects to a specific 10428 destination and/or has a specific reciprocal. The specified 10429 destination will be created if it doesn't exist, or where no 10430 destination is specified, a new unexplored decision will be 10431 added. The ID of the decision connected to is returned. 10432 10433 Sets the exploration status of the observed destination to 10434 "noticed" if a destination is specified and needs to be created 10435 (but not when no destination is specified). 10436 10437 For example: 10438 10439 >>> e = DiscreteExploration() 10440 >>> e.start('start') 10441 0 10442 >>> e.observe('start', 'up') 10443 1 10444 >>> g = e.getSituation().graph 10445 >>> g.destinationsFrom('start') 10446 {'up': 1} 10447 >>> e.getExplorationStatus(1) # not given a name: assumed unknown 10448 'unknown' 10449 >>> e.observe('start', 'left', 'A') 10450 2 10451 >>> g.destinationsFrom('start') 10452 {'up': 1, 'left': 2} 10453 >>> g.nameFor(2) 10454 'A' 10455 >>> e.getExplorationStatus(2) # given a name: noticed 10456 'noticed' 10457 >>> e.observe('start', 'up2', 1) 10458 1 10459 >>> g.destinationsFrom('start') 10460 {'up': 1, 'left': 2, 'up2': 1} 10461 >>> e.getExplorationStatus(1) # existing decision: status unchanged 10462 'unknown' 10463 >>> e.observe('start', 'right', 'B', 'left') 10464 3 10465 >>> g.destinationsFrom('start') 10466 {'up': 1, 'left': 2, 'up2': 1, 'right': 3} 10467 >>> g.nameFor(3) 10468 'B' 10469 >>> e.getExplorationStatus(3) # new + name -> noticed 10470 'noticed' 10471 >>> e.observe('start', 'right') # repeat transition name 10472 Traceback (most recent call last): 10473 ... 10474 exploration.core.TransitionCollisionError... 10475 >>> e.observe('start', 'right2', 'B', 'left') # repeat reciprocal 10476 Traceback (most recent call last): 10477 ... 10478 exploration.core.TransitionCollisionError... 10479 >>> g = e.getSituation().graph 10480 >>> g.createZone('Z', 0) 10481 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 10482 annotations=[]) 10483 >>> g.addDecisionToZone('start', 'Z') 10484 >>> e.observe('start', 'down', 'C', 'up') 10485 4 10486 >>> g.destinationsFrom('start') 10487 {'up': 1, 'left': 2, 'up2': 1, 'right': 3, 'down': 4} 10488 >>> g.identityOf('C') 10489 '4 (C)' 10490 >>> g.zoneParents(4) # not in any zones, 'cause still unexplored 10491 set() 10492 >>> e.observe( 10493 ... 'C', 10494 ... 'right', 10495 ... base.DecisionSpecifier('main', 'Z2', 'D'), 10496 ... ) # creates zone 10497 5 10498 >>> g.destinationsFrom('C') 10499 {'up': 0, 'right': 5} 10500 >>> g.destinationsFrom('D') # default reciprocal name 10501 {'return': 4} 10502 >>> g.identityOf('D') 10503 '5 (Z2::D)' 10504 >>> g.zoneParents(5) 10505 {'Z2'} 10506 """ 10507 now = self.getSituation() 10508 fromID = now.graph.resolveDecision(where) 10509 10510 kwargs: Dict[ 10511 str, 10512 Union[base.Transition, base.DecisionName, None] 10513 ] = {} 10514 if reciprocal is not None: 10515 kwargs['reciprocal'] = reciprocal 10516 10517 if destination is not None: 10518 try: 10519 destID = now.graph.resolveDecision(destination) 10520 now.graph.addTransition( 10521 fromID, 10522 transition, 10523 destID, 10524 reciprocal 10525 ) 10526 return destID 10527 except MissingDecisionError: 10528 if isinstance(destination, base.DecisionSpecifier): 10529 kwargs['toDomain'] = destination.domain 10530 kwargs['placeInZone'] = destination.zone 10531 kwargs['destinationName'] = destination.name 10532 elif isinstance(destination, base.DecisionName): 10533 kwargs['destinationName'] = destination 10534 else: 10535 assert isinstance(destination, base.DecisionID) 10536 # We got to except by failing to resolve, so it's an 10537 # invalid ID 10538 raise 10539 10540 result = now.graph.addUnexploredEdge( 10541 fromID, 10542 transition, 10543 **kwargs # type: ignore [arg-type] 10544 ) 10545 if 'destinationName' in kwargs: 10546 self.setExplorationStatus(result, 'noticed', upgradeOnly=True) 10547 return result 10548 10549 def observeMechanisms( 10550 self, 10551 where: Optional[base.AnyDecisionSpecifier], 10552 *mechanisms: Union[ 10553 base.MechanismName, 10554 Tuple[base.MechanismName, base.MechanismState] 10555 ] 10556 ) -> List[base.MechanismID]: 10557 """ 10558 Adds one or more mechanisms to the exploration's current graph, 10559 located at the specified decision. Global mechanisms can be 10560 added by using `None` for the location. Mechanisms are named, or 10561 a (name, state) tuple can be used to set them into a specific 10562 state. Mechanisms not set to a state will be in the 10563 `base.DEFAULT_MECHANISM_STATE`. 10564 """ 10565 now = self.getSituation() 10566 result = [] 10567 for mSpec in mechanisms: 10568 setState = None 10569 if isinstance(mSpec, base.MechanismName): 10570 result.append(now.graph.addMechanism(mSpec, where)) 10571 elif ( 10572 isinstance(mSpec, tuple) 10573 and len(mSpec) == 2 10574 and isinstance(mSpec[0], base.MechanismName) 10575 and isinstance(mSpec[1], base.MechanismState) 10576 ): 10577 result.append(now.graph.addMechanism(mSpec[0], where)) 10578 setState = mSpec[1] 10579 else: 10580 raise TypeError( 10581 f"Invalid mechanism: {repr(mSpec)} (must be a" 10582 f" mechanism name or a (name, state) tuple." 10583 ) 10584 10585 if setState: 10586 self.setMechanismStateNow(result[-1], setState) 10587 10588 return result 10589 10590 def reZone( 10591 self, 10592 zone: base.Zone, 10593 where: base.AnyDecisionSpecifier, 10594 replace: Union[base.Zone, int] = 0 10595 ) -> None: 10596 """ 10597 Alters the current graph without adding a new exploration step. 10598 10599 Calls `DecisionGraph.replaceZonesInHierarchy` targeting the 10600 specified decision. Note that per the logic of that method, ALL 10601 zones at the specified hierarchy level are replaced, even if a 10602 specific zone to replace is specified here. 10603 10604 TODO: not that? 10605 10606 The level value is either specified via `replace` (default 0) or 10607 deduced from the zone provided as the `replace` value using 10608 `DecisionGraph.zoneHierarchyLevel`. 10609 """ 10610 now = self.getSituation() 10611 10612 if isinstance(replace, int): 10613 level = replace 10614 else: 10615 level = now.graph.zoneHierarchyLevel(replace) 10616 10617 now.graph.replaceZonesInHierarchy(where, zone, level) 10618 10619 def runCommand( 10620 self, 10621 command: commands.Command, 10622 scope: Optional[commands.Scope] = None, 10623 line: int = -1 10624 ) -> commands.CommandResult: 10625 """ 10626 Runs a single `Command` applying effects to the exploration, its 10627 current graph, and the provided execution context, and returning 10628 a command result, which contains the modified scope plus 10629 optional skip and label values (see `CommandResult`). This 10630 function also directly modifies the scope you give it. Variable 10631 references in the command are resolved via entries in the 10632 provided scope. If no scope is given, an empty one is created. 10633 10634 A line number may be supplied for use in error messages; if left 10635 out line -1 will be used. 10636 10637 Raises an error if the command is invalid. 10638 10639 For commands that establish a value as the 'current value', that 10640 value will be stored in the '_' variable. When this happens, the 10641 old contents of '_' are stored in '__' first, and the old 10642 contents of '__' are discarded. Note that non-automatic 10643 assignment to '_' does not move the old value to '__'. 10644 """ 10645 try: 10646 if scope is None: 10647 scope = {} 10648 10649 skip: Union[int, str, None] = None 10650 label: Optional[str] = None 10651 10652 if command.command == 'val': 10653 command = cast(commands.LiteralValue, command) 10654 result = commands.resolveValue(command.value, scope) 10655 commands.pushCurrentValue(scope, result) 10656 10657 elif command.command == 'empty': 10658 command = cast(commands.EstablishCollection, command) 10659 collection = commands.resolveVarName(command.collection, scope) 10660 commands.pushCurrentValue( 10661 scope, 10662 { 10663 'list': [], 10664 'tuple': (), 10665 'set': set(), 10666 'dict': {}, 10667 }[collection] 10668 ) 10669 10670 elif command.command == 'append': 10671 command = cast(commands.AppendValue, command) 10672 target = scope['_'] 10673 addIt = commands.resolveValue(command.value, scope) 10674 if isinstance(target, list): 10675 target.append(addIt) 10676 elif isinstance(target, tuple): 10677 scope['_'] = target + (addIt,) 10678 elif isinstance(target, set): 10679 target.add(addIt) 10680 elif isinstance(target, dict): 10681 raise TypeError( 10682 "'append' command cannot be used with a" 10683 " dictionary. Use 'set' instead." 10684 ) 10685 else: 10686 raise TypeError( 10687 f"Invalid current value for 'append' command." 10688 f" The current value must be a list, tuple, or" 10689 f" set, but it was a '{type(target).__name__}'." 10690 ) 10691 10692 elif command.command == 'set': 10693 command = cast(commands.SetValue, command) 10694 target = scope['_'] 10695 where = commands.resolveValue(command.location, scope) 10696 what = commands.resolveValue(command.value, scope) 10697 if isinstance(target, list): 10698 if not isinstance(where, int): 10699 raise TypeError( 10700 f"Cannot set item in list: index {where!r}" 10701 f" is not an integer." 10702 ) 10703 target[where] = what 10704 elif isinstance(target, tuple): 10705 if not isinstance(where, int): 10706 raise TypeError( 10707 f"Cannot set item in tuple: index {where!r}" 10708 f" is not an integer." 10709 ) 10710 if not ( 10711 0 <= where < len(target) 10712 or -1 >= where >= -len(target) 10713 ): 10714 raise IndexError( 10715 f"Cannot set item in tuple at index" 10716 f" {where}: Tuple has length {len(target)}." 10717 ) 10718 scope['_'] = target[:where] + (what,) + target[where + 1:] 10719 elif isinstance(target, set): 10720 if what: 10721 target.add(where) 10722 else: 10723 try: 10724 target.remove(where) 10725 except KeyError: 10726 pass 10727 elif isinstance(target, dict): 10728 target[where] = what 10729 10730 elif command.command == 'pop': 10731 command = cast(commands.PopValue, command) 10732 target = scope['_'] 10733 if isinstance(target, list): 10734 result = target.pop() 10735 commands.pushCurrentValue(scope, result) 10736 elif isinstance(target, tuple): 10737 result = target[-1] 10738 updated = target[:-1] 10739 scope['__'] = updated 10740 scope['_'] = result 10741 else: 10742 raise TypeError( 10743 f"Cannot 'pop' from a {type(target).__name__}" 10744 f" (current value must be a list or tuple)." 10745 ) 10746 10747 elif command.command == 'get': 10748 command = cast(commands.GetValue, command) 10749 target = scope['_'] 10750 where = commands.resolveValue(command.location, scope) 10751 if isinstance(target, list): 10752 if not isinstance(where, int): 10753 raise TypeError( 10754 f"Cannot get item from list: index" 10755 f" {where!r} is not an integer." 10756 ) 10757 elif isinstance(target, tuple): 10758 if not isinstance(where, int): 10759 raise TypeError( 10760 f"Cannot get item from tuple: index" 10761 f" {where!r} is not an integer." 10762 ) 10763 elif isinstance(target, set): 10764 result = where in target 10765 commands.pushCurrentValue(scope, result) 10766 elif isinstance(target, dict): 10767 result = target[where] 10768 commands.pushCurrentValue(scope, result) 10769 else: 10770 result = getattr(target, where) 10771 commands.pushCurrentValue(scope, result) 10772 10773 elif command.command == 'remove': 10774 command = cast(commands.RemoveValue, command) 10775 target = scope['_'] 10776 where = commands.resolveValue(command.location, scope) 10777 if isinstance(target, (list, tuple)): 10778 # this cast is not correct but suppresses warnings 10779 # given insufficient narrowing by MyPy 10780 target = cast(Tuple[Any, ...], target) 10781 if not isinstance(where, int): 10782 raise TypeError( 10783 f"Cannot remove item from list or tuple:" 10784 f" index {where!r} is not an integer." 10785 ) 10786 scope['_'] = target[:where] + target[where + 1:] 10787 elif isinstance(target, set): 10788 target.remove(where) 10789 elif isinstance(target, dict): 10790 del target[where] 10791 else: 10792 raise TypeError( 10793 f"Cannot use 'remove' on a/an" 10794 f" {type(target).__name__}." 10795 ) 10796 10797 elif command.command == 'op': 10798 command = cast(commands.ApplyOperator, command) 10799 left = commands.resolveValue(command.left, scope) 10800 right = commands.resolveValue(command.right, scope) 10801 op = command.op 10802 if op == '+': 10803 result = left + right 10804 elif op == '-': 10805 result = left - right 10806 elif op == '*': 10807 result = left * right 10808 elif op == '/': 10809 result = left / right 10810 elif op == '//': 10811 result = left // right 10812 elif op == '**': 10813 result = left ** right 10814 elif op == '%': 10815 result = left % right 10816 elif op == '^': 10817 result = left ^ right 10818 elif op == '|': 10819 result = left | right 10820 elif op == '&': 10821 result = left & right 10822 elif op == 'and': 10823 result = left and right 10824 elif op == 'or': 10825 result = left or right 10826 elif op == '<': 10827 result = left < right 10828 elif op == '>': 10829 result = left > right 10830 elif op == '<=': 10831 result = left <= right 10832 elif op == '>=': 10833 result = left >= right 10834 elif op == '==': 10835 result = left == right 10836 elif op == 'is': 10837 result = left is right 10838 else: 10839 raise RuntimeError("Invalid operator '{op}'.") 10840 10841 commands.pushCurrentValue(scope, result) 10842 10843 elif command.command == 'unary': 10844 command = cast(commands.ApplyUnary, command) 10845 value = commands.resolveValue(command.value, scope) 10846 op = command.op 10847 if op == '-': 10848 result = -value 10849 elif op == '~': 10850 result = ~value 10851 elif op == 'not': 10852 result = not value 10853 10854 commands.pushCurrentValue(scope, result) 10855 10856 elif command.command == 'assign': 10857 command = cast(commands.VariableAssignment, command) 10858 varname = commands.resolveVarName(command.varname, scope) 10859 value = commands.resolveValue(command.value, scope) 10860 scope[varname] = value 10861 10862 elif command.command == 'delete': 10863 command = cast(commands.VariableDeletion, command) 10864 varname = commands.resolveVarName(command.varname, scope) 10865 del scope[varname] 10866 10867 elif command.command == 'load': 10868 command = cast(commands.LoadVariable, command) 10869 varname = commands.resolveVarName(command.varname, scope) 10870 commands.pushCurrentValue(scope, scope[varname]) 10871 10872 elif command.command == 'call': 10873 command = cast(commands.FunctionCall, command) 10874 function = command.function 10875 if function.startswith('$'): 10876 function = commands.resolveValue(function, scope) 10877 10878 toCall: Callable 10879 args: Tuple[str, ...] 10880 kwargs: Dict[str, Any] 10881 10882 if command.target == 'builtin': 10883 toCall = commands.COMMAND_BUILTINS[function] 10884 args = (scope['_'],) 10885 kwargs = {} 10886 if toCall == round: 10887 if 'ndigits' in scope: 10888 kwargs['ndigits'] = scope['ndigits'] 10889 elif toCall == range and args[0] is None: 10890 start = scope.get('start', 0) 10891 stop = scope['stop'] 10892 step = scope.get('step', 1) 10893 args = (start, stop, step) 10894 10895 else: 10896 if command.target == 'stored': 10897 toCall = function 10898 elif command.target == 'graph': 10899 toCall = getattr(self.getSituation().graph, function) 10900 elif command.target == 'exploration': 10901 toCall = getattr(self, function) 10902 else: 10903 raise TypeError( 10904 f"Invalid call target '{command.target}'" 10905 f" (must be one of 'builtin', 'stored'," 10906 f" 'graph', or 'exploration'." 10907 ) 10908 10909 # Fill in arguments via kwargs defined in scope 10910 args = () 10911 kwargs = {} 10912 signature = inspect.signature(toCall) 10913 # TODO: Maybe try some type-checking here? 10914 for argName, param in signature.parameters.items(): 10915 if param.kind == inspect.Parameter.VAR_POSITIONAL: 10916 if argName in scope: 10917 args = args + tuple(scope[argName]) 10918 # Else leave args as-is 10919 elif param.kind == inspect.Parameter.KEYWORD_ONLY: 10920 # These must have a default 10921 if argName in scope: 10922 kwargs[argName] = scope[argName] 10923 elif param.kind == inspect.Parameter.VAR_KEYWORD: 10924 # treat as a dictionary 10925 if argName in scope: 10926 argsToUse = scope[argName] 10927 if not isinstance(argsToUse, dict): 10928 raise TypeError( 10929 f"Variable '{argName}' must" 10930 f" hold a dictionary when" 10931 f" calling function" 10932 f" '{toCall.__name__} which" 10933 f" uses that argument as a" 10934 f" keyword catchall." 10935 ) 10936 kwargs.update(scope[argName]) 10937 else: # a normal parameter 10938 if argName in scope: 10939 args = args + (scope[argName],) 10940 elif param.default == inspect.Parameter.empty: 10941 raise TypeError( 10942 f"No variable named '{argName}' has" 10943 f" been defined to supply the" 10944 f" required parameter with that" 10945 f" name for function" 10946 f" '{toCall.__name__}'." 10947 ) 10948 10949 result = toCall(*args, **kwargs) 10950 commands.pushCurrentValue(scope, result) 10951 10952 elif command.command == 'skip': 10953 command = cast(commands.SkipCommands, command) 10954 doIt = commands.resolveValue(command.condition, scope) 10955 if doIt: 10956 skip = commands.resolveValue(command.amount, scope) 10957 if not isinstance(skip, (int, str)): 10958 raise TypeError( 10959 f"Skip amount must be an integer or a label" 10960 f" name (got {skip!r})." 10961 ) 10962 10963 elif command.command == 'label': 10964 command = cast(commands.Label, command) 10965 label = commands.resolveValue(command.name, scope) 10966 if not isinstance(label, str): 10967 raise TypeError( 10968 f"Label name must be a string (got {label!r})." 10969 ) 10970 10971 else: 10972 raise ValueError( 10973 f"Invalid command type: {command.command!r}" 10974 ) 10975 except ValueError as e: 10976 raise commands.CommandValueError(command, line, e) 10977 except TypeError as e: 10978 raise commands.CommandTypeError(command, line, e) 10979 except IndexError as e: 10980 raise commands.CommandIndexError(command, line, e) 10981 except KeyError as e: 10982 raise commands.CommandKeyError(command, line, e) 10983 except Exception as e: 10984 raise commands.CommandOtherError(command, line, e) 10985 10986 return (scope, skip, label) 10987 10988 def runCommandBlock( 10989 self, 10990 block: List[commands.Command], 10991 scope: Optional[commands.Scope] = None 10992 ) -> commands.Scope: 10993 """ 10994 Runs a list of commands, using the given scope (or creating a new 10995 empty scope if none was provided). Returns the scope after 10996 running all of the commands, which may also edit the exploration 10997 and/or the current graph of course. 10998 10999 Note that if a skip command would skip past the end of the 11000 block, execution will end. If a skip command would skip before 11001 the beginning of the block, execution will start from the first 11002 command. 11003 11004 Example: 11005 11006 >>> e = DiscreteExploration() 11007 >>> scope = e.runCommandBlock([ 11008 ... commands.command('assign', 'decision', "'START'"), 11009 ... commands.command('call', 'exploration', 'start'), 11010 ... commands.command('assign', 'where', '$decision'), 11011 ... commands.command('assign', 'transition', "'left'"), 11012 ... commands.command('call', 'exploration', 'observe'), 11013 ... commands.command('assign', 'transition', "'right'"), 11014 ... commands.command('call', 'exploration', 'observe'), 11015 ... commands.command('call', 'graph', 'destinationsFrom'), 11016 ... commands.command('call', 'builtin', 'print'), 11017 ... commands.command('assign', 'transition', "'right'"), 11018 ... commands.command('assign', 'destination', "'EastRoom'"), 11019 ... commands.command('call', 'exploration', 'explore'), 11020 ... ]) 11021 {'left': 1, 'right': 2} 11022 >>> scope['decision'] 11023 'START' 11024 >>> scope['where'] 11025 'START' 11026 >>> scope['_'] # result of 'explore' call is dest ID 11027 2 11028 >>> scope['transition'] 11029 'right' 11030 >>> scope['destination'] 11031 'EastRoom' 11032 >>> g = e.getSituation().graph 11033 >>> len(e) 11034 3 11035 >>> len(g) 11036 3 11037 >>> g.namesListing(g) 11038 ' 0 (START)\\n 1 (_u.0)\\n 2 (EastRoom)\\n' 11039 """ 11040 if scope is None: 11041 scope = {} 11042 11043 labelPositions: Dict[str, List[int]] = {} 11044 11045 # Keep going until we've exhausted the commands list 11046 index = 0 11047 while index < len(block): 11048 11049 # Execute the next command 11050 scope, skip, label = self.runCommand( 11051 block[index], 11052 scope, 11053 index + 1 11054 ) 11055 11056 # Increment our index, or apply a skip 11057 if skip is None: 11058 index = index + 1 11059 11060 elif isinstance(skip, int): # Integer skip value 11061 if skip < 0: 11062 index += skip 11063 if index < 0: # can't skip before the start 11064 index = 0 11065 else: 11066 index += skip + 1 # may end loop if we skip too far 11067 11068 else: # must be a label name 11069 if skip in labelPositions: # an established label 11070 # We jump to the last previous index, or if there 11071 # are none, to the first future index. 11072 prevIndices = [ 11073 x 11074 for x in labelPositions[skip] 11075 if x < index 11076 ] 11077 futureIndices = [ 11078 x 11079 for x in labelPositions[skip] 11080 if x >= index 11081 ] 11082 if len(prevIndices) > 0: 11083 index = max(prevIndices) 11084 else: 11085 index = min(futureIndices) 11086 else: # must be a forward-reference 11087 for future in range(index + 1, len(block)): 11088 inspect = block[future] 11089 if inspect.command == 'label': 11090 inspect = cast(commands.Label, inspect) 11091 if inspect.name == skip: 11092 index = future 11093 break 11094 else: 11095 raise KeyError( 11096 f"Skip command indicated a jump to label" 11097 f" {skip!r} but that label had not already" 11098 f" been defined and there is no future" 11099 f" label with that name either (future" 11100 f" labels based on variables cannot be" 11101 f" skipped to from above as their names" 11102 f" are not known yet)." 11103 ) 11104 11105 # If there's a label, record it 11106 if label is not None: 11107 labelPositions.setdefault(label, []).append(index) 11108 11109 # And now the while loop continues, or ends if we're at the 11110 # end of the commands list. 11111 11112 # Return the scope object. 11113 return scope
A list of Situations
each of which contains a DecisionGraph
representing exploration over time, with States
containing
FocalContext
information for each step and 'taken' values for the
transition selected (at a particular decision) in that step. Each
decision graph represents a new state of the world (and/or new
knowledge about a persisting state of the world), and the 'taken'
transition in one situation transition indicates which option was
selected, or what event happened to cause update(s). Depending on the
resolution, it could represent a close record of every decision made
or a more coarse set of snapshots from gameplay with more time in
between.
The steps of the exploration can also be tagged and annotated (see
tagStep
and annotateStep
).
When a new DiscreteExploration
is created, it starts out with an
empty Situation
that contains an empty DecisionGraph
. Use the
start
method to name the starting decision point and set things up
for other methods.
Tracking of player goals and destinations is also possible (see the
quest
, progress
, complete
, destination
, and arrive
methods).
TODO: That
6463 @staticmethod 6464 def fromGraph( 6465 graph: DecisionGraph, 6466 state: Optional[base.State] = None 6467 ) -> 'DiscreteExploration': 6468 """ 6469 Creates an exploration which has just a single step whose graph 6470 is the entire specified graph, with the specified decision as 6471 the primary decision (if any). The graph is copied, so that 6472 changes to the exploration will not modify it. A starting state 6473 may also be specified if desired, although if not an empty state 6474 will be used (a provided starting state is NOT copied, but used 6475 directly). 6476 6477 Example: 6478 6479 >>> g = DecisionGraph() 6480 >>> g.addDecision('Room1') 6481 0 6482 >>> g.addDecision('Room2') 6483 1 6484 >>> g.addTransition('Room1', 'door', 'Room2', 'door') 6485 >>> e = DiscreteExploration.fromGraph(g) 6486 >>> len(e) 6487 1 6488 >>> e.getSituation().graph == g 6489 True 6490 >>> e.getActiveDecisions() 6491 set() 6492 >>> e.primaryDecision() is None 6493 True 6494 >>> e.observe('Room1', 'hatch') 6495 2 6496 >>> e.getSituation().graph == g 6497 False 6498 >>> e.getSituation().graph.destinationsFrom('Room1') 6499 {'door': 1, 'hatch': 2} 6500 >>> g.destinationsFrom('Room1') 6501 {'door': 1} 6502 """ 6503 result = DiscreteExploration() 6504 result.situations[0] = base.Situation( 6505 graph=copy.deepcopy(graph), 6506 state=base.emptyState() if state is None else state, 6507 type='pending', 6508 action=None, 6509 saves={}, 6510 tags={}, 6511 annotations=[] 6512 ) 6513 return result
Creates an exploration which has just a single step whose graph is the entire specified graph, with the specified decision as the primary decision (if any). The graph is copied, so that changes to the exploration will not modify it. A starting state may also be specified if desired, although if not an empty state will be used (a provided starting state is NOT copied, but used directly).
Example:
>>> g = DecisionGraph()
>>> g.addDecision('Room1')
0
>>> g.addDecision('Room2')
1
>>> g.addTransition('Room1', 'door', 'Room2', 'door')
>>> e = DiscreteExploration.fromGraph(g)
>>> len(e)
1
>>> e.getSituation().graph == g
True
>>> e.getActiveDecisions()
set()
>>> e.primaryDecision() is None
True
>>> e.observe('Room1', 'hatch')
2
>>> e.getSituation().graph == g
False
>>> e.getSituation().graph.destinationsFrom('Room1')
{'door': 1, 'hatch': 2}
>>> g.destinationsFrom('Room1')
{'door': 1}
6534 def getSituation(self, step: int = -1) -> base.Situation: 6535 """ 6536 Returns a `base.Situation` named tuple detailing the state of 6537 the exploration at a given step (or at the current step if no 6538 argument is given). Note that this method works the same 6539 way as indexing the exploration: see `__getitem__`. 6540 6541 Raises an `IndexError` if asked for a step that's out-of-range. 6542 """ 6543 return self[step]
Returns a base.Situation
named tuple detailing the state of
the exploration at a given step (or at the current step if no
argument is given). Note that this method works the same
way as indexing the exploration: see __getitem__
.
Raises an IndexError
if asked for a step that's out-of-range.
6545 def primaryDecision(self, step: int = -1) -> Optional[base.DecisionID]: 6546 """ 6547 Returns the current primary `base.DecisionID`, or the primary 6548 decision from a specific step if one is specified. This may be 6549 `None` for some steps, but mostly it's the destination of the 6550 transition taken in the previous step. 6551 """ 6552 return self[step].state['primaryDecision']
Returns the current primary base.DecisionID
, or the primary
decision from a specific step if one is specified. This may be
None
for some steps, but mostly it's the destination of the
transition taken in the previous step.
6554 def effectiveCapabilities( 6555 self, 6556 step: int = -1 6557 ) -> base.CapabilitySet: 6558 """ 6559 Returns the effective capability set for the specified step 6560 (default is the last/current step). See 6561 `base.effectiveCapabilities`. 6562 """ 6563 return base.effectiveCapabilitySet(self.getSituation(step).state)
Returns the effective capability set for the specified step
(default is the last/current step). See
base.effectiveCapabilities
.
6565 def getCommonContext( 6566 self, 6567 step: Optional[int] = None 6568 ) -> base.FocalContext: 6569 """ 6570 Returns the common `FocalContext` at the specified step, or at 6571 the current step if no argument is given. Raises an `IndexError` 6572 if an invalid step is specified. 6573 """ 6574 if step is None: 6575 step = -1 6576 state = self.getSituation(step).state 6577 return state['common']
Returns the common FocalContext
at the specified step, or at
the current step if no argument is given. Raises an IndexError
if an invalid step is specified.
6579 def getActiveContext( 6580 self, 6581 step: Optional[int] = None 6582 ) -> base.FocalContext: 6583 """ 6584 Returns the active `FocalContext` at the specified step, or at 6585 the current step if no argument is provided. Raises an 6586 `IndexError` if an invalid step is specified. 6587 """ 6588 if step is None: 6589 step = -1 6590 state = self.getSituation(step).state 6591 return state['contexts'][state['activeContext']]
Returns the active FocalContext
at the specified step, or at
the current step if no argument is provided. Raises an
IndexError
if an invalid step is specified.
6593 def addFocalContext(self, name: base.FocalContextName) -> None: 6594 """ 6595 Adds a new empty focal context to our set of focal contexts (see 6596 `emptyFocalContext`). Use `setActiveContext` to swap to it. 6597 Raises a `FocalContextCollisionError` if the name is already in 6598 use. 6599 """ 6600 contextMap = self.getSituation().state['contexts'] 6601 if name in contextMap: 6602 raise FocalContextCollisionError( 6603 f"Cannot add focal context {name!r}: a focal context" 6604 f" with that name already exists." 6605 ) 6606 contextMap[name] = base.emptyFocalContext()
Adds a new empty focal context to our set of focal contexts (see
emptyFocalContext
). Use setActiveContext
to swap to it.
Raises a FocalContextCollisionError
if the name is already in
use.
6608 def setActiveContext(self, which: base.FocalContextName) -> None: 6609 """ 6610 Sets the active context to the named focal context, creating it 6611 if it did not already exist (makes changes to the current 6612 situation only). Does not add an exploration step (use 6613 `advanceSituation` with a 'swap' action for that). 6614 """ 6615 state = self.getSituation().state 6616 contextMap = state['contexts'] 6617 if which not in contextMap: 6618 self.addFocalContext(which) 6619 state['activeContext'] = which
Sets the active context to the named focal context, creating it
if it did not already exist (makes changes to the current
situation only). Does not add an exploration step (use
advanceSituation
with a 'swap' action for that).
6621 def createDomain( 6622 self, 6623 name: base.Domain, 6624 focalization: base.DomainFocalization = 'singular', 6625 makeActive: bool = False, 6626 inCommon: Union[bool, Literal["both"]] = "both" 6627 ) -> None: 6628 """ 6629 Creates a new domain with the given focalization type, in either 6630 the common context (`inCommon` = `True`) the active context 6631 (`inCommon` = `False`) or both (the default; `inCommon` = 'both'). 6632 The domain's focalization will be set to the given 6633 `focalization` value (default 'singular') and it will have no 6634 active decisions. Raises a `DomainCollisionError` if a domain 6635 with the specified name already exists. 6636 6637 Creates the domain in the current situation. 6638 6639 If `makeActive` is set to `True` (default is `False`) then the 6640 domain will be made active in whichever context(s) it's created 6641 in. 6642 """ 6643 now = self.getSituation() 6644 state = now.state 6645 modify = [] 6646 if inCommon in (True, "both"): 6647 modify.append(('common', state['common'])) 6648 if inCommon in (False, "both"): 6649 acName = state['activeContext'] 6650 modify.append( 6651 ('current ({repr(acName)})', state['contexts'][acName]) 6652 ) 6653 6654 for (fcType, fc) in modify: 6655 if name in fc['focalization']: 6656 raise DomainCollisionError( 6657 f"Cannot create domain {repr(name)} because a" 6658 f" domain with that name already exists in the" 6659 f" {fcType} focal context." 6660 ) 6661 fc['focalization'][name] = focalization 6662 if makeActive: 6663 fc['activeDomains'].add(name) 6664 if focalization == "spreading": 6665 fc['activeDecisions'][name] = set() 6666 elif focalization == "plural": 6667 fc['activeDecisions'][name] = {} 6668 else: 6669 fc['activeDecisions'][name] = None
Creates a new domain with the given focalization type, in either
the common context (inCommon
= True
) the active context
(inCommon
= False
) or both (the default; inCommon
= 'both').
The domain's focalization will be set to the given
focalization
value (default 'singular') and it will have no
active decisions. Raises a DomainCollisionError
if a domain
with the specified name already exists.
Creates the domain in the current situation.
If makeActive
is set to True
(default is False
) then the
domain will be made active in whichever context(s) it's created
in.
6671 def activateDomain( 6672 self, 6673 domain: base.Domain, 6674 activate: bool = True, 6675 inContext: base.ContextSpecifier = "active" 6676 ) -> None: 6677 """ 6678 Sets the given domain as active (or inactive if 'activate' is 6679 given as `False`) in the specified context (default "active"). 6680 6681 Modifies the current situation. 6682 """ 6683 fc: base.FocalContext 6684 if inContext == "active": 6685 fc = self.getActiveContext() 6686 elif inContext == "common": 6687 fc = self.getCommonContext() 6688 6689 if activate: 6690 fc['activeDomains'].add(domain) 6691 else: 6692 try: 6693 fc['activeDomains'].remove(domain) 6694 except KeyError: 6695 pass
Sets the given domain as active (or inactive if 'activate' is
given as False
) in the specified context (default "active").
Modifies the current situation.
6697 def createTriggerGroup( 6698 self, 6699 name: base.DecisionName 6700 ) -> base.DecisionID: 6701 """ 6702 Creates a new trigger group with the given name, returning the 6703 decision ID for that trigger group. If this is the first trigger 6704 group being created, also creates the `TRIGGERS_DOMAIN` domain 6705 as a spreading-focalized domain that's active in the common 6706 context (but does NOT set the created trigger group as an active 6707 decision in that domain). 6708 6709 You can use 'goto' effects to activate trigger domains via 6710 consequences, and 'retreat' effects to deactivate them. 6711 6712 Creating a second trigger group with the same name as another 6713 results in a `ValueError`. 6714 6715 TODO: Retreat effects 6716 """ 6717 ctx = self.getCommonContext() 6718 if TRIGGERS_DOMAIN not in ctx['focalization']: 6719 self.createDomain( 6720 TRIGGERS_DOMAIN, 6721 focalization='spreading', 6722 makeActive=True, 6723 inCommon=True 6724 ) 6725 6726 graph = self.getSituation().graph 6727 if graph.getDecision( 6728 base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name) 6729 ) is not None: 6730 raise ValueError( 6731 f"Cannot create trigger group {name!r}: a trigger group" 6732 f" with that name already exists." 6733 ) 6734 6735 return self.getSituation().graph.triggerGroupID(name)
Creates a new trigger group with the given name, returning the
decision ID for that trigger group. If this is the first trigger
group being created, also creates the TRIGGERS_DOMAIN
domain
as a spreading-focalized domain that's active in the common
context (but does NOT set the created trigger group as an active
decision in that domain).
You can use 'goto' effects to activate trigger domains via consequences, and 'retreat' effects to deactivate them.
Creating a second trigger group with the same name as another
results in a ValueError
.
TODO: Retreat effects
6737 def toggleTriggerGroup( 6738 self, 6739 name: base.DecisionName, 6740 setActive: Union[bool, None] = None 6741 ): 6742 """ 6743 Toggles whether the specified trigger group (a decision in the 6744 `TRIGGERS_DOMAIN`) is active or not. Pass `True` or `False` as 6745 the `setActive` argument (instead of the default `None`) to set 6746 the state directly instead of toggling it. 6747 6748 Note that trigger groups are decisions in a spreading-focalized 6749 domain, so they can be activated or deactivated by the 'goto' 6750 and 'retreat' effects as well. 6751 6752 This does not affect whether the `TRIGGERS_DOMAIN` itself is 6753 active (normally it would always be active). 6754 6755 Raises a `MissingDecisionError` if the specified trigger group 6756 does not exist yet, including when the entire `TRIGGERS_DOMAIN` 6757 does not exist. Raises a `KeyError` if the target group exists 6758 but the `TRIGGERS_DOMAIN` has not been set up properly. 6759 """ 6760 ctx = self.getCommonContext() 6761 tID = self.getSituation().graph.resolveDecision( 6762 base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name) 6763 ) 6764 activeGroups = ctx['activeDecisions'][TRIGGERS_DOMAIN] 6765 assert isinstance(activeGroups, set) 6766 if tID in activeGroups: 6767 if setActive is not True: 6768 activeGroups.remove(tID) 6769 else: 6770 if setActive is not False: 6771 activeGroups.add(tID)
Toggles whether the specified trigger group (a decision in the
TRIGGERS_DOMAIN
) is active or not. Pass True
or False
as
the setActive
argument (instead of the default None
) to set
the state directly instead of toggling it.
Note that trigger groups are decisions in a spreading-focalized domain, so they can be activated or deactivated by the 'goto' and 'retreat' effects as well.
This does not affect whether the TRIGGERS_DOMAIN
itself is
active (normally it would always be active).
Raises a MissingDecisionError
if the specified trigger group
does not exist yet, including when the entire TRIGGERS_DOMAIN
does not exist. Raises a KeyError
if the target group exists
but the TRIGGERS_DOMAIN
has not been set up properly.
6773 def getActiveDecisions( 6774 self, 6775 step: Optional[int] = None, 6776 inCommon: Union[bool, Literal["both"]] = "both" 6777 ) -> Set[base.DecisionID]: 6778 """ 6779 Returns the set of active decisions at the given step index, or 6780 at the current step if no step is specified. Raises an 6781 `IndexError` if the step index is out of bounds (see `__len__`). 6782 May return an empty set if no decisions are active. 6783 6784 If `inCommon` is set to "both" (the default) then decisions 6785 active in either the common or active context are returned. Set 6786 it to `True` or `False` to return only decisions active in the 6787 common (when `True`) or active (when `False`) context. 6788 """ 6789 if step is None: 6790 step = -1 6791 state = self.getSituation(step).state 6792 if inCommon == "both": 6793 return base.combinedDecisionSet(state) 6794 elif inCommon is True: 6795 return base.activeDecisionSet(state['common']) 6796 elif inCommon is False: 6797 return base.activeDecisionSet( 6798 state['contexts'][state['activeContext']] 6799 ) 6800 else: 6801 raise ValueError( 6802 f"Invalid inCommon value {repr(inCommon)} (must be" 6803 f" 'both', True, or False)." 6804 )
Returns the set of active decisions at the given step index, or
at the current step if no step is specified. Raises an
IndexError
if the step index is out of bounds (see __len__
).
May return an empty set if no decisions are active.
If inCommon
is set to "both" (the default) then decisions
active in either the common or active context are returned. Set
it to True
or False
to return only decisions active in the
common (when True
) or active (when False
) context.
6806 def setActiveDecisionsAtStep( 6807 self, 6808 step: int, 6809 domain: base.Domain, 6810 activate: Union[ 6811 base.DecisionID, 6812 Dict[base.FocalPointName, Optional[base.DecisionID]], 6813 Set[base.DecisionID] 6814 ], 6815 inCommon: bool = False 6816 ) -> None: 6817 """ 6818 Changes the activation status of decisions in the active 6819 `FocalContext` at the specified step, for the specified domain 6820 (see `currentActiveContext`). Does this without adding an 6821 exploration step, which is unusual: normally you should use 6822 another method like `warp` to update active decisions. 6823 6824 Note that this does not change which domains are active, and 6825 setting active decisions in inactive domains does not make those 6826 decisions active overall. 6827 6828 Which decisions to activate or deactivate are specified as 6829 either a single `DecisionID`, a list of them, or a set of them, 6830 depending on the `DomainFocalization` setting in the selected 6831 `FocalContext` for the specified domain. A `TypeError` will be 6832 raised if the wrong kind of decision information is provided. If 6833 the focalization context does not have any focalization value for 6834 the domain in question, it will be set based on the kind of 6835 active decision information specified. 6836 6837 A `MissingDecisionError` will be raised if a decision is 6838 included which is not part of the current `DecisionGraph`. 6839 The provided information will overwrite the previous active 6840 decision information. 6841 6842 If `inCommon` is set to `True`, then decisions are activated or 6843 deactivated in the common context, instead of in the active 6844 context. 6845 6846 Example: 6847 6848 >>> e = DiscreteExploration() 6849 >>> e.getActiveDecisions() 6850 set() 6851 >>> graph = e.getSituation().graph 6852 >>> graph.addDecision('A') 6853 0 6854 >>> graph.addDecision('B') 6855 1 6856 >>> graph.addDecision('C') 6857 2 6858 >>> e.setActiveDecisionsAtStep(0, 'main', 0) 6859 >>> e.getActiveDecisions() 6860 {0} 6861 >>> e.setActiveDecisionsAtStep(0, 'main', 1) 6862 >>> e.getActiveDecisions() 6863 {1} 6864 >>> graph = e.getSituation().graph 6865 >>> graph.addDecision('One', domain='numbers') 6866 3 6867 >>> graph.addDecision('Two', domain='numbers') 6868 4 6869 >>> graph.addDecision('Three', domain='numbers') 6870 5 6871 >>> graph.addDecision('Bear', domain='animals') 6872 6 6873 >>> graph.addDecision('Spider', domain='animals') 6874 7 6875 >>> graph.addDecision('Eel', domain='animals') 6876 8 6877 >>> ac = e.getActiveContext() 6878 >>> ac['focalization']['numbers'] = 'plural' 6879 >>> ac['focalization']['animals'] = 'spreading' 6880 >>> ac['activeDecisions']['numbers'] = {'a': None, 'b': None} 6881 >>> ac['activeDecisions']['animals'] = set() 6882 >>> cc = e.getCommonContext() 6883 >>> cc['focalization']['numbers'] = 'plural' 6884 >>> cc['focalization']['animals'] = 'spreading' 6885 >>> cc['activeDecisions']['numbers'] = {'z': None} 6886 >>> cc['activeDecisions']['animals'] = set() 6887 >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 3, 'b': 3}) 6888 >>> e.getActiveDecisions() 6889 {1} 6890 >>> e.activateDomain('numbers') 6891 >>> e.getActiveDecisions() 6892 {1, 3} 6893 >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 4, 'b': None}) 6894 >>> e.getActiveDecisions() 6895 {1, 4} 6896 >>> # Wrong domain for the decision ID: 6897 >>> e.setActiveDecisionsAtStep(0, 'main', 3) 6898 Traceback (most recent call last): 6899 ... 6900 ValueError... 6901 >>> # Wrong domain for one of the decision IDs: 6902 >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 2, 'b': None}) 6903 Traceback (most recent call last): 6904 ... 6905 ValueError... 6906 >>> # Wrong kind of decision information provided. 6907 >>> e.setActiveDecisionsAtStep(0, 'numbers', 3) 6908 Traceback (most recent call last): 6909 ... 6910 TypeError... 6911 >>> e.getActiveDecisions() 6912 {1, 4} 6913 >>> e.setActiveDecisionsAtStep(0, 'animals', {6, 7}) 6914 >>> e.getActiveDecisions() 6915 {1, 4} 6916 >>> e.activateDomain('animals') 6917 >>> e.getActiveDecisions() 6918 {1, 4, 6, 7} 6919 >>> e.setActiveDecisionsAtStep(0, 'animals', {8}) 6920 >>> e.getActiveDecisions() 6921 {8, 1, 4} 6922 >>> e.setActiveDecisionsAtStep(1, 'main', 2) # invalid step 6923 Traceback (most recent call last): 6924 ... 6925 IndexError... 6926 >>> e.setActiveDecisionsAtStep(0, 'novel', 0) # domain mismatch 6927 Traceback (most recent call last): 6928 ... 6929 ValueError... 6930 6931 Example of active/common contexts: 6932 6933 >>> e = DiscreteExploration() 6934 >>> graph = e.getSituation().graph 6935 >>> graph.addDecision('A') 6936 0 6937 >>> graph.addDecision('B') 6938 1 6939 >>> e.activateDomain('main', inContext="common") 6940 >>> e.setActiveDecisionsAtStep(0, 'main', 0, inCommon=True) 6941 >>> e.getActiveDecisions() 6942 {0} 6943 >>> e.setActiveDecisionsAtStep(0, 'main', None) 6944 >>> e.getActiveDecisions() 6945 {0} 6946 >>> # (Still active since it's active in the common context) 6947 >>> e.setActiveDecisionsAtStep(0, 'main', 1) 6948 >>> e.getActiveDecisions() 6949 {0, 1} 6950 >>> e.setActiveDecisionsAtStep(0, 'main', 1, inCommon=True) 6951 >>> e.getActiveDecisions() 6952 {1} 6953 >>> e.setActiveDecisionsAtStep(0, 'main', None, inCommon=True) 6954 >>> e.getActiveDecisions() 6955 {1} 6956 >>> # (Still active since it's active in the active context) 6957 >>> e.setActiveDecisionsAtStep(0, 'main', None) 6958 >>> e.getActiveDecisions() 6959 set() 6960 """ 6961 now = self.getSituation(step) 6962 graph = now.graph 6963 if inCommon: 6964 context = self.getCommonContext(step) 6965 else: 6966 context = self.getActiveContext(step) 6967 6968 defaultFocalization: base.DomainFocalization = 'singular' 6969 if isinstance(activate, base.DecisionID): 6970 defaultFocalization = 'singular' 6971 elif isinstance(activate, dict): 6972 defaultFocalization = 'plural' 6973 elif isinstance(activate, set): 6974 defaultFocalization = 'spreading' 6975 elif domain not in context['focalization']: 6976 raise TypeError( 6977 f"Domain {domain!r} has no focalization in the" 6978 f" {'common' if inCommon else 'active'} context," 6979 f" and the specified position doesn't imply one." 6980 ) 6981 6982 focalization = base.getDomainFocalization( 6983 context, 6984 domain, 6985 defaultFocalization 6986 ) 6987 6988 # Check domain & existence of decision(s) in question 6989 if activate is None: 6990 pass 6991 elif isinstance(activate, base.DecisionID): 6992 if activate not in graph: 6993 raise MissingDecisionError( 6994 f"There is no decision {activate} at step {step}." 6995 ) 6996 if graph.domainFor(activate) != domain: 6997 raise ValueError( 6998 f"Can't set active decisions in domain {domain!r}" 6999 f" to decision {graph.identityOf(activate)} because" 7000 f" that decision is in actually in domain" 7001 f" {graph.domainFor(activate)!r}." 7002 ) 7003 elif isinstance(activate, dict): 7004 for fpName, pos in activate.items(): 7005 if pos is None: 7006 continue 7007 if pos not in graph: 7008 raise MissingDecisionError( 7009 f"There is no decision {pos} at step {step}." 7010 ) 7011 if graph.domainFor(pos) != domain: 7012 raise ValueError( 7013 f"Can't set active decision for focal point" 7014 f" {fpName!r} in domain {domain!r}" 7015 f" to decision {graph.identityOf(pos)} because" 7016 f" that decision is in actually in domain" 7017 f" {graph.domainFor(pos)!r}." 7018 ) 7019 elif isinstance(activate, set): 7020 for pos in activate: 7021 if pos not in graph: 7022 raise MissingDecisionError( 7023 f"There is no decision {pos} at step {step}." 7024 ) 7025 if graph.domainFor(pos) != domain: 7026 raise ValueError( 7027 f"Can't set {graph.identityOf(pos)} as an" 7028 f" active decision in domain {domain!r} to" 7029 f" decision because that decision is in" 7030 f" actually in domain {graph.domainFor(pos)!r}." 7031 ) 7032 else: 7033 raise TypeError( 7034 f"Domain {domain!r} has no focalization in the" 7035 f" {'common' if inCommon else 'active'} context," 7036 f" and the specified position doesn't imply one:" 7037 f"\n{activate!r}" 7038 ) 7039 7040 if focalization == 'singular': 7041 if activate is None or isinstance(activate, base.DecisionID): 7042 if activate is not None: 7043 targetDomain = graph.domainFor(activate) 7044 if activate not in graph: 7045 raise MissingDecisionError( 7046 f"There is no decision {activate} in the" 7047 f" graph at step {step}." 7048 ) 7049 elif targetDomain != domain: 7050 raise ValueError( 7051 f"At step {step}, decision {activate} cannot" 7052 f" be the active decision for domain" 7053 f" {repr(domain)} because it is in a" 7054 f" different domain ({repr(targetDomain)})." 7055 ) 7056 context['activeDecisions'][domain] = activate 7057 else: 7058 raise TypeError( 7059 f"{'Common' if inCommon else 'Active'} focal" 7060 f" context at step {step} has {repr(focalization)}" 7061 f" focalization for domain {repr(domain)}, so the" 7062 f" active decision must be a single decision or" 7063 f" None.\n(You provided: {repr(activate)})" 7064 ) 7065 elif focalization == 'plural': 7066 if ( 7067 isinstance(activate, dict) 7068 and all( 7069 isinstance(k, base.FocalPointName) 7070 for k in activate.keys() 7071 ) 7072 and all( 7073 v is None or isinstance(v, base.DecisionID) 7074 for v in activate.values() 7075 ) 7076 ): 7077 for v in activate.values(): 7078 if v is not None: 7079 targetDomain = graph.domainFor(v) 7080 if v not in graph: 7081 raise MissingDecisionError( 7082 f"There is no decision {v} in the graph" 7083 f" at step {step}." 7084 ) 7085 elif targetDomain != domain: 7086 raise ValueError( 7087 f"At step {step}, decision {activate}" 7088 f" cannot be an active decision for" 7089 f" domain {repr(domain)} because it is" 7090 f" in a different domain" 7091 f" ({repr(targetDomain)})." 7092 ) 7093 context['activeDecisions'][domain] = activate 7094 else: 7095 raise TypeError( 7096 f"{'Common' if inCommon else 'Active'} focal" 7097 f" context at step {step} has {repr(focalization)}" 7098 f" focalization for domain {repr(domain)}, so the" 7099 f" active decision must be a dictionary mapping" 7100 f" focal point names to decision IDs (or Nones)." 7101 f"\n(You provided: {repr(activate)})" 7102 ) 7103 elif focalization == 'spreading': 7104 if ( 7105 isinstance(activate, set) 7106 and all(isinstance(x, base.DecisionID) for x in activate) 7107 ): 7108 for x in activate: 7109 targetDomain = graph.domainFor(x) 7110 if x not in graph: 7111 raise MissingDecisionError( 7112 f"There is no decision {x} in the graph" 7113 f" at step {step}." 7114 ) 7115 elif targetDomain != domain: 7116 raise ValueError( 7117 f"At step {step}, decision {activate}" 7118 f" cannot be an active decision for" 7119 f" domain {repr(domain)} because it is" 7120 f" in a different domain" 7121 f" ({repr(targetDomain)})." 7122 ) 7123 context['activeDecisions'][domain] = activate 7124 else: 7125 raise TypeError( 7126 f"{'Common' if inCommon else 'Active'} focal" 7127 f" context at step {step} has {repr(focalization)}" 7128 f" focalization for domain {repr(domain)}, so the" 7129 f" active decision must be a set of decision IDs" 7130 f"\n(You provided: {repr(activate)})" 7131 ) 7132 else: 7133 raise RuntimeError( 7134 f"Invalid focalization value {repr(focalization)} for" 7135 f" domain {repr(domain)} at step {step}." 7136 )
Changes the activation status of decisions in the active
FocalContext
at the specified step, for the specified domain
(see currentActiveContext
). Does this without adding an
exploration step, which is unusual: normally you should use
another method like warp
to update active decisions.
Note that this does not change which domains are active, and setting active decisions in inactive domains does not make those decisions active overall.
Which decisions to activate or deactivate are specified as
either a single DecisionID
, a list of them, or a set of them,
depending on the DomainFocalization
setting in the selected
FocalContext
for the specified domain. A TypeError
will be
raised if the wrong kind of decision information is provided. If
the focalization context does not have any focalization value for
the domain in question, it will be set based on the kind of
active decision information specified.
A MissingDecisionError
will be raised if a decision is
included which is not part of the current DecisionGraph
.
The provided information will overwrite the previous active
decision information.
If inCommon
is set to True
, then decisions are activated or
deactivated in the common context, instead of in the active
context.
Example:
>>> e = DiscreteExploration()
>>> e.getActiveDecisions()
set()
>>> graph = e.getSituation().graph
>>> graph.addDecision('A')
0
>>> graph.addDecision('B')
1
>>> graph.addDecision('C')
2
>>> e.setActiveDecisionsAtStep(0, 'main', 0)
>>> e.getActiveDecisions()
{0}
>>> e.setActiveDecisionsAtStep(0, 'main', 1)
>>> e.getActiveDecisions()
{1}
>>> graph = e.getSituation().graph
>>> graph.addDecision('One', domain='numbers')
3
>>> graph.addDecision('Two', domain='numbers')
4
>>> graph.addDecision('Three', domain='numbers')
5
>>> graph.addDecision('Bear', domain='animals')
6
>>> graph.addDecision('Spider', domain='animals')
7
>>> graph.addDecision('Eel', domain='animals')
8
>>> ac = e.getActiveContext()
>>> ac['focalization']['numbers'] = 'plural'
>>> ac['focalization']['animals'] = 'spreading'
>>> ac['activeDecisions']['numbers'] = {'a': None, 'b': None}
>>> ac['activeDecisions']['animals'] = set()
>>> cc = e.getCommonContext()
>>> cc['focalization']['numbers'] = 'plural'
>>> cc['focalization']['animals'] = 'spreading'
>>> cc['activeDecisions']['numbers'] = {'z': None}
>>> cc['activeDecisions']['animals'] = set()
>>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 3, 'b': 3})
>>> e.getActiveDecisions()
{1}
>>> e.activateDomain('numbers')
>>> e.getActiveDecisions()
{1, 3}
>>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 4, 'b': None})
>>> e.getActiveDecisions()
{1, 4}
>>> # Wrong domain for the decision ID:
>>> e.setActiveDecisionsAtStep(0, 'main', 3)
Traceback (most recent call last):
...
ValueError...
>>> # Wrong domain for one of the decision IDs:
>>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 2, 'b': None})
Traceback (most recent call last):
...
ValueError...
>>> # Wrong kind of decision information provided.
>>> e.setActiveDecisionsAtStep(0, 'numbers', 3)
Traceback (most recent call last):
...
TypeError...
>>> e.getActiveDecisions()
{1, 4}
>>> e.setActiveDecisionsAtStep(0, 'animals', {6, 7})
>>> e.getActiveDecisions()
{1, 4}
>>> e.activateDomain('animals')
>>> e.getActiveDecisions()
{1, 4, 6, 7}
>>> e.setActiveDecisionsAtStep(0, 'animals', {8})
>>> e.getActiveDecisions()
{8, 1, 4}
>>> e.setActiveDecisionsAtStep(1, 'main', 2) # invalid step
Traceback (most recent call last):
...
IndexError...
>>> e.setActiveDecisionsAtStep(0, 'novel', 0) # domain mismatch
Traceback (most recent call last):
...
ValueError...
Example of active/common contexts:
>>> e = DiscreteExploration()
>>> graph = e.getSituation().graph
>>> graph.addDecision('A')
0
>>> graph.addDecision('B')
1
>>> e.activateDomain('main', inContext="common")
>>> e.setActiveDecisionsAtStep(0, 'main', 0, inCommon=True)
>>> e.getActiveDecisions()
{0}
>>> e.setActiveDecisionsAtStep(0, 'main', None)
>>> e.getActiveDecisions()
{0}
>>> # (Still active since it's active in the common context)
>>> e.setActiveDecisionsAtStep(0, 'main', 1)
>>> e.getActiveDecisions()
{0, 1}
>>> e.setActiveDecisionsAtStep(0, 'main', 1, inCommon=True)
>>> e.getActiveDecisions()
{1}
>>> e.setActiveDecisionsAtStep(0, 'main', None, inCommon=True)
>>> e.getActiveDecisions()
{1}
>>> # (Still active since it's active in the active context)
>>> e.setActiveDecisionsAtStep(0, 'main', None)
>>> e.getActiveDecisions()
set()
7138 def movementAtStep(self, step: int = -1) -> Tuple[ 7139 Union[base.DecisionID, Set[base.DecisionID], None], 7140 Optional[base.Transition], 7141 Union[base.DecisionID, Set[base.DecisionID], None] 7142 ]: 7143 """ 7144 Given a step number, returns information about the starting 7145 decision, transition taken, and destination decision for that 7146 step. Not all steps have all of those, so some items may be 7147 `None`. 7148 7149 For steps where there is no action, where a decision is still 7150 pending, or where the action type is 'focus', 'swap', 7151 or 'focalize', the result will be (`None`, `None`, `None`), 7152 unless a primary decision is available in which case the first 7153 item in the tuple will be that decision. For 'start' actions, the 7154 starting position and transition will be `None` (again unless the 7155 step had a primary decision) but the destination will be the ID 7156 of the node started at. 7157 7158 Also, if the action taken has multiple potential or actual start 7159 or end points, these may be sets of decision IDs instead of 7160 single IDs. 7161 7162 Note that the primary decision of the starting state is usually 7163 used as the from-decision, but in some cases an action dictates 7164 taking a transition from a different decision, and this function 7165 will return that decision as the from-decision. 7166 7167 TODO: Examples! 7168 7169 TODO: Account for bounce/follow/goto effects!!! 7170 """ 7171 now = self.getSituation(step) 7172 action = now.action 7173 graph = now.graph 7174 primary = now.state['primaryDecision'] 7175 7176 if action is None: 7177 return (primary, None, None) 7178 7179 aType = action[0] 7180 fromID: Optional[base.DecisionID] 7181 destID: Optional[base.DecisionID] 7182 transition: base.Transition 7183 outcomes: List[bool] 7184 7185 if aType in ('noAction', 'focus', 'swap', 'focalize'): 7186 return (primary, None, None) 7187 elif aType == 'start': 7188 assert len(action) == 7 7189 where = cast( 7190 Union[ 7191 base.DecisionID, 7192 Dict[base.FocalPointName, base.DecisionID], 7193 Set[base.DecisionID] 7194 ], 7195 action[1] 7196 ) 7197 if isinstance(where, dict): 7198 where = set(where.values()) 7199 return (primary, None, where) 7200 elif aType in ('take', 'explore'): 7201 if ( 7202 (len(action) == 4 or len(action) == 7) 7203 and isinstance(action[2], base.DecisionID) 7204 ): 7205 fromID = action[2] 7206 assert isinstance(action[3], tuple) 7207 transition, outcomes = action[3] 7208 if ( 7209 action[0] == "explore" 7210 and isinstance(action[4], base.DecisionID) 7211 ): 7212 destID = action[4] 7213 else: 7214 destID = graph.getDestination(fromID, transition) 7215 return (fromID, transition, destID) 7216 elif ( 7217 (len(action) == 3 or len(action) == 6) 7218 and isinstance(action[1], tuple) 7219 and isinstance(action[2], base.Transition) 7220 and len(action[1]) == 3 7221 and action[1][0] in get_args(base.ContextSpecifier) 7222 and isinstance(action[1][1], base.Domain) 7223 and isinstance(action[1][2], base.FocalPointName) 7224 ): 7225 fromID = base.resolvePosition(now, action[1]) 7226 if fromID is None: 7227 raise InvalidActionError( 7228 f"{aType!r} action at step {step} has position" 7229 f" {action[1]!r} which cannot be resolved to a" 7230 f" decision." 7231 ) 7232 transition, outcomes = action[2] 7233 if ( 7234 action[0] == "explore" 7235 and isinstance(action[3], base.DecisionID) 7236 ): 7237 destID = action[3] 7238 else: 7239 destID = graph.getDestination(fromID, transition) 7240 return (fromID, transition, destID) 7241 else: 7242 raise InvalidActionError( 7243 f"Malformed {aType!r} action:\n{repr(action)}" 7244 ) 7245 elif aType == 'warp': 7246 if len(action) != 3: 7247 raise InvalidActionError( 7248 f"Malformed 'warp' action:\n{repr(action)}" 7249 ) 7250 dest = action[2] 7251 assert isinstance(dest, base.DecisionID) 7252 if action[1] in get_args(base.ContextSpecifier): 7253 # Unspecified starting point; find active decisions in 7254 # same domain if primary is None 7255 if primary is not None: 7256 return (primary, None, dest) 7257 else: 7258 toDomain = now.graph.domainFor(dest) 7259 # TODO: Could check destination focalization here... 7260 active = self.getActiveDecisions(step) 7261 sameDomain = set( 7262 dID 7263 for dID in active 7264 if now.graph.domainFor(dID) == toDomain 7265 ) 7266 if len(sameDomain) == 1: 7267 return ( 7268 list(sameDomain)[0], 7269 None, 7270 dest 7271 ) 7272 else: 7273 return ( 7274 sameDomain, 7275 None, 7276 dest 7277 ) 7278 else: 7279 if ( 7280 not isinstance(action[1], tuple) 7281 or not len(action[1]) == 3 7282 or not action[1][0] in get_args(base.ContextSpecifier) 7283 or not isinstance(action[1][1], base.Domain) 7284 or not isinstance(action[1][2], base.FocalPointName) 7285 ): 7286 raise InvalidActionError( 7287 f"Malformed 'warp' action:\n{repr(action)}" 7288 ) 7289 return ( 7290 base.resolvePosition(now, action[1]), 7291 None, 7292 dest 7293 ) 7294 else: 7295 raise InvalidActionError( 7296 f"Action taken had invalid action type {repr(aType)}:" 7297 f"\n{repr(action)}" 7298 )
Given a step number, returns information about the starting
decision, transition taken, and destination decision for that
step. Not all steps have all of those, so some items may be
None
.
For steps where there is no action, where a decision is still
pending, or where the action type is 'focus', 'swap',
or 'focalize', the result will be (None
, None
, None
),
unless a primary decision is available in which case the first
item in the tuple will be that decision. For 'start' actions, the
starting position and transition will be None
(again unless the
step had a primary decision) but the destination will be the ID
of the node started at.
Also, if the action taken has multiple potential or actual start or end points, these may be sets of decision IDs instead of single IDs.
Note that the primary decision of the starting state is usually used as the from-decision, but in some cases an action dictates taking a transition from a different decision, and this function will return that decision as the from-decision.
TODO: Examples!
TODO: Account for bounce/follow/goto effects!!!
7300 def tagStep( 7301 self, 7302 tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]], 7303 tagValue: Union[ 7304 base.TagValue, 7305 type[base.NoTagValue] 7306 ] = base.NoTagValue, 7307 step: int = -1 7308 ) -> None: 7309 """ 7310 Adds a tag (or multiple tags) to the current step, or to a 7311 specific step if `n` is given as an integer rather than the 7312 default `None`. A tag value should be supplied when a tag is 7313 given (unless you want to use the default of `1`), but it's a 7314 `ValueError` to supply a tag value when a dictionary of tags to 7315 update is provided. 7316 """ 7317 if isinstance(tagOrTags, base.Tag): 7318 if tagValue is base.NoTagValue: 7319 tagValue = 1 7320 7321 # Not sure why this is necessary... 7322 tagValue = cast(base.TagValue, tagValue) 7323 7324 self.getSituation(step).tags.update({tagOrTags: tagValue}) 7325 else: 7326 self.getSituation(step).tags.update(tagOrTags)
Adds a tag (or multiple tags) to the current step, or to a
specific step if n
is given as an integer rather than the
default None
. A tag value should be supplied when a tag is
given (unless you want to use the default of 1
), but it's a
ValueError
to supply a tag value when a dictionary of tags to
update is provided.
7328 def annotateStep( 7329 self, 7330 annotationOrAnnotations: Union[ 7331 base.Annotation, 7332 Sequence[base.Annotation] 7333 ], 7334 step: Optional[int] = None 7335 ) -> None: 7336 """ 7337 Adds an annotation to the current exploration step, or to a 7338 specific step if `n` is given as an integer rather than the 7339 default `None`. 7340 """ 7341 if step is None: 7342 step = -1 7343 if isinstance(annotationOrAnnotations, base.Annotation): 7344 self.getSituation(step).annotations.append( 7345 annotationOrAnnotations 7346 ) 7347 else: 7348 self.getSituation(step).annotations.extend( 7349 annotationOrAnnotations 7350 )
Adds an annotation to the current exploration step, or to a
specific step if n
is given as an integer rather than the
default None
.
7352 def hasCapability( 7353 self, 7354 capability: base.Capability, 7355 step: Optional[int] = None, 7356 inCommon: Union[bool, Literal['both']] = "both" 7357 ) -> bool: 7358 """ 7359 Returns True if the player currently had the specified 7360 capability, at the specified exploration step, and False 7361 otherwise. Checks the current state if no step is given. Does 7362 NOT return true if the game state means that the player has an 7363 equivalent for that capability (see 7364 `hasCapabilityOrEquivalent`). 7365 7366 Normally, `inCommon` is set to 'both' by default and so if 7367 either the common `FocalContext` or the active one has the 7368 capability, this will return `True`. `inCommon` may instead be 7369 set to `True` or `False` to ask about just the common (or 7370 active) focal context. 7371 """ 7372 state = self.getSituation().state 7373 commonCapabilities = state['common']['capabilities']\ 7374 ['capabilities'] # noqa 7375 activeCapabilities = state['contexts'][state['activeContext']]\ 7376 ['capabilities']['capabilities'] # noqa 7377 7378 if inCommon == 'both': 7379 return ( 7380 capability in commonCapabilities 7381 or capability in activeCapabilities 7382 ) 7383 elif inCommon is True: 7384 return capability in commonCapabilities 7385 elif inCommon is False: 7386 return capability in activeCapabilities 7387 else: 7388 raise ValueError( 7389 f"Invalid inCommon value (must be False, True, or" 7390 f" 'both'; got {repr(inCommon)})." 7391 )
Returns True if the player currently had the specified
capability, at the specified exploration step, and False
otherwise. Checks the current state if no step is given. Does
NOT return true if the game state means that the player has an
equivalent for that capability (see
hasCapabilityOrEquivalent
).
Normally, inCommon
is set to 'both' by default and so if
either the common FocalContext
or the active one has the
capability, this will return True
. inCommon
may instead be
set to True
or False
to ask about just the common (or
active) focal context.
7393 def hasCapabilityOrEquivalent( 7394 self, 7395 capability: base.Capability, 7396 step: Optional[int] = None, 7397 location: Optional[Set[base.DecisionID]] = None 7398 ) -> bool: 7399 """ 7400 Works like `hasCapability`, but also returns `True` if the 7401 player counts as having the specified capability via an equivalence 7402 that's part of the current graph. As with `hasCapability`, the 7403 optional `step` argument is used to specify which step to check, 7404 with the current step being used as the default. 7405 7406 The `location` set can specify where to start looking for 7407 mechanisms; if left unspecified active decisions for that step 7408 will be used. 7409 """ 7410 if step is None: 7411 step = -1 7412 if location is None: 7413 location = self.getActiveDecisions(step) 7414 situation = self.getSituation(step) 7415 return base.hasCapabilityOrEquivalent( 7416 capability, 7417 base.RequirementContext( 7418 state=situation.state, 7419 graph=situation.graph, 7420 searchFrom=location 7421 ) 7422 )
Works like hasCapability
, but also returns True
if the
player counts as having the specified capability via an equivalence
that's part of the current graph. As with hasCapability
, the
optional step
argument is used to specify which step to check,
with the current step being used as the default.
The location
set can specify where to start looking for
mechanisms; if left unspecified active decisions for that step
will be used.
7424 def gainCapabilityNow( 7425 self, 7426 capability: base.Capability, 7427 inCommon: bool = False 7428 ) -> None: 7429 """ 7430 Modifies the current game state to add the specified `Capability` 7431 to the player's capabilities. No changes are made to the current 7432 graph. 7433 7434 If `inCommon` is set to `True` (default is `False`) then the 7435 capability will be added to the common `FocalContext` and will 7436 therefore persist even when a focal context switch happens. 7437 Normally, it will be added to the currently-active focal 7438 context. 7439 """ 7440 state = self.getSituation().state 7441 if inCommon: 7442 context = state['common'] 7443 else: 7444 context = state['contexts'][state['activeContext']] 7445 context['capabilities']['capabilities'].add(capability)
Modifies the current game state to add the specified Capability
to the player's capabilities. No changes are made to the current
graph.
If inCommon
is set to True
(default is False
) then the
capability will be added to the common FocalContext
and will
therefore persist even when a focal context switch happens.
Normally, it will be added to the currently-active focal
context.
7447 def loseCapabilityNow( 7448 self, 7449 capability: base.Capability, 7450 inCommon: Union[bool, Literal['both']] = "both" 7451 ) -> None: 7452 """ 7453 Modifies the current game state to remove the specified `Capability` 7454 from the player's capabilities. Does nothing if the player 7455 doesn't already have that capability. 7456 7457 By default, this removes the capability from both the common 7458 capabilities set and the active `FocalContext`'s capabilities 7459 set, so that afterwards the player will definitely not have that 7460 capability. However, if you set `inCommon` to either `True` or 7461 `False`, it will remove the capability from just the common 7462 capabilities set (if `True`) or just the active capabilities set 7463 (if `False`). In these cases, removing the capability from just 7464 one capability set will not actually remove it in terms of the 7465 `hasCapability` result if it had been present in the other set. 7466 Set `inCommon` to "both" to use the default behavior explicitly. 7467 """ 7468 now = self.getSituation() 7469 if inCommon in ("both", True): 7470 context = now.state['common'] 7471 try: 7472 context['capabilities']['capabilities'].remove(capability) 7473 except KeyError: 7474 pass 7475 elif inCommon in ("both", False): 7476 context = now.state['contexts'][now.state['activeContext']] 7477 try: 7478 context['capabilities']['capabilities'].remove(capability) 7479 except KeyError: 7480 pass 7481 else: 7482 raise ValueError( 7483 f"Invalid inCommon value (must be False, True, or" 7484 f" 'both'; got {repr(inCommon)})." 7485 )
Modifies the current game state to remove the specified Capability
from the player's capabilities. Does nothing if the player
doesn't already have that capability.
By default, this removes the capability from both the common
capabilities set and the active FocalContext
's capabilities
set, so that afterwards the player will definitely not have that
capability. However, if you set inCommon
to either True
or
False
, it will remove the capability from just the common
capabilities set (if True
) or just the active capabilities set
(if False
). In these cases, removing the capability from just
one capability set will not actually remove it in terms of the
hasCapability
result if it had been present in the other set.
Set inCommon
to "both" to use the default behavior explicitly.
7487 def tokenCountNow(self, tokenType: base.Token) -> Optional[int]: 7488 """ 7489 Returns the number of tokens the player currently has of a given 7490 type. Returns `None` if the player has never acquired or lost 7491 tokens of that type. 7492 7493 This method adds together tokens from the common and active 7494 focal contexts. 7495 """ 7496 state = self.getSituation().state 7497 commonContext = state['common'] 7498 activeContext = state['contexts'][state['activeContext']] 7499 base = commonContext['capabilities']['tokens'].get(tokenType) 7500 if base is None: 7501 return activeContext['capabilities']['tokens'].get(tokenType) 7502 else: 7503 return base + activeContext['capabilities']['tokens'].get( 7504 tokenType, 7505 0 7506 )
Returns the number of tokens the player currently has of a given
type. Returns None
if the player has never acquired or lost
tokens of that type.
This method adds together tokens from the common and active focal contexts.
7508 def adjustTokensNow( 7509 self, 7510 tokenType: base.Token, 7511 amount: int, 7512 inCommon: bool = False 7513 ) -> None: 7514 """ 7515 Modifies the current game state to add the specified number of 7516 `Token`s of the given type to the player's tokens. No changes are 7517 made to the current graph. Reduce the number of tokens by 7518 supplying a negative amount; note that negative token amounts 7519 are possible. 7520 7521 By default, the number of tokens for the current active 7522 `FocalContext` will be adjusted. However, if `inCommon` is set 7523 to `True`, then the number of tokens for the common context will 7524 be adjusted instead. 7525 """ 7526 # TODO: Custom token caps! 7527 state = self.getSituation().state 7528 if inCommon: 7529 context = state['common'] 7530 else: 7531 context = state['contexts'][state['activeContext']] 7532 tokens = context['capabilities']['tokens'] 7533 tokens[tokenType] = tokens.get(tokenType, 0) + amount
Modifies the current game state to add the specified number of
Token
s of the given type to the player's tokens. No changes are
made to the current graph. Reduce the number of tokens by
supplying a negative amount; note that negative token amounts
are possible.
By default, the number of tokens for the current active
FocalContext
will be adjusted. However, if inCommon
is set
to True
, then the number of tokens for the common context will
be adjusted instead.
7535 def setTokensNow( 7536 self, 7537 tokenType: base.Token, 7538 amount: int, 7539 inCommon: bool = False 7540 ) -> None: 7541 """ 7542 Modifies the current game state to set number of `Token`s of the 7543 given type to a specific amount, regardless of the old value. No 7544 changes are made to the current graph. 7545 7546 By default this sets the number of tokens for the active 7547 `FocalContext`. But if you set `inCommon` to `True`, it will 7548 set the number of tokens in the common context instead. 7549 """ 7550 # TODO: Custom token caps! 7551 state = self.getSituation().state 7552 if inCommon: 7553 context = state['common'] 7554 else: 7555 context = state['contexts'][state['activeContext']] 7556 context['capabilities']['tokens'][tokenType] = amount
Modifies the current game state to set number of Token
s of the
given type to a specific amount, regardless of the old value. No
changes are made to the current graph.
By default this sets the number of tokens for the active
FocalContext
. But if you set inCommon
to True
, it will
set the number of tokens in the common context instead.
7558 def lookupMechanism( 7559 self, 7560 mechanism: base.MechanismName, 7561 step: Optional[int] = None, 7562 where: Union[ 7563 Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]], 7564 Collection[base.AnyDecisionSpecifier], 7565 None 7566 ] = None 7567 ) -> base.MechanismID: 7568 """ 7569 Looks up a mechanism ID by name, in the graph for the specified 7570 step. The `where` argument specifies where to start looking, 7571 which helps disambiguate. It can be a tuple with a decision 7572 specifier and `None` to start from a single decision, or with a 7573 decision specifier and a transition name to start from either 7574 end of that transition. It can also be `None` to look at global 7575 mechanisms and then all decisions directly, although this 7576 increases the chance of a `MechanismCollisionError`. Finally, it 7577 can be some other non-tuple collection of decision specifiers to 7578 start from that set. 7579 7580 If no step is specified, uses the current step. 7581 """ 7582 if step is None: 7583 step = -1 7584 situation = self.getSituation(step) 7585 graph = situation.graph 7586 searchFrom: Collection[base.AnyDecisionSpecifier] 7587 if where is None: 7588 searchFrom = set() 7589 elif isinstance(where, tuple): 7590 if len(where) != 2: 7591 raise ValueError( 7592 f"Mechanism lookup location was a tuple with an" 7593 f" invalid length (must be length-2 if it's a" 7594 f" tuple):\n {repr(where)}" 7595 ) 7596 where = cast( 7597 Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]], 7598 where 7599 ) 7600 if where[1] is None: 7601 searchFrom = {graph.resolveDecision(where[0])} 7602 else: 7603 searchFrom = graph.bothEnds(where[0], where[1]) 7604 else: # must be a collection of specifiers 7605 searchFrom = cast(Collection[base.AnyDecisionSpecifier], where) 7606 return graph.lookupMechanism(searchFrom, mechanism)
Looks up a mechanism ID by name, in the graph for the specified
step. The where
argument specifies where to start looking,
which helps disambiguate. It can be a tuple with a decision
specifier and None
to start from a single decision, or with a
decision specifier and a transition name to start from either
end of that transition. It can also be None
to look at global
mechanisms and then all decisions directly, although this
increases the chance of a MechanismCollisionError
. Finally, it
can be some other non-tuple collection of decision specifiers to
start from that set.
If no step is specified, uses the current step.
7608 def mechanismState( 7609 self, 7610 mechanism: base.AnyMechanismSpecifier, 7611 where: Optional[Set[base.DecisionID]] = None, 7612 step: int = -1 7613 ) -> Optional[base.MechanismState]: 7614 """ 7615 Returns the current state for the specified mechanism (or the 7616 state at the specified step if a step index is given). `where` 7617 may be provided as a set of decision IDs to indicate where to 7618 search for the named mechanism, or a mechanism ID may be provided 7619 in the first place. Mechanism states are properties of a `State` 7620 but are not associated with focal contexts. 7621 """ 7622 situation = self.getSituation(step) 7623 mID = situation.graph.resolveMechanism(mechanism, startFrom=where) 7624 return situation.state['mechanisms'].get( 7625 mID, 7626 base.DEFAULT_MECHANISM_STATE 7627 )
Returns the current state for the specified mechanism (or the
state at the specified step if a step index is given). where
may be provided as a set of decision IDs to indicate where to
search for the named mechanism, or a mechanism ID may be provided
in the first place. Mechanism states are properties of a State
but are not associated with focal contexts.
7629 def setMechanismStateNow( 7630 self, 7631 mechanism: base.AnyMechanismSpecifier, 7632 toState: base.MechanismState, 7633 where: Optional[Set[base.DecisionID]] = None 7634 ) -> None: 7635 """ 7636 Sets the state of the specified mechanism to the specified 7637 state. Mechanisms can only be in one state at once, so this 7638 removes any previous states for that mechanism (note that via 7639 equivalences multiple mechanism states can count as active). 7640 7641 The mechanism can be any kind of mechanism specifier (see 7642 `base.AnyMechanismSpecifier`). If it's not a mechanism ID and 7643 doesn't have its own position information, the 'where' argument 7644 can be used to hint where to search for the mechanism. 7645 """ 7646 now = self.getSituation() 7647 mID = now.graph.resolveMechanism(mechanism, startFrom=where) 7648 if mID is None: 7649 raise MissingMechanismError( 7650 f"Couldn't find mechanism for {repr(mechanism)}." 7651 ) 7652 now.state['mechanisms'][mID] = toState
Sets the state of the specified mechanism to the specified state. Mechanisms can only be in one state at once, so this removes any previous states for that mechanism (note that via equivalences multiple mechanism states can count as active).
The mechanism can be any kind of mechanism specifier (see
base.AnyMechanismSpecifier
). If it's not a mechanism ID and
doesn't have its own position information, the 'where' argument
can be used to hint where to search for the mechanism.
7654 def skillLevel( 7655 self, 7656 skill: base.Skill, 7657 step: Optional[int] = None 7658 ) -> Optional[base.Level]: 7659 """ 7660 Returns the skill level the player had in a given skill at a 7661 given step, or for the current step if no step is specified. 7662 Returns `None` if the player had never acquired or lost levels 7663 in that skill before the specified step (skill level would count 7664 as 0 in that case). 7665 7666 This method adds together levels from the common and active 7667 focal contexts. 7668 """ 7669 if step is None: 7670 step = -1 7671 state = self.getSituation(step).state 7672 commonContext = state['common'] 7673 activeContext = state['contexts'][state['activeContext']] 7674 base = commonContext['capabilities']['skills'].get(skill) 7675 if base is None: 7676 return activeContext['capabilities']['skills'].get(skill) 7677 else: 7678 return base + activeContext['capabilities']['skills'].get( 7679 skill, 7680 0 7681 )
Returns the skill level the player had in a given skill at a
given step, or for the current step if no step is specified.
Returns None
if the player had never acquired or lost levels
in that skill before the specified step (skill level would count
as 0 in that case).
This method adds together levels from the common and active focal contexts.
7683 def adjustSkillLevelNow( 7684 self, 7685 skill: base.Skill, 7686 levels: base.Level, 7687 inCommon: bool = False 7688 ) -> None: 7689 """ 7690 Modifies the current game state to add the specified number of 7691 `Level`s of the given skill. No changes are made to the current 7692 graph. Reduce the skill level by supplying negative levels; note 7693 that negative skill levels are possible. 7694 7695 By default, the skill level for the current active 7696 `FocalContext` will be adjusted. However, if `inCommon` is set 7697 to `True`, then the skill level for the common context will be 7698 adjusted instead. 7699 """ 7700 # TODO: Custom level caps? 7701 state = self.getSituation().state 7702 if inCommon: 7703 context = state['common'] 7704 else: 7705 context = state['contexts'][state['activeContext']] 7706 skills = context['capabilities']['skills'] 7707 skills[skill] = skills.get(skill, 0) + levels
Modifies the current game state to add the specified number of
Level
s of the given skill. No changes are made to the current
graph. Reduce the skill level by supplying negative levels; note
that negative skill levels are possible.
By default, the skill level for the current active
FocalContext
will be adjusted. However, if inCommon
is set
to True
, then the skill level for the common context will be
adjusted instead.
7709 def setSkillLevelNow( 7710 self, 7711 skill: base.Skill, 7712 level: base.Level, 7713 inCommon: bool = False 7714 ) -> None: 7715 """ 7716 Modifies the current game state to set `Skill` `Level` for the 7717 given skill, regardless of the old value. No changes are made to 7718 the current graph. 7719 7720 By default this sets the skill level for the active 7721 `FocalContext`. But if you set `inCommon` to `True`, it will set 7722 the skill level in the common context instead. 7723 """ 7724 # TODO: Custom level caps? 7725 state = self.getSituation().state 7726 if inCommon: 7727 context = state['common'] 7728 else: 7729 context = state['contexts'][state['activeContext']] 7730 skills = context['capabilities']['skills'] 7731 skills[skill] = level
Modifies the current game state to set Skill
Level
for the
given skill, regardless of the old value. No changes are made to
the current graph.
By default this sets the skill level for the active
FocalContext
. But if you set inCommon
to True
, it will set
the skill level in the common context instead.
7733 def updateRequirementNow( 7734 self, 7735 decision: base.AnyDecisionSpecifier, 7736 transition: base.Transition, 7737 requirement: Optional[base.Requirement] 7738 ) -> None: 7739 """ 7740 Updates the requirement for a specific transition in a specific 7741 decision. Use `None` to remove the requirement for that edge. 7742 """ 7743 if requirement is None: 7744 requirement = base.ReqNothing() 7745 self.getSituation().graph.setTransitionRequirement( 7746 decision, 7747 transition, 7748 requirement 7749 )
Updates the requirement for a specific transition in a specific
decision. Use None
to remove the requirement for that edge.
7751 def isTraversable( 7752 self, 7753 decision: base.AnyDecisionSpecifier, 7754 transition: base.Transition, 7755 step: int = -1 7756 ) -> bool: 7757 """ 7758 Returns True if the specified transition from the specified 7759 decision had its requirement satisfied by the game state at the 7760 specified step (or at the current step if no step is specified). 7761 Raises an `IndexError` if the specified step doesn't exist, and 7762 a `KeyError` if the decision or transition specified does not 7763 exist in the `DecisionGraph` at that step. 7764 """ 7765 situation = self.getSituation(step) 7766 req = situation.graph.getTransitionRequirement(decision, transition) 7767 ctx = base.contextForTransition(situation, decision, transition) 7768 fromID = situation.graph.resolveDecision(decision) 7769 return ( 7770 req.satisfied(ctx) 7771 and (fromID, transition) not in situation.state['deactivated'] 7772 )
Returns True if the specified transition from the specified
decision had its requirement satisfied by the game state at the
specified step (or at the current step if no step is specified).
Raises an IndexError
if the specified step doesn't exist, and
a KeyError
if the decision or transition specified does not
exist in the DecisionGraph
at that step.
7774 def applyTransitionEffect( 7775 self, 7776 whichEffect: base.EffectSpecifier, 7777 moveWhich: Optional[base.FocalPointName] = None 7778 ) -> Optional[base.DecisionID]: 7779 """ 7780 Applies an effect attached to a transition, taking charges and 7781 delay into account based on the current `Situation`. 7782 Modifies the effect's trigger count (but may not actually 7783 trigger the effect if the charges and/or delay values indicate 7784 not to; see `base.doTriggerEffect`). 7785 7786 If a specific focal point in a plural-focalized domain is 7787 triggering the effect, the focal point name should be specified 7788 via `moveWhich` so that goto `Effect`s can know which focal 7789 point to move when it's not explicitly specified in the effect. 7790 TODO: Test this! 7791 7792 Returns None most of the time, but if a 'goto', 'bounce', or 7793 'follow' effect was applied, it returns the decision ID for that 7794 effect's destination, which would override a transition's normal 7795 destination. If it returns a destination ID, then the exploration 7796 state will already have been updated to set the position there, 7797 and further position updates are not needed. 7798 7799 Note that transition effects which update active decisions will 7800 also update the exploration status of those decisions to 7801 'exploring' if they had been in an unvisited status (see 7802 `updatePosition` and `hasBeenVisited`). 7803 7804 Note: callers should immediately update situation-based variables 7805 that might have been changes by a 'revert' effect. 7806 """ 7807 now = self.getSituation() 7808 effect, triggerCount = base.doTriggerEffect(now, whichEffect) 7809 if triggerCount is not None: 7810 return self.applyExtraneousEffect( 7811 effect, 7812 where=whichEffect[:2], 7813 moveWhich=moveWhich 7814 ) 7815 else: 7816 return None
Applies an effect attached to a transition, taking charges and
delay into account based on the current Situation
.
Modifies the effect's trigger count (but may not actually
trigger the effect if the charges and/or delay values indicate
not to; see base.doTriggerEffect
).
If a specific focal point in a plural-focalized domain is
triggering the effect, the focal point name should be specified
via moveWhich
so that goto Effect
s can know which focal
point to move when it's not explicitly specified in the effect.
TODO: Test this!
Returns None most of the time, but if a 'goto', 'bounce', or 'follow' effect was applied, it returns the decision ID for that effect's destination, which would override a transition's normal destination. If it returns a destination ID, then the exploration state will already have been updated to set the position there, and further position updates are not needed.
Note that transition effects which update active decisions will
also update the exploration status of those decisions to
'exploring' if they had been in an unvisited status (see
updatePosition
and hasBeenVisited
).
Note: callers should immediately update situation-based variables that might have been changes by a 'revert' effect.
7818 def applyExtraneousEffect( 7819 self, 7820 effect: base.Effect, 7821 where: Optional[ 7822 Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]] 7823 ] = None, 7824 moveWhich: Optional[base.FocalPointName] = None, 7825 challengePolicy: base.ChallengePolicy = "specified" 7826 ) -> Optional[base.DecisionID]: 7827 """ 7828 Applies a single extraneous effect to the state & graph, 7829 *without* accounting for charges or delay values, since the 7830 effect is not part of the graph (use `applyTransitionEffect` to 7831 apply effects that are attached to transitions, which is almost 7832 always the function you should be using). An associated 7833 transition for the extraneous effect can be supplied using the 7834 `where` argument, and effects like 'deactivate' and 'edit' will 7835 affect it (but the effect's charges and delay values will still 7836 be ignored). 7837 7838 If the effect would change the destination of a transition, the 7839 altered destination ID is returned: 'bounce' effects return the 7840 provided decision part of `where`, 'goto' effects return their 7841 target, and 'follow' effects return the destination followed to 7842 (possibly via chained follows in the extreme case). In all other 7843 cases, `None` is returned indicating no change to a normal 7844 destination. 7845 7846 If a specific focal point in a plural-focalized domain is 7847 triggering the effect, the focal point name should be specified 7848 via `moveWhich` so that goto `Effect`s can know which focal 7849 point to move when it's not explicitly specified in the effect. 7850 TODO: Test this! 7851 7852 Note that transition effects which update active decisions will 7853 also update the exploration status of those decisions to 7854 'exploring' if they had been in an unvisited status and will 7855 remove any 'unconfirmed' tag they might still have (see 7856 `updatePosition` and `hasBeenVisited`). 7857 7858 The given `challengePolicy` is applied when traversing further 7859 transitions due to 'follow' effects. 7860 7861 Note: Anyone calling `applyExtraneousEffect` should update any 7862 situation-based variables immediately after the call, as a 7863 'revert' effect may have changed the current graph and/or state. 7864 """ 7865 typ = effect['type'] 7866 value = effect['value'] 7867 applyTo = effect['applyTo'] 7868 inCommon = applyTo == 'common' 7869 7870 now = self.getSituation() 7871 7872 if where is not None: 7873 if where[1] is not None: 7874 searchFrom = now.graph.bothEnds(where[0], where[1]) 7875 else: 7876 searchFrom = {now.graph.resolveDecision(where[0])} 7877 else: 7878 searchFrom = None 7879 7880 # Note: Delay and charges are ignored! 7881 7882 if typ in ("gain", "lose"): 7883 value = cast( 7884 Union[ 7885 base.Capability, 7886 Tuple[base.Token, base.TokenCount], 7887 Tuple[Literal['skill'], base.Skill, base.Level], 7888 ], 7889 value 7890 ) 7891 if isinstance(value, base.Capability): 7892 if typ == "gain": 7893 self.gainCapabilityNow(value, inCommon) 7894 else: 7895 self.loseCapabilityNow(value, inCommon) 7896 elif len(value) == 2: # must be a token, amount pair 7897 token, amount = cast( 7898 Tuple[base.Token, base.TokenCount], 7899 value 7900 ) 7901 if typ == "lose": 7902 amount *= -1 7903 self.adjustTokensNow(token, amount, inCommon) 7904 else: # must be a 'skill', skill, level triple 7905 _, skill, levels = cast( 7906 Tuple[Literal['skill'], base.Skill, base.Level], 7907 value 7908 ) 7909 if typ == "lose": 7910 levels *= -1 7911 self.adjustSkillLevelNow(skill, levels, inCommon) 7912 7913 elif typ == "set": 7914 value = cast( 7915 Union[ 7916 Tuple[base.Token, base.TokenCount], 7917 Tuple[base.AnyMechanismSpecifier, base.MechanismState], 7918 Tuple[Literal['skill'], base.Skill, base.Level], 7919 ], 7920 value 7921 ) 7922 if len(value) == 2: # must be a token or mechanism pair 7923 if isinstance(value[1], base.TokenCount): # token 7924 token, amount = cast( 7925 Tuple[base.Token, base.TokenCount], 7926 value 7927 ) 7928 self.setTokensNow(token, amount, inCommon) 7929 else: # mechanism 7930 mechanism, state = cast( 7931 Tuple[ 7932 base.AnyMechanismSpecifier, 7933 base.MechanismState 7934 ], 7935 value 7936 ) 7937 self.setMechanismStateNow(mechanism, state, searchFrom) 7938 else: # must be a 'skill', skill, level triple 7939 _, skill, level = cast( 7940 Tuple[Literal['skill'], base.Skill, base.Level], 7941 value 7942 ) 7943 self.setSkillLevelNow(skill, level, inCommon) 7944 7945 elif typ == "toggle": 7946 # Length-1 list just toggles a capability on/off based on current 7947 # state (not attending to equivalents): 7948 if isinstance(value, List): # capabilities list 7949 value = cast(List[base.Capability], value) 7950 if len(value) == 0: 7951 raise ValueError( 7952 "Toggle effect has empty capabilities list." 7953 ) 7954 if len(value) == 1: 7955 capability = value[0] 7956 if self.hasCapability(capability, inCommon=False): 7957 self.loseCapabilityNow(capability, inCommon=False) 7958 else: 7959 self.gainCapabilityNow(capability) 7960 else: 7961 # Otherwise toggle all powers off, then one on, 7962 # based on the first capability that's currently on. 7963 # Note we do NOT count equivalences. 7964 7965 # Find first capability that's on: 7966 firstIndex: Optional[int] = None 7967 for i, capability in enumerate(value): 7968 if self.hasCapability(capability): 7969 firstIndex = i 7970 break 7971 7972 # Turn them all off: 7973 for capability in value: 7974 self.loseCapabilityNow(capability, inCommon=False) 7975 # TODO: inCommon for the check? 7976 7977 if firstIndex is None: 7978 self.gainCapabilityNow(value[0]) 7979 else: 7980 self.gainCapabilityNow( 7981 value[(firstIndex + 1) % len(value)] 7982 ) 7983 else: # must be a mechanism w/ states list 7984 mechanism, states = cast( 7985 Tuple[ 7986 base.AnyMechanismSpecifier, 7987 List[base.MechanismState] 7988 ], 7989 value 7990 ) 7991 currentState = self.mechanismState(mechanism, where=searchFrom) 7992 if len(states) == 1: 7993 if currentState == states[0]: 7994 # default alternate state 7995 self.setMechanismStateNow( 7996 mechanism, 7997 base.DEFAULT_MECHANISM_STATE, 7998 searchFrom 7999 ) 8000 else: 8001 self.setMechanismStateNow( 8002 mechanism, 8003 states[0], 8004 searchFrom 8005 ) 8006 else: 8007 # Find our position in the list, if any 8008 try: 8009 currentIndex = states.index(cast(str, currentState)) 8010 # Cast here just because we know that None will 8011 # raise a ValueError but we'll catch it, and we 8012 # want to suppress the mypy warning about the 8013 # option 8014 except ValueError: 8015 currentIndex = len(states) - 1 8016 # Set next state in list as current state 8017 nextIndex = (currentIndex + 1) % len(states) 8018 self.setMechanismStateNow( 8019 mechanism, 8020 states[nextIndex], 8021 searchFrom 8022 ) 8023 8024 elif typ == "deactivate": 8025 if where is None or where[1] is None: 8026 raise ValueError( 8027 "Can't apply a deactivate effect without specifying" 8028 " which transition it applies to." 8029 ) 8030 8031 decision, transition = cast( 8032 Tuple[base.AnyDecisionSpecifier, base.Transition], 8033 where 8034 ) 8035 8036 dID = now.graph.resolveDecision(decision) 8037 now.state['deactivated'].add((dID, transition)) 8038 8039 elif typ == "edit": 8040 value = cast(List[List[commands.Command]], value) 8041 # If there are no blocks, do nothing 8042 if len(value) > 0: 8043 # Apply the first block of commands and then rotate the list 8044 scope: commands.Scope = {} 8045 if where is not None: 8046 here: base.DecisionID = now.graph.resolveDecision( 8047 where[0] 8048 ) 8049 outwards: Optional[base.Transition] = where[1] 8050 scope['@'] = here 8051 scope['@t'] = outwards 8052 if outwards is not None: 8053 reciprocal = now.graph.getReciprocal(here, outwards) 8054 destination = now.graph.getDestination(here, outwards) 8055 else: 8056 reciprocal = None 8057 destination = None 8058 scope['@r'] = reciprocal 8059 scope['@d'] = destination 8060 self.runCommandBlock(value[0], scope) 8061 value.append(value.pop(0)) 8062 8063 elif typ == "goto": 8064 if isinstance(value, base.DecisionSpecifier): 8065 target: base.AnyDecisionSpecifier = value 8066 # use moveWhich provided as argument 8067 elif isinstance(value, tuple): 8068 target, moveWhich = cast( 8069 Tuple[base.AnyDecisionSpecifier, base.FocalPointName], 8070 value 8071 ) 8072 else: 8073 target = cast(base.AnyDecisionSpecifier, value) 8074 # use moveWhich provided as argument 8075 8076 destID = now.graph.resolveDecision(target) 8077 base.updatePosition(now, destID, applyTo, moveWhich) 8078 return destID 8079 8080 elif typ == "bounce": 8081 # Just need to let the caller know they should cancel 8082 if where is None: 8083 raise ValueError( 8084 "Can't apply a 'bounce' effect without a position" 8085 " to apply it from." 8086 ) 8087 return now.graph.resolveDecision(where[0]) 8088 8089 elif typ == "follow": 8090 if where is None: 8091 raise ValueError( 8092 f"Can't follow transition {value!r} because there" 8093 f" is no position information when applying the" 8094 f" effect." 8095 ) 8096 if where[1] is not None: 8097 followFrom = now.graph.getDestination(where[0], where[1]) 8098 if followFrom is None: 8099 raise ValueError( 8100 f"Can't follow transition {value!r} because the" 8101 f" position information specifies transition" 8102 f" {where[1]!r} from decision" 8103 f" {now.graph.identityOf(where[0])} but that" 8104 f" transition does not exist." 8105 ) 8106 else: 8107 followFrom = now.graph.resolveDecision(where[0]) 8108 8109 following = cast(base.Transition, value) 8110 8111 followTo = now.graph.getDestination(followFrom, following) 8112 8113 if followTo is None: 8114 raise ValueError( 8115 f"Can't follow transition {following!r} because" 8116 f" that transition doesn't exist at the specified" 8117 f" destination {now.graph.identityOf(followFrom)}." 8118 ) 8119 8120 if self.isTraversable(followFrom, following): # skip if not 8121 # Perform initial position update before following new 8122 # transition: 8123 base.updatePosition( 8124 now, 8125 followFrom, 8126 applyTo, 8127 moveWhich 8128 ) 8129 8130 # Apply consequences of followed transition 8131 fullFollowTo = self.applyTransitionConsequence( 8132 followFrom, 8133 following, 8134 moveWhich, 8135 challengePolicy 8136 ) 8137 8138 # Now update to end of followed transition 8139 if fullFollowTo is None: 8140 base.updatePosition( 8141 now, 8142 followTo, 8143 applyTo, 8144 moveWhich 8145 ) 8146 fullFollowTo = followTo 8147 8148 # Skip the normal update: we've taken care of that plus more 8149 return fullFollowTo 8150 else: 8151 # Normal position updates still applies since follow 8152 # transition wasn't possible 8153 return None 8154 8155 elif typ == "save": 8156 assert isinstance(value, base.SaveSlot) 8157 now.saves[value] = copy.deepcopy((now.graph, now.state)) 8158 8159 else: 8160 raise ValueError(f"Invalid effect type {typ!r}.") 8161 8162 return None # default return value if we didn't return above
Applies a single extraneous effect to the state & graph,
without accounting for charges or delay values, since the
effect is not part of the graph (use applyTransitionEffect
to
apply effects that are attached to transitions, which is almost
always the function you should be using). An associated
transition for the extraneous effect can be supplied using the
where
argument, and effects like 'deactivate' and 'edit' will
affect it (but the effect's charges and delay values will still
be ignored).
If the effect would change the destination of a transition, the
altered destination ID is returned: 'bounce' effects return the
provided decision part of where
, 'goto' effects return their
target, and 'follow' effects return the destination followed to
(possibly via chained follows in the extreme case). In all other
cases, None
is returned indicating no change to a normal
destination.
If a specific focal point in a plural-focalized domain is
triggering the effect, the focal point name should be specified
via moveWhich
so that goto Effect
s can know which focal
point to move when it's not explicitly specified in the effect.
TODO: Test this!
Note that transition effects which update active decisions will
also update the exploration status of those decisions to
'exploring' if they had been in an unvisited status and will
remove any 'unconfirmed' tag they might still have (see
updatePosition
and hasBeenVisited
).
The given challengePolicy
is applied when traversing further
transitions due to 'follow' effects.
Note: Anyone calling applyExtraneousEffect
should update any
situation-based variables immediately after the call, as a
'revert' effect may have changed the current graph and/or state.
8164 def applyExtraneousConsequence( 8165 self, 8166 consequence: base.Consequence, 8167 where: Optional[ 8168 Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]] 8169 ] = None, 8170 moveWhich: Optional[base.FocalPointName] = None 8171 ) -> Optional[base.DecisionID]: 8172 """ 8173 Applies an extraneous consequence not associated with a 8174 transition. Unlike `applyTransitionConsequence`, the provided 8175 `base.Consequence` must already have observed outcomes (see 8176 `base.observeChallengeOutcomes`). Returns the decision ID for a 8177 decision implied by a goto, follow, or bounce effect, or `None` 8178 if no effect implies a destination. 8179 8180 The `where` and `moveWhich` optional arguments specify which 8181 decision and/or transition to use as the application position, 8182 and/or which focal point to move. This affects mechanism lookup 8183 as well as the end position when 'follow' effects are used. 8184 Specifically: 8185 8186 - A 'follow' trigger will search for transitions to follow from 8187 the destination of the specified transition, or if only a 8188 decision was supplied, from that decision. 8189 - Mechanism lookups will start with both ends of the specified 8190 transition as their search field (or with just the specified 8191 decision if no transition is included). 8192 8193 'bounce' effects will cause an error unless position information 8194 is provided, and will set the position to the base decision 8195 provided in `where`. 8196 8197 Note: callers should update any situation-based variables 8198 immediately after calling this as a 'revert' effect could change 8199 the current graph and/or state and other changes could get lost 8200 if they get applied to a stale graph/state. 8201 8202 # TODO: Examples for goto and follow effects. 8203 """ 8204 now = self.getSituation() 8205 searchFrom = set() 8206 if where is not None: 8207 if where[1] is not None: 8208 searchFrom = now.graph.bothEnds(where[0], where[1]) 8209 else: 8210 searchFrom = {now.graph.resolveDecision(where[0])} 8211 8212 context = base.RequirementContext( 8213 state=now.state, 8214 graph=now.graph, 8215 searchFrom=searchFrom 8216 ) 8217 8218 effectIndices = base.observedEffects(context, consequence) 8219 destID = None 8220 for index in effectIndices: 8221 effect = base.consequencePart(consequence, index) 8222 if not isinstance(effect, dict) or 'value' not in effect: 8223 raise RuntimeError( 8224 f"Invalid effect index {index}: Consequence part at" 8225 f" that index is not an Effect. Got:\n{effect}" 8226 ) 8227 effect = cast(base.Effect, effect) 8228 destID = self.applyExtraneousEffect( 8229 effect, 8230 where, 8231 moveWhich 8232 ) 8233 # technically this variable is not used later in this 8234 # function, but the `applyExtraneousEffect` call means it 8235 # needs an update, so we're doing that in case someone later 8236 # adds code to this function that uses 'now' after this 8237 # point. 8238 now = self.getSituation() 8239 8240 return destID
Applies an extraneous consequence not associated with a
transition. Unlike applyTransitionConsequence
, the provided
base.Consequence
must already have observed outcomes (see
base.observeChallengeOutcomes
). Returns the decision ID for a
decision implied by a goto, follow, or bounce effect, or None
if no effect implies a destination.
The where
and moveWhich
optional arguments specify which
decision and/or transition to use as the application position,
and/or which focal point to move. This affects mechanism lookup
as well as the end position when 'follow' effects are used.
Specifically:
- A 'follow' trigger will search for transitions to follow from the destination of the specified transition, or if only a decision was supplied, from that decision.
- Mechanism lookups will start with both ends of the specified transition as their search field (or with just the specified decision if no transition is included).
'bounce' effects will cause an error unless position information
is provided, and will set the position to the base decision
provided in where
.
Note: callers should update any situation-based variables immediately after calling this as a 'revert' effect could change the current graph and/or state and other changes could get lost if they get applied to a stale graph/state.
TODO: Examples for goto and follow effects.
8242 def applyTransitionConsequence( 8243 self, 8244 decision: base.AnyDecisionSpecifier, 8245 transition: base.AnyTransition, 8246 moveWhich: Optional[base.FocalPointName] = None, 8247 policy: base.ChallengePolicy = "specified", 8248 fromIndex: Optional[int] = None, 8249 toIndex: Optional[int] = None 8250 ) -> Optional[base.DecisionID]: 8251 """ 8252 Applies the effects of the specified transition to the current 8253 graph and state, possibly overriding observed outcomes using 8254 outcomes specified as part of a `base.TransitionWithOutcomes`. 8255 8256 The `where` and `moveWhich` function serve the same purpose as 8257 for `applyExtraneousEffect`. If `where` is `None`, then the 8258 effects will be applied as extraneous effects, meaning that 8259 their delay and charges values will be ignored and their trigger 8260 count will not be tracked. If `where` is supplied 8261 8262 Returns either None to indicate that the position update for the 8263 transition should apply as usual, or a decision ID indicating 8264 another destination which has already been applied by a 8265 transition effect. 8266 8267 If `fromIndex` and/or `toIndex` are specified, then only effects 8268 which have indices between those two (inclusive) will be 8269 applied, and other effects will neither apply nor be updated in 8270 any way. Note that `onlyPart` does not override the challenge 8271 policy: if the effects in the specified part are not applied due 8272 to a challenge outcome, they still won't happen, including 8273 challenge outcomes outside of that part. Also, outcomes for 8274 challenges of the entire consequence are re-observed if the 8275 challenge policy implies it. 8276 8277 Note: Anyone calling this should update any situation-based 8278 variables immediately after the call, as a 'revert' effect may 8279 have changed the current graph and/or state. 8280 """ 8281 now = self.getSituation() 8282 dID = now.graph.resolveDecision(decision) 8283 8284 transitionName, outcomes = base.nameAndOutcomes(transition) 8285 8286 searchFrom = set() 8287 searchFrom = now.graph.bothEnds(dID, transitionName) 8288 8289 context = base.RequirementContext( 8290 state=now.state, 8291 graph=now.graph, 8292 searchFrom=searchFrom 8293 ) 8294 8295 consequence = now.graph.getConsequence(dID, transitionName) 8296 8297 # Make sure that challenge outcomes are known 8298 if policy != "specified": 8299 base.resetChallengeOutcomes(consequence) 8300 useUp = outcomes[:] 8301 base.observeChallengeOutcomes( 8302 context, 8303 consequence, 8304 location=searchFrom, 8305 policy=policy, 8306 knownOutcomes=useUp 8307 ) 8308 if len(useUp) > 0: 8309 raise ValueError( 8310 f"More outcomes specified than challenges observed in" 8311 f" consequence:\n{consequence}" 8312 f"\nRemaining outcomes:\n{useUp}" 8313 ) 8314 8315 # Figure out which effects apply, and apply each of them 8316 effectIndices = base.observedEffects(context, consequence) 8317 if fromIndex is None: 8318 fromIndex = 0 8319 8320 altDest = None 8321 for index in effectIndices: 8322 if ( 8323 index >= fromIndex 8324 and (toIndex is None or index <= toIndex) 8325 ): 8326 thisDest = self.applyTransitionEffect( 8327 (dID, transitionName, index), 8328 moveWhich 8329 ) 8330 if thisDest is not None: 8331 altDest = thisDest 8332 # TODO: What if this updates state with 'revert' to a 8333 # graph that doesn't contain the same effects? 8334 # TODO: Update 'now' and 'context'?! 8335 return altDest
Applies the effects of the specified transition to the current
graph and state, possibly overriding observed outcomes using
outcomes specified as part of a base.TransitionWithOutcomes
.
The where
and moveWhich
function serve the same purpose as
for applyExtraneousEffect
. If where
is None
, then the
effects will be applied as extraneous effects, meaning that
their delay and charges values will be ignored and their trigger
count will not be tracked. If where
is supplied
Returns either None to indicate that the position update for the transition should apply as usual, or a decision ID indicating another destination which has already been applied by a transition effect.
If fromIndex
and/or toIndex
are specified, then only effects
which have indices between those two (inclusive) will be
applied, and other effects will neither apply nor be updated in
any way. Note that onlyPart
does not override the challenge
policy: if the effects in the specified part are not applied due
to a challenge outcome, they still won't happen, including
challenge outcomes outside of that part. Also, outcomes for
challenges of the entire consequence are re-observed if the
challenge policy implies it.
Note: Anyone calling this should update any situation-based variables immediately after the call, as a 'revert' effect may have changed the current graph and/or state.
8337 def allDecisions(self) -> List[base.DecisionID]: 8338 """ 8339 Returns the list of all decisions which existed at any point 8340 within the exploration. Example: 8341 8342 >>> ex = DiscreteExploration() 8343 >>> ex.start('A') 8344 0 8345 >>> ex.observe('A', 'right') 8346 1 8347 >>> ex.explore('right', 'B', 'left') 8348 1 8349 >>> ex.observe('B', 'right') 8350 2 8351 >>> ex.allDecisions() # 'A', 'B', and the unnamed 'right of B' 8352 [0, 1, 2] 8353 """ 8354 seen = set() 8355 result = [] 8356 for situation in self: 8357 for decision in situation.graph: 8358 if decision not in seen: 8359 result.append(decision) 8360 seen.add(decision) 8361 8362 return result
Returns the list of all decisions which existed at any point within the exploration. Example:
>>> ex = DiscreteExploration()
>>> ex.start('A')
0
>>> ex.observe('A', 'right')
1
>>> ex.explore('right', 'B', 'left')
1
>>> ex.observe('B', 'right')
2
>>> ex.allDecisions() # 'A', 'B', and the unnamed 'right of B'
[0, 1, 2]
8364 def allExploredDecisions(self) -> List[base.DecisionID]: 8365 """ 8366 Returns the list of all decisions which existed at any point 8367 within the exploration, excluding decisions whose highest 8368 exploration status was `noticed` or lower. May still include 8369 decisions which don't exist in the final situation's graph due to 8370 things like decision merging. Example: 8371 8372 >>> ex = DiscreteExploration() 8373 >>> ex.start('A') 8374 0 8375 >>> ex.observe('A', 'right') 8376 1 8377 >>> ex.explore('right', 'B', 'left') 8378 1 8379 >>> ex.observe('B', 'right') 8380 2 8381 >>> graph = ex.getSituation().graph 8382 >>> graph.addDecision('C') # add isolated decision; doesn't set status 8383 3 8384 >>> ex.hasBeenVisited('C') 8385 False 8386 >>> ex.allExploredDecisions() 8387 [0, 1] 8388 >>> ex.setExplorationStatus('C', 'exploring') 8389 >>> ex.allExploredDecisions() # 2 is the decision right from 'B' 8390 [0, 1, 3] 8391 >>> ex.setExplorationStatus('A', 'explored') 8392 >>> ex.allExploredDecisions() 8393 [0, 1, 3] 8394 >>> ex.setExplorationStatus('A', 'unknown') 8395 >>> # remains visisted in an earlier step 8396 >>> ex.allExploredDecisions() 8397 [0, 1, 3] 8398 >>> ex.setExplorationStatus('C', 'unknown') # not explored earlier 8399 >>> ex.allExploredDecisions() # 2 is the decision right from 'B' 8400 [0, 1] 8401 """ 8402 seen = set() 8403 result = [] 8404 for situation in self: 8405 graph = situation.graph 8406 for decision in graph: 8407 if ( 8408 decision not in seen 8409 and base.hasBeenVisited(situation, decision) 8410 ): 8411 result.append(decision) 8412 seen.add(decision) 8413 8414 return result
Returns the list of all decisions which existed at any point
within the exploration, excluding decisions whose highest
exploration status was noticed
or lower. May still include
decisions which don't exist in the final situation's graph due to
things like decision merging. Example:
>>> ex = DiscreteExploration()
>>> ex.start('A')
0
>>> ex.observe('A', 'right')
1
>>> ex.explore('right', 'B', 'left')
1
>>> ex.observe('B', 'right')
2
>>> graph = ex.getSituation().graph
>>> graph.addDecision('C') # add isolated decision; doesn't set status
3
>>> ex.hasBeenVisited('C')
False
>>> ex.allExploredDecisions()
[0, 1]
>>> ex.setExplorationStatus('C', 'exploring')
>>> ex.allExploredDecisions() # 2 is the decision right from 'B'
[0, 1, 3]
>>> ex.setExplorationStatus('A', 'explored')
>>> ex.allExploredDecisions()
[0, 1, 3]
>>> ex.setExplorationStatus('A', 'unknown')
>>> # remains visisted in an earlier step
>>> ex.allExploredDecisions()
[0, 1, 3]
>>> ex.setExplorationStatus('C', 'unknown') # not explored earlier
>>> ex.allExploredDecisions() # 2 is the decision right from 'B'
[0, 1]
8416 def allVisitedDecisions(self) -> List[base.DecisionID]: 8417 """ 8418 Returns the list of all decisions which existed at any point 8419 within the exploration and which were visited at least once. 8420 Usually all of these will be present in the final situation's 8421 graph, but sometimes merging or other factors means there might 8422 be some that won't be. Being present on the game state's 'active' 8423 list in a step for its domain is what counts as "being visited," 8424 which means that nodes which were passed through directly via a 8425 'follow' effect won't be counted, for example. 8426 8427 This should usually correspond with the absence of the 8428 'unconfirmed' tag. 8429 8430 Example: 8431 8432 >>> ex = DiscreteExploration() 8433 >>> ex.start('A') 8434 0 8435 >>> ex.observe('A', 'right') 8436 1 8437 >>> ex.explore('right', 'B', 'left') 8438 1 8439 >>> ex.observe('B', 'right') 8440 2 8441 >>> ex.getSituation().graph.addDecision('C') # add isolated decision 8442 3 8443 >>> av = ex.allVisitedDecisions() 8444 >>> av 8445 [0, 1] 8446 >>> all( # no decisions in the 'visited' list are tagged 8447 ... 'unconfirmed' not in ex.getSituation().graph.decisionTags(d) 8448 ... for d in av 8449 ... ) 8450 True 8451 >>> graph = ex.getSituation().graph 8452 >>> 'unconfirmed' in graph.decisionTags(0) 8453 False 8454 >>> 'unconfirmed' in graph.decisionTags(1) 8455 False 8456 >>> 'unconfirmed' in graph.decisionTags(2) 8457 True 8458 >>> 'unconfirmed' in graph.decisionTags(3) # not tagged; not explored 8459 False 8460 """ 8461 seen = set() 8462 result = [] 8463 for step in range(len(self)): 8464 active = self.getActiveDecisions(step) 8465 for dID in active: 8466 if dID not in seen: 8467 result.append(dID) 8468 seen.add(dID) 8469 8470 return result
Returns the list of all decisions which existed at any point within the exploration and which were visited at least once. Usually all of these will be present in the final situation's graph, but sometimes merging or other factors means there might be some that won't be. Being present on the game state's 'active' list in a step for its domain is what counts as "being visited," which means that nodes which were passed through directly via a 'follow' effect won't be counted, for example.
This should usually correspond with the absence of the 'unconfirmed' tag.
Example:
>>> ex = DiscreteExploration()
>>> ex.start('A')
0
>>> ex.observe('A', 'right')
1
>>> ex.explore('right', 'B', 'left')
1
>>> ex.observe('B', 'right')
2
>>> ex.getSituation().graph.addDecision('C') # add isolated decision
3
>>> av = ex.allVisitedDecisions()
>>> av
[0, 1]
>>> all( # no decisions in the 'visited' list are tagged
... 'unconfirmed' not in ex.getSituation().graph.decisionTags(d)
... for d in av
... )
True
>>> graph = ex.getSituation().graph
>>> 'unconfirmed' in graph.decisionTags(0)
False
>>> 'unconfirmed' in graph.decisionTags(1)
False
>>> 'unconfirmed' in graph.decisionTags(2)
True
>>> 'unconfirmed' in graph.decisionTags(3) # not tagged; not explored
False
8472 def start( 8473 self, 8474 decision: base.AnyDecisionSpecifier, 8475 startCapabilities: Optional[base.CapabilitySet] = None, 8476 setMechanismStates: Optional[ 8477 Dict[base.MechanismID, base.MechanismState] 8478 ] = None, 8479 setCustomState: Optional[dict] = None, 8480 decisionType: base.DecisionType = "imposed" 8481 ) -> base.DecisionID: 8482 """ 8483 Sets the initial position information for a newly-relevant 8484 domain for the current focal context. Creates a new decision 8485 if the decision is specified by name or `DecisionSpecifier` and 8486 that decision doesn't already exist. Returns the decision ID for 8487 the newly-placed decision (or for the specified decision if it 8488 already existed). 8489 8490 Raises a `BadStart` error if the current focal context already 8491 has position information for the specified domain. 8492 8493 - The given `startCapabilities` replaces any existing 8494 capabilities for the current focal context, although you can 8495 leave it as the default `None` to avoid that and retain any 8496 capabilities that have been set up already. 8497 - The given `setMechanismStates` and `setCustomState` 8498 dictionaries override all previous mechanism states & custom 8499 states in the new situation. Leave these as the default 8500 `None` to maintain those states. 8501 - If created, the decision will be placed in the DEFAULT_DOMAIN 8502 domain unless it's specified as a `base.DecisionSpecifier` 8503 with a domain part, in which case that domain is used. 8504 - If specified as a `base.DecisionSpecifier` with a zone part 8505 and a new decision needs to be created, the decision will be 8506 added to that zone, creating it at level 0 if necessary, 8507 although otherwise no zone information will be changed. 8508 - Resets the decision type to "pending" and the action taken to 8509 `None`. Sets the decision type of the previous situation to 8510 'imposed' (or the specified `decisionType`) and sets an 8511 appropriate 'start' action for that situation. 8512 - Tags the step with 'start'. 8513 - Even in a plural- or spreading-focalized domain, you still need 8514 to pick one decision to start at. 8515 """ 8516 now = self.getSituation() 8517 8518 startID = now.graph.getDecision(decision) 8519 zone = None 8520 domain = base.DEFAULT_DOMAIN 8521 if startID is None: 8522 if isinstance(decision, base.DecisionID): 8523 raise MissingDecisionError( 8524 f"Cannot start at decision {decision} because no" 8525 f" decision with that ID exists. Supply a name or" 8526 f" DecisionSpecifier if you need the start decision" 8527 f" to be created automatically." 8528 ) 8529 elif isinstance(decision, base.DecisionName): 8530 decision = base.DecisionSpecifier( 8531 domain=None, 8532 zone=None, 8533 name=decision 8534 ) 8535 startID = now.graph.addDecision( 8536 decision.name, 8537 domain=decision.domain 8538 ) 8539 zone = decision.zone 8540 if decision.domain is not None: 8541 domain = decision.domain 8542 8543 if zone is not None: 8544 if now.graph.getZoneInfo(zone) is None: 8545 now.graph.createZone(zone, 0) 8546 now.graph.addDecisionToZone(startID, zone) 8547 8548 action: base.ExplorationAction = ( 8549 'start', 8550 startID, 8551 startID, 8552 domain, 8553 startCapabilities, 8554 setMechanismStates, 8555 setCustomState 8556 ) 8557 8558 self.advanceSituation(action, decisionType) 8559 8560 return startID
Sets the initial position information for a newly-relevant
domain for the current focal context. Creates a new decision
if the decision is specified by name or DecisionSpecifier
and
that decision doesn't already exist. Returns the decision ID for
the newly-placed decision (or for the specified decision if it
already existed).
Raises a BadStart
error if the current focal context already
has position information for the specified domain.
- The given
startCapabilities
replaces any existing capabilities for the current focal context, although you can leave it as the defaultNone
to avoid that and retain any capabilities that have been set up already. - The given
setMechanismStates
andsetCustomState
dictionaries override all previous mechanism states & custom states in the new situation. Leave these as the defaultNone
to maintain those states. - If created, the decision will be placed in the DEFAULT_DOMAIN
domain unless it's specified as a
base.DecisionSpecifier
with a domain part, in which case that domain is used. - If specified as a
base.DecisionSpecifier
with a zone part and a new decision needs to be created, the decision will be added to that zone, creating it at level 0 if necessary, although otherwise no zone information will be changed. - Resets the decision type to "pending" and the action taken to
None
. Sets the decision type of the previous situation to 'imposed' (or the specifieddecisionType
) and sets an appropriate 'start' action for that situation. - Tags the step with 'start'.
- Even in a plural- or spreading-focalized domain, you still need to pick one decision to start at.
8562 def hasBeenVisited( 8563 self, 8564 decision: base.AnyDecisionSpecifier, 8565 step: int = -1 8566 ): 8567 """ 8568 Returns whether or not the specified decision has been visited in 8569 the specified step (default current step). 8570 """ 8571 return base.hasBeenVisited(self.getSituation(step), decision)
Returns whether or not the specified decision has been visited in the specified step (default current step).
8573 def setExplorationStatus( 8574 self, 8575 decision: base.AnyDecisionSpecifier, 8576 status: base.ExplorationStatus, 8577 upgradeOnly: bool = False 8578 ): 8579 """ 8580 Updates the current exploration status of a specific decision in 8581 the current situation. If `upgradeOnly` is true (default is 8582 `False` then the update will only apply if the new exploration 8583 status counts as 'more-explored' than the old one (see 8584 `base.moreExplored`). 8585 """ 8586 base.setExplorationStatus( 8587 self.getSituation(), 8588 decision, 8589 status, 8590 upgradeOnly 8591 )
Updates the current exploration status of a specific decision in
the current situation. If upgradeOnly
is true (default is
False
then the update will only apply if the new exploration
status counts as 'more-explored' than the old one (see
base.moreExplored
).
8593 def getExplorationStatus( 8594 self, 8595 decision: base.AnyDecisionSpecifier, 8596 step: int = -1 8597 ): 8598 """ 8599 Returns the exploration status of the specified decision at the 8600 specified step (default is last step). Decisions whose 8601 exploration status has never been set will have a default status 8602 of 'unknown'. 8603 """ 8604 situation = self.getSituation(step) 8605 dID = situation.graph.resolveDecision(decision) 8606 return situation.state['exploration'].get(dID, 'unknown')
Returns the exploration status of the specified decision at the specified step (default is last step). Decisions whose exploration status has never been set will have a default status of 'unknown'.
8608 def deduceTransitionDetailsAtStep( 8609 self, 8610 step: int, 8611 transition: base.Transition, 8612 fromDecision: Optional[base.AnyDecisionSpecifier] = None, 8613 whichFocus: Optional[base.FocalPointSpecifier] = None, 8614 inCommon: Union[bool, Literal["auto"]] = "auto" 8615 ) -> Tuple[ 8616 base.ContextSpecifier, 8617 base.DecisionID, 8618 base.DecisionID, 8619 Optional[base.FocalPointSpecifier] 8620 ]: 8621 """ 8622 Given just a transition name which the player intends to take in 8623 a specific step, deduces the `ContextSpecifier` for which 8624 context should be updated, the source and destination 8625 `DecisionID`s for the transition, and if the destination 8626 decision's domain is plural-focalized, the `FocalPointName` 8627 specifying which focal point should be moved. 8628 8629 Because many of those things are ambiguous, you may get an 8630 `AmbiguousTransitionError` when things are underspecified, and 8631 there are options for specifying some of the extra information 8632 directly: 8633 8634 - `fromDecision` may be used to specify the source decision. 8635 - `whichFocus` may be used to specify the focal point (within a 8636 particular context/domain) being updated. When focal point 8637 ambiguity remains and this is unspecified, the 8638 alphabetically-earliest relevant focal point will be used 8639 (either among all focal points which activate the source 8640 decision, if there are any, or among all focal points for 8641 the entire domain of the destination decision). 8642 - `inCommon` (a `ContextSpecifier`) may be used to specify which 8643 context to update. The default of "auto" will cause the 8644 active context to be selected unless it does not activate 8645 the source decision, in which case the common context will 8646 be selected. 8647 8648 A `MissingDecisionError` will be raised if there are no current 8649 active decisions (e.g., before `start` has been called), and a 8650 `MissingTransitionError` will be raised if the listed transition 8651 does not exist from any active decision (or from the specified 8652 decision if `fromDecision` is used). 8653 """ 8654 now = self.getSituation(step) 8655 active = self.getActiveDecisions(step) 8656 if len(active) == 0: 8657 raise MissingDecisionError( 8658 f"There are no active decisions from which transition" 8659 f" {repr(transition)} could be taken at step {step}." 8660 ) 8661 8662 # All source/destination decision pairs for transitions with the 8663 # given transition name. 8664 allDecisionPairs: Dict[base.DecisionID, base.DecisionID] = {} 8665 8666 # TODO: When should we be trimming the active decisions to match 8667 # any alterations to the graph? 8668 for dID in active: 8669 outgoing = now.graph.destinationsFrom(dID) 8670 if transition in outgoing: 8671 allDecisionPairs[dID] = outgoing[transition] 8672 8673 if len(allDecisionPairs) == 0: 8674 raise MissingTransitionError( 8675 f"No transitions named {repr(transition)} are outgoing" 8676 f" from active decisions at step {step}." 8677 f"\nActive decisions are:" 8678 f"\n{now.graph.namesListing(active)}" 8679 ) 8680 8681 if ( 8682 fromDecision is not None 8683 and fromDecision not in allDecisionPairs 8684 ): 8685 raise MissingTransitionError( 8686 f"{fromDecision} was specified as the source decision" 8687 f" for traversing transition {repr(transition)} but" 8688 f" there is no transition of that name from that" 8689 f" decision at step {step}." 8690 f"\nValid source decisions are:" 8691 f"\n{now.graph.namesListing(allDecisionPairs)}" 8692 ) 8693 elif fromDecision is not None: 8694 fromID = now.graph.resolveDecision(fromDecision) 8695 destID = allDecisionPairs[fromID] 8696 fromDomain = now.graph.domainFor(fromID) 8697 elif len(allDecisionPairs) == 1: 8698 fromID, destID = list(allDecisionPairs.items())[0] 8699 fromDomain = now.graph.domainFor(fromID) 8700 else: 8701 fromID = None 8702 destID = None 8703 fromDomain = None 8704 # Still ambiguous; resolve this below 8705 8706 # Use whichFocus if provided 8707 if whichFocus is not None: 8708 # Type/value check for whichFocus 8709 if ( 8710 not isinstance(whichFocus, tuple) 8711 or len(whichFocus) != 3 8712 or whichFocus[0] not in ("active", "common") 8713 or not isinstance(whichFocus[1], base.Domain) 8714 or not isinstance(whichFocus[2], base.FocalPointName) 8715 ): 8716 raise ValueError( 8717 f"Invalid whichFocus value {repr(whichFocus)}." 8718 f"\nMust be a length-3 tuple with 'active' or 'common'" 8719 f" as the first element, a Domain as the second" 8720 f" element, and a FocalPointName as the third" 8721 f" element." 8722 ) 8723 8724 # Resolve focal point specified 8725 fromID = base.resolvePosition( 8726 now, 8727 whichFocus 8728 ) 8729 if fromID is None: 8730 raise MissingTransitionError( 8731 f"Focal point {repr(whichFocus)} was specified as" 8732 f" the transition source, but that focal point does" 8733 f" not have a position." 8734 ) 8735 else: 8736 destID = now.graph.destination(fromID, transition) 8737 fromDomain = now.graph.domainFor(fromID) 8738 8739 elif fromID is None: # whichFocus is None, so it can't disambiguate 8740 raise AmbiguousTransitionError( 8741 f"Transition {repr(transition)} was selected for" 8742 f" disambiguation, but there are multiple transitions" 8743 f" with that name from currently-active decisions, and" 8744 f" neither fromDecision nor whichFocus adequately" 8745 f" disambiguates the specific transition taken." 8746 f"\nValid source decisions at step {step} are:" 8747 f"\n{now.graph.namesListing(allDecisionPairs)}" 8748 ) 8749 8750 # At this point, fromID, destID, and fromDomain have 8751 # been resolved. 8752 if fromID is None or destID is None or fromDomain is None: 8753 raise RuntimeError( 8754 f"One of fromID, destID, or fromDomain was None after" 8755 f" disambiguation was finished:" 8756 f"\nfromID: {fromID}, destID: {destID}, fromDomain:" 8757 f" {repr(fromDomain)}" 8758 ) 8759 8760 # Now figure out which context activated the source so we know 8761 # which focal point we're moving: 8762 context = self.getActiveContext() 8763 active = base.activeDecisionSet(context) 8764 using: base.ContextSpecifier = "active" 8765 if fromID not in active: 8766 context = self.getCommonContext(step) 8767 using = "common" 8768 8769 destDomain = now.graph.domainFor(destID) 8770 if ( 8771 whichFocus is None 8772 and base.getDomainFocalization(context, destDomain) == 'plural' 8773 ): 8774 # Need to figure out which focal point is moving; use the 8775 # alphabetically earliest one that's positioned at the 8776 # fromID, or just the earliest one overall if none of them 8777 # are there. 8778 contextFocalPoints: Dict[ 8779 base.FocalPointName, 8780 Optional[base.DecisionID] 8781 ] = cast( 8782 Dict[base.FocalPointName, Optional[base.DecisionID]], 8783 context['activeDecisions'][destDomain] 8784 ) 8785 if not isinstance(contextFocalPoints, dict): 8786 raise RuntimeError( 8787 f"Active decisions specifier for domain" 8788 f" {repr(destDomain)} with plural focalization has" 8789 f" a non-dictionary value." 8790 ) 8791 8792 if fromDomain == destDomain: 8793 focalCandidates = [ 8794 fp 8795 for fp, pos in contextFocalPoints.items() 8796 if pos == fromID 8797 ] 8798 else: 8799 focalCandidates = list(contextFocalPoints) 8800 8801 whichFocus = (using, destDomain, min(focalCandidates)) 8802 8803 # Now whichFocus has been set if it wasn't already specified; 8804 # might still be None if it's not relevant. 8805 return (using, fromID, destID, whichFocus)
Given just a transition name which the player intends to take in
a specific step, deduces the ContextSpecifier
for which
context should be updated, the source and destination
DecisionID
s for the transition, and if the destination
decision's domain is plural-focalized, the FocalPointName
specifying which focal point should be moved.
Because many of those things are ambiguous, you may get an
AmbiguousTransitionError
when things are underspecified, and
there are options for specifying some of the extra information
directly:
fromDecision
may be used to specify the source decision.whichFocus
may be used to specify the focal point (within a particular context/domain) being updated. When focal point ambiguity remains and this is unspecified, the alphabetically-earliest relevant focal point will be used (either among all focal points which activate the source decision, if there are any, or among all focal points for the entire domain of the destination decision).inCommon
(aContextSpecifier
) may be used to specify which context to update. The default of "auto" will cause the active context to be selected unless it does not activate the source decision, in which case the common context will be selected.
A MissingDecisionError
will be raised if there are no current
active decisions (e.g., before start
has been called), and a
MissingTransitionError
will be raised if the listed transition
does not exist from any active decision (or from the specified
decision if fromDecision
is used).
8807 def advanceSituation( 8808 self, 8809 action: base.ExplorationAction, 8810 decisionType: base.DecisionType = "active", 8811 challengePolicy: base.ChallengePolicy = "specified" 8812 ) -> Tuple[base.Situation, Set[base.DecisionID]]: 8813 """ 8814 Given an `ExplorationAction`, sets that as the action taken in 8815 the current situation, and adds a new situation with the results 8816 of that action. A `DoubleActionError` will be raised if the 8817 current situation already has an action specified, and/or has a 8818 decision type other than 'pending'. By default the type of the 8819 decision will be 'active' but another `DecisionType` can be 8820 specified via the `decisionType` parameter. 8821 8822 If the action specified is `('noAction',)`, then the new 8823 situation will be a copy of the old one; this represents waiting 8824 or being at an ending (a decision type other than 'pending' 8825 should be used). 8826 8827 Although `None` can appear as the action entry in situations 8828 with pending decisions, you cannot call `advanceSituation` with 8829 `None` as the action. 8830 8831 If the action includes taking a transition whose requirements 8832 are not satisfied, the transition will still be taken (and any 8833 consequences applied) but a `TransitionBlockedWarning` will be 8834 issued. 8835 8836 A `ChallengePolicy` may be specified, the default is 'specified' 8837 which requires that outcomes are pre-specified. If any other 8838 policy is set, the challenge outcomes will be reset before 8839 re-resolving them according to the provided policy. 8840 8841 The new situation will have decision type 'pending' and `None` 8842 as the action. 8843 8844 The new situation created as a result of the action is returned, 8845 along with the set of destination decision IDs, including 8846 possibly a modified destination via 'bounce', 'goto', and/or 8847 'follow' effects. For actions that don't have a destination, the 8848 second part of the returned tuple will be an empty set. Multiple 8849 IDs may be in the set when using a start action in a plural- or 8850 spreading-focalized domain, for example. 8851 8852 If the action updates active decisions (including via transition 8853 effects) this will also update the exploration status of those 8854 decisions to 'exploring' if they had been in an unvisited 8855 status (see `updatePosition` and `hasBeenVisited`). This 8856 includes decisions traveled through but not ultimately arrived 8857 at via 'follow' effects. 8858 8859 If any decisions are active in the `ENDINGS_DOMAIN`, attempting 8860 to 'warp', 'explore', 'take', or 'start' will raise an 8861 `InvalidActionError`. 8862 """ 8863 now = self.getSituation() 8864 if now.type != 'pending' or now.action is not None: 8865 raise DoubleActionError( 8866 f"Attempted to take action {repr(action)} at step" 8867 f" {len(self) - 1}, but an action and/or decision type" 8868 f" had already been specified:" 8869 f"\nAction: {repr(now.action)}" 8870 f"\nType: {repr(now.type)}" 8871 ) 8872 8873 # Update the now situation to add in the decision type and 8874 # action taken: 8875 revised = base.Situation( 8876 now.graph, 8877 now.state, 8878 decisionType, 8879 action, 8880 now.saves, 8881 now.tags, 8882 now.annotations 8883 ) 8884 self.situations[-1] = revised 8885 8886 # Separate update process when reverting (this branch returns) 8887 if ( 8888 action is not None 8889 and isinstance(action, tuple) 8890 and len(action) == 3 8891 and action[0] == 'revertTo' 8892 and isinstance(action[1], base.SaveSlot) 8893 and isinstance(action[2], set) 8894 and all(isinstance(x, str) for x in action[2]) 8895 ): 8896 _, slot, aspects = action 8897 if slot not in now.saves: 8898 raise KeyError( 8899 f"Cannot load save slot {slot!r} because no save" 8900 f" data has been established for that slot." 8901 ) 8902 load = now.saves[slot] 8903 rGraph, rState = base.revertedState( 8904 (now.graph, now.state), 8905 load, 8906 aspects 8907 ) 8908 reverted = base.Situation( 8909 graph=rGraph, 8910 state=rState, 8911 type='pending', 8912 action=None, 8913 saves=copy.deepcopy(now.saves), 8914 tags={}, 8915 annotations=[] 8916 ) 8917 self.situations.append(reverted) 8918 # Apply any active triggers (edits reverted) 8919 self.applyActiveTriggers() 8920 # Figure out destinations set to return 8921 newDestinations = set() 8922 newPr = rState['primaryDecision'] 8923 if newPr is not None: 8924 newDestinations.add(newPr) 8925 return (reverted, newDestinations) 8926 8927 # TODO: These deep copies are expensive time-wise. Can we avoid 8928 # them? Probably not. 8929 newGraph = copy.deepcopy(now.graph) 8930 newState = copy.deepcopy(now.state) 8931 newSaves = copy.copy(now.saves) # a shallow copy 8932 newTags: Dict[base.Tag, base.TagValue] = {} 8933 newAnnotations: List[base.Annotation] = [] 8934 updated = base.Situation( 8935 graph=newGraph, 8936 state=newState, 8937 type='pending', 8938 action=None, 8939 saves=newSaves, 8940 tags=newTags, 8941 annotations=newAnnotations 8942 ) 8943 8944 targetContext: base.FocalContext 8945 8946 # Now that action effects have been imprinted into the updated 8947 # situation, append it to our situations list 8948 self.situations.append(updated) 8949 8950 # Figure out effects of the action: 8951 if action is None: 8952 raise InvalidActionError( 8953 "None cannot be used as an action when advancing the" 8954 " situation." 8955 ) 8956 8957 aLen = len(action) 8958 8959 destIDs = set() 8960 8961 if ( 8962 action[0] in ('start', 'take', 'explore', 'warp') 8963 and any( 8964 newGraph.domainFor(d) == ENDINGS_DOMAIN 8965 for d in self.getActiveDecisions() 8966 ) 8967 ): 8968 activeEndings = [ 8969 d 8970 for d in self.getActiveDecisions() 8971 if newGraph.domainFor(d) == ENDINGS_DOMAIN 8972 ] 8973 raise InvalidActionError( 8974 f"Attempted to {action[0]!r} while an ending was" 8975 f" active. Active endings are:" 8976 f"\n{newGraph.namesListing(activeEndings)}" 8977 ) 8978 8979 if action == ('noAction',): 8980 # No updates needed 8981 pass 8982 8983 elif ( 8984 not isinstance(action, tuple) 8985 or (action[0] not in get_args(base.ExplorationActionType)) 8986 or not (2 <= aLen <= 7) 8987 ): 8988 raise InvalidActionError( 8989 f"Invalid ExplorationAction tuple (must be a tuple that" 8990 f" starts with an ExplorationActionType and has 2-6" 8991 f" entries if it's not ('noAction',)):" 8992 f"\n{repr(action)}" 8993 ) 8994 8995 elif action[0] == 'start': 8996 ( 8997 _, 8998 positionSpecifier, 8999 primary, 9000 domain, 9001 capabilities, 9002 mechanismStates, 9003 customState 9004 ) = cast( 9005 Tuple[ 9006 Literal['start'], 9007 Union[ 9008 base.DecisionID, 9009 Dict[base.FocalPointName, base.DecisionID], 9010 Set[base.DecisionID] 9011 ], 9012 Optional[base.DecisionID], 9013 base.Domain, 9014 Optional[base.CapabilitySet], 9015 Optional[Dict[base.MechanismID, base.MechanismState]], 9016 Optional[dict] 9017 ], 9018 action 9019 ) 9020 targetContext = newState['contexts'][ 9021 newState['activeContext'] 9022 ] 9023 9024 targetFocalization = base.getDomainFocalization( 9025 targetContext, 9026 domain 9027 ) # sets up 'singular' as default if 9028 9029 # Check if there are any already-active decisions. 9030 if targetContext['activeDecisions'][domain] is not None: 9031 raise BadStart( 9032 f"Cannot start in domain {repr(domain)} because" 9033 f" that domain already has a position. 'start' may" 9034 f" only be used with domains that don't yet have" 9035 f" any position information." 9036 ) 9037 9038 # Make the domain active 9039 if domain not in targetContext['activeDomains']: 9040 targetContext['activeDomains'].add(domain) 9041 9042 # Check position info matches focalization type and update 9043 # exploration statuses 9044 if isinstance(positionSpecifier, base.DecisionID): 9045 if targetFocalization != 'singular': 9046 raise BadStart( 9047 f"Invalid position specifier" 9048 f" {repr(positionSpecifier)} (type" 9049 f" {type(positionSpecifier)}). Domain" 9050 f" {repr(domain)} has {targetFocalization}" 9051 f" focalization." 9052 ) 9053 base.setExplorationStatus( 9054 updated, 9055 positionSpecifier, 9056 'exploring', 9057 upgradeOnly=True 9058 ) 9059 destIDs.add(positionSpecifier) 9060 elif isinstance(positionSpecifier, dict): 9061 if targetFocalization != 'plural': 9062 raise BadStart( 9063 f"Invalid position specifier" 9064 f" {repr(positionSpecifier)} (type" 9065 f" {type(positionSpecifier)}). Domain" 9066 f" {repr(domain)} has {targetFocalization}" 9067 f" focalization." 9068 ) 9069 destIDs |= set(positionSpecifier.values()) 9070 elif isinstance(positionSpecifier, set): 9071 if targetFocalization != 'spreading': 9072 raise BadStart( 9073 f"Invalid position specifier" 9074 f" {repr(positionSpecifier)} (type" 9075 f" {type(positionSpecifier)}). Domain" 9076 f" {repr(domain)} has {targetFocalization}" 9077 f" focalization." 9078 ) 9079 destIDs |= positionSpecifier 9080 else: 9081 raise TypeError( 9082 f"Invalid position specifier" 9083 f" {repr(positionSpecifier)} (type" 9084 f" {type(positionSpecifier)}). It must be a" 9085 f" DecisionID, a dictionary from FocalPointNames to" 9086 f" DecisionIDs, or a set of DecisionIDs, according" 9087 f" to the focalization of the relevant domain." 9088 ) 9089 9090 # Put specified position(s) in place 9091 # TODO: This cast is really silly... 9092 targetContext['activeDecisions'][domain] = cast( 9093 Union[ 9094 None, 9095 base.DecisionID, 9096 Dict[base.FocalPointName, Optional[base.DecisionID]], 9097 Set[base.DecisionID] 9098 ], 9099 positionSpecifier 9100 ) 9101 9102 # Set primary decision 9103 newState['primaryDecision'] = primary 9104 9105 # Set capabilities 9106 if capabilities is not None: 9107 targetContext['capabilities'] = capabilities 9108 9109 # Set mechanism states 9110 if mechanismStates is not None: 9111 newState['mechanisms'] = mechanismStates 9112 9113 # Set custom state 9114 if customState is not None: 9115 newState['custom'] = customState 9116 9117 elif action[0] in ('explore', 'take', 'warp'): # similar handling 9118 assert ( 9119 len(action) == 3 9120 or len(action) == 4 9121 or len(action) == 6 9122 or len(action) == 7 9123 ) 9124 # Set up necessary variables 9125 cSpec: base.ContextSpecifier = "active" 9126 fromID: Optional[base.DecisionID] = None 9127 takeTransition: Optional[base.Transition] = None 9128 outcomes: List[bool] = [] 9129 destID: base.DecisionID # No starting value as it's not optional 9130 moveInDomain: Optional[base.Domain] = None 9131 moveWhich: Optional[base.FocalPointName] = None 9132 9133 # Figure out target context 9134 if isinstance(action[1], str): 9135 if action[1] not in get_args(base.ContextSpecifier): 9136 raise InvalidActionError( 9137 f"Action specifies {repr(action[1])} context," 9138 f" but that's not a valid context specifier." 9139 f" The valid options are:" 9140 f"\n{repr(get_args(base.ContextSpecifier))}" 9141 ) 9142 else: 9143 cSpec = cast(base.ContextSpecifier, action[1]) 9144 else: # Must be a `FocalPointSpecifier` 9145 cSpec, moveInDomain, moveWhich = cast( 9146 base.FocalPointSpecifier, 9147 action[1] 9148 ) 9149 assert moveInDomain is not None 9150 9151 # Grab target context to work in 9152 if cSpec == 'common': 9153 targetContext = newState['common'] 9154 else: 9155 targetContext = newState['contexts'][ 9156 newState['activeContext'] 9157 ] 9158 9159 # Check focalization of the target domain 9160 if moveInDomain is not None: 9161 fType = base.getDomainFocalization( 9162 targetContext, 9163 moveInDomain 9164 ) 9165 if ( 9166 ( 9167 isinstance(action[1], str) 9168 and fType == 'plural' 9169 ) or ( 9170 not isinstance(action[1], str) 9171 and fType != 'plural' 9172 ) 9173 ): 9174 raise ImpossibleActionError( 9175 f"Invalid ExplorationAction (moves in" 9176 f" plural-focalized domains must include a" 9177 f" FocalPointSpecifier, while moves in" 9178 f" non-plural-focalized domains must not." 9179 f" Domain {repr(moveInDomain)} is" 9180 f" {fType}-focalized):" 9181 f"\n{repr(action)}" 9182 ) 9183 9184 if action[0] == "warp": 9185 # It's a warp, so destination is specified directly 9186 if not isinstance(action[2], base.DecisionID): 9187 raise TypeError( 9188 f"Invalid ExplorationAction tuple (third part" 9189 f" must be a decision ID for 'warp' actions):" 9190 f"\n{repr(action)}" 9191 ) 9192 else: 9193 destID = cast(base.DecisionID, action[2]) 9194 9195 elif aLen == 4 or aLen == 7: 9196 # direct 'take' or 'explore' 9197 fromID = cast(base.DecisionID, action[2]) 9198 takeTransition, outcomes = cast( 9199 base.TransitionWithOutcomes, 9200 action[3] # type: ignore [misc] 9201 ) 9202 if ( 9203 not isinstance(fromID, base.DecisionID) 9204 or not isinstance(takeTransition, base.Transition) 9205 ): 9206 raise InvalidActionError( 9207 f"Invalid ExplorationAction tuple (for 'take' or" 9208 f" 'explore', if the length is 4/7, parts 2-4" 9209 f" must be a context specifier, a decision ID, and a" 9210 f" transition name. Got:" 9211 f"\n{repr(action)}" 9212 ) 9213 9214 try: 9215 destID = newGraph.destination(fromID, takeTransition) 9216 except MissingDecisionError: 9217 raise ImpossibleActionError( 9218 f"Invalid ExplorationAction: move from decision" 9219 f" {fromID} is invalid because there is no" 9220 f" decision with that ID in the current" 9221 f" graph." 9222 f"\nValid decisions are:" 9223 f"\n{newGraph.namesListing(newGraph)}" 9224 ) 9225 except MissingTransitionError: 9226 valid = newGraph.destinationsFrom(fromID) 9227 listing = newGraph.destinationsListing(valid) 9228 raise ImpossibleActionError( 9229 f"Invalid ExplorationAction: move from decision" 9230 f" {newGraph.identityOf(fromID)}" 9231 f" along transition {repr(takeTransition)} is" 9232 f" invalid because there is no such transition" 9233 f" at that decision." 9234 f"\nValid transitions there are:" 9235 f"\n{listing}" 9236 ) 9237 targetActive = targetContext['activeDecisions'] 9238 if moveInDomain is not None: 9239 activeInDomain = targetActive[moveInDomain] 9240 if ( 9241 ( 9242 isinstance(activeInDomain, base.DecisionID) 9243 and fromID != activeInDomain 9244 ) 9245 or ( 9246 isinstance(activeInDomain, set) 9247 and fromID not in activeInDomain 9248 ) 9249 or ( 9250 isinstance(activeInDomain, dict) 9251 and fromID not in activeInDomain.values() 9252 ) 9253 ): 9254 raise ImpossibleActionError( 9255 f"Invalid ExplorationAction: move from" 9256 f" decision {fromID} is invalid because" 9257 f" that decision is not active in domain" 9258 f" {repr(moveInDomain)} in the current" 9259 f" graph." 9260 f"\nValid decisions are:" 9261 f"\n{newGraph.namesListing(newGraph)}" 9262 ) 9263 9264 elif aLen == 3 or aLen == 6: 9265 # 'take' or 'explore' focal point 9266 # We know that moveInDomain is not None here. 9267 assert moveInDomain is not None 9268 if not isinstance(action[2], base.Transition): 9269 raise InvalidActionError( 9270 f"Invalid ExplorationAction tuple (for 'take'" 9271 f" actions if the second part is a" 9272 f" FocalPointSpecifier the third part must be a" 9273 f" transition name):" 9274 f"\n{repr(action)}" 9275 ) 9276 9277 takeTransition, outcomes = cast( 9278 base.TransitionWithOutcomes, 9279 action[2] 9280 ) 9281 targetActive = targetContext['activeDecisions'] 9282 activeInDomain = cast( 9283 Dict[base.FocalPointName, Optional[base.DecisionID]], 9284 targetActive[moveInDomain] 9285 ) 9286 if ( 9287 moveInDomain is not None 9288 and ( 9289 not isinstance(activeInDomain, dict) 9290 or moveWhich not in activeInDomain 9291 ) 9292 ): 9293 raise ImpossibleActionError( 9294 f"Invalid ExplorationAction: move of focal" 9295 f" point {repr(moveWhich)} in domain" 9296 f" {repr(moveInDomain)} is invalid because" 9297 f" that domain does not have a focal point" 9298 f" with that name." 9299 ) 9300 fromID = activeInDomain[moveWhich] 9301 if fromID is None: 9302 raise ImpossibleActionError( 9303 f"Invalid ExplorationAction: move of focal" 9304 f" point {repr(moveWhich)} in domain" 9305 f" {repr(moveInDomain)} is invalid because" 9306 f" that focal point does not have a position" 9307 f" at this step." 9308 ) 9309 try: 9310 destID = newGraph.destination(fromID, takeTransition) 9311 except MissingDecisionError: 9312 raise ImpossibleActionError( 9313 f"Invalid exploration state: focal point" 9314 f" {repr(moveWhich)} in domain" 9315 f" {repr(moveInDomain)} specifies decision" 9316 f" {fromID} as the current position, but" 9317 f" that decision does not exist!" 9318 ) 9319 except MissingTransitionError: 9320 valid = newGraph.destinationsFrom(fromID) 9321 listing = newGraph.destinationsListing(valid) 9322 raise ImpossibleActionError( 9323 f"Invalid ExplorationAction: move of focal" 9324 f" point {repr(moveWhich)} in domain" 9325 f" {repr(moveInDomain)} along transition" 9326 f" {repr(takeTransition)} is invalid because" 9327 f" that focal point is at decision" 9328 f" {newGraph.identityOf(fromID)} and that" 9329 f" decision does not have an outgoing" 9330 f" transition with that name.\nValid" 9331 f" transitions from that decision are:" 9332 f"\n{listing}" 9333 ) 9334 9335 else: 9336 raise InvalidActionError( 9337 f"Invalid ExplorationAction: unrecognized" 9338 f" 'explore', 'take' or 'warp' format:" 9339 f"\n{action}" 9340 ) 9341 9342 # If we're exploring, update information for the destination 9343 if action[0] == 'explore': 9344 zone = cast( 9345 Union[base.Zone, None, type[base.DefaultZone]], 9346 action[-1] 9347 ) 9348 recipName = cast(Optional[base.Transition], action[-2]) 9349 destOrName = cast( 9350 Union[base.DecisionName, base.DecisionID, None], 9351 action[-3] 9352 ) 9353 if isinstance(destOrName, base.DecisionID): 9354 destID = destOrName 9355 9356 if fromID is None or takeTransition is None: 9357 raise ImpossibleActionError( 9358 f"Invalid ExplorationAction: exploration" 9359 f" has unclear origin decision or transition." 9360 f" Got:\n{action}" 9361 ) 9362 9363 currentDest = newGraph.destination(fromID, takeTransition) 9364 if not newGraph.isConfirmed(currentDest): 9365 newGraph.replaceUnconfirmed( 9366 fromID, 9367 takeTransition, 9368 destOrName, 9369 recipName, 9370 placeInZone=zone, 9371 forceNew=not isinstance(destOrName, base.DecisionID) 9372 ) 9373 else: 9374 # Otherwise, since the destination already existed 9375 # and was hooked up at the right decision, no graph 9376 # edits need to be made, unless we need to rename 9377 # the reciprocal. 9378 # TODO: Do we care about zones here? 9379 if recipName is not None: 9380 oldReciprocal = newGraph.getReciprocal( 9381 fromID, 9382 takeTransition 9383 ) 9384 if ( 9385 oldReciprocal is not None 9386 and oldReciprocal != recipName 9387 ): 9388 newGraph.addTransition( 9389 destID, 9390 recipName, 9391 fromID, 9392 None 9393 ) 9394 newGraph.setReciprocal( 9395 destID, 9396 recipName, 9397 takeTransition, 9398 setBoth=True 9399 ) 9400 newGraph.mergeTransitions( 9401 destID, 9402 oldReciprocal, 9403 recipName 9404 ) 9405 9406 # If we are moving along a transition, check requirements 9407 # and apply transition effects *before* updating our 9408 # position, and check that they don't cancel the normal 9409 # position update 9410 finalDest = None 9411 if takeTransition is not None: 9412 assert fromID is not None # both or neither 9413 if not self.isTraversable(fromID, takeTransition): 9414 req = now.graph.getTransitionRequirement( 9415 fromID, 9416 takeTransition 9417 ) 9418 # TODO: Alter warning message if transition is 9419 # deactivated vs. requirement not satisfied 9420 warnings.warn( 9421 ( 9422 f"The requirements for transition" 9423 f" {takeTransition!r} from decision" 9424 f" {now.graph.identityOf(fromID)} are" 9425 f" not met at step {len(self) - 1} (or that" 9426 f" transition has been deactivated):\n{req}" 9427 ), 9428 TransitionBlockedWarning 9429 ) 9430 9431 # Apply transition consequences to our new state and 9432 # figure out if we need to skip our normal update or not 9433 finalDest = self.applyTransitionConsequence( 9434 fromID, 9435 (takeTransition, outcomes), 9436 moveWhich, 9437 challengePolicy 9438 ) 9439 9440 # Check moveInDomain 9441 destDomain = newGraph.domainFor(destID) 9442 if moveInDomain is not None and moveInDomain != destDomain: 9443 raise ImpossibleActionError( 9444 f"Invalid ExplorationAction: move specified" 9445 f" domain {repr(moveInDomain)} as the domain of" 9446 f" the focal point to move, but the destination" 9447 f" of the move is {now.graph.identityOf(destID)}" 9448 f" which is in domain {repr(destDomain)}, so focal" 9449 f" point {repr(moveWhich)} cannot be moved there." 9450 ) 9451 9452 # Now that we know where we're going, update position 9453 # information (assuming it wasn't already set): 9454 if finalDest is None: 9455 finalDest = destID 9456 base.updatePosition( 9457 updated, 9458 destID, 9459 cSpec, 9460 moveWhich 9461 ) 9462 9463 destIDs.add(finalDest) 9464 9465 elif action[0] == "focus": 9466 # Figure out target context 9467 action = cast( 9468 Tuple[ 9469 Literal['focus'], 9470 base.ContextSpecifier, 9471 Set[base.Domain], 9472 Set[base.Domain] 9473 ], 9474 action 9475 ) 9476 contextSpecifier: base.ContextSpecifier = action[1] 9477 if contextSpecifier == 'common': 9478 targetContext = newState['common'] 9479 else: 9480 targetContext = newState['contexts'][ 9481 newState['activeContext'] 9482 ] 9483 9484 # Just need to swap out active domains 9485 goingOut, comingIn = cast( 9486 Tuple[Set[base.Domain], Set[base.Domain]], 9487 action[2:] 9488 ) 9489 if ( 9490 not isinstance(goingOut, set) 9491 or not isinstance(comingIn, set) 9492 or not all(isinstance(d, base.Domain) for d in goingOut) 9493 or not all(isinstance(d, base.Domain) for d in comingIn) 9494 ): 9495 raise InvalidActionError( 9496 f"Invalid ExplorationAction tuple (must have 4" 9497 f" parts if the first part is 'focus' and" 9498 f" the third and fourth parts must be sets of" 9499 f" domains):" 9500 f"\n{repr(action)}" 9501 ) 9502 activeSet = targetContext['activeDomains'] 9503 for dom in goingOut: 9504 try: 9505 activeSet.remove(dom) 9506 except KeyError: 9507 warnings.warn( 9508 ( 9509 f"Domain {repr(dom)} was deactivated at" 9510 f" step {len(self)} but it was already" 9511 f" inactive at that point." 9512 ), 9513 InactiveDomainWarning 9514 ) 9515 # TODO: Also warn for doubly-activated domains? 9516 activeSet |= comingIn 9517 9518 # destIDs remains empty in this case 9519 9520 elif action[0] == 'swap': # update which `FocalContext` is active 9521 newContext = cast(base.FocalContextName, action[1]) 9522 if newContext not in newState['contexts']: 9523 raise MissingFocalContextError( 9524 f"'swap' action with target {repr(newContext)} is" 9525 f" invalid because no context with that name" 9526 f" exists." 9527 ) 9528 newState['activeContext'] = newContext 9529 9530 # destIDs remains empty in this case 9531 9532 elif action[0] == 'focalize': # create new `FocalContext` 9533 newContext = cast(base.FocalContextName, action[1]) 9534 if newContext in newState['contexts']: 9535 raise FocalContextCollisionError( 9536 f"'focalize' action with target {repr(newContext)}" 9537 f" is invalid because a context with that name" 9538 f" already exists." 9539 ) 9540 newState['contexts'][newContext] = base.emptyFocalContext() 9541 newState['activeContext'] = newContext 9542 9543 # destIDs remains empty in this case 9544 9545 # revertTo is handled above 9546 else: 9547 raise InvalidActionError( 9548 f"Invalid ExplorationAction tuple (first item must be" 9549 f" an ExplorationActionType, and tuple must be length-1" 9550 f" if the action type is 'noAction'):" 9551 f"\n{repr(action)}" 9552 ) 9553 9554 # Apply any active triggers 9555 followTo = self.applyActiveTriggers() 9556 if followTo is not None: 9557 destIDs.add(followTo) 9558 # TODO: Re-work to work with multiple position updates in 9559 # different focal contexts, domains, and/or for different 9560 # focal points in plural-focalized domains. 9561 9562 return (updated, destIDs)
Given an ExplorationAction
, sets that as the action taken in
the current situation, and adds a new situation with the results
of that action. A DoubleActionError
will be raised if the
current situation already has an action specified, and/or has a
decision type other than 'pending'. By default the type of the
decision will be 'active' but another DecisionType
can be
specified via the decisionType
parameter.
If the action specified is ('noAction',)
, then the new
situation will be a copy of the old one; this represents waiting
or being at an ending (a decision type other than 'pending'
should be used).
Although None
can appear as the action entry in situations
with pending decisions, you cannot call advanceSituation
with
None
as the action.
If the action includes taking a transition whose requirements
are not satisfied, the transition will still be taken (and any
consequences applied) but a TransitionBlockedWarning
will be
issued.
A ChallengePolicy
may be specified, the default is 'specified'
which requires that outcomes are pre-specified. If any other
policy is set, the challenge outcomes will be reset before
re-resolving them according to the provided policy.
The new situation will have decision type 'pending' and None
as the action.
The new situation created as a result of the action is returned, along with the set of destination decision IDs, including possibly a modified destination via 'bounce', 'goto', and/or 'follow' effects. For actions that don't have a destination, the second part of the returned tuple will be an empty set. Multiple IDs may be in the set when using a start action in a plural- or spreading-focalized domain, for example.
If the action updates active decisions (including via transition
effects) this will also update the exploration status of those
decisions to 'exploring' if they had been in an unvisited
status (see updatePosition
and hasBeenVisited
). This
includes decisions traveled through but not ultimately arrived
at via 'follow' effects.
If any decisions are active in the ENDINGS_DOMAIN
, attempting
to 'warp', 'explore', 'take', or 'start' will raise an
InvalidActionError
.
9564 def applyActiveTriggers(self) -> Optional[base.DecisionID]: 9565 """ 9566 Finds all actions with the 'trigger' tag attached to currently 9567 active decisions, and applies their effects if their requirements 9568 are met (ordered by decision-ID with ties broken alphabetically 9569 by action name). 9570 9571 'bounce', 'goto' and 'follow' effects may apply. However, any 9572 new triggers that would be activated because of decisions 9573 reached by such effects will not apply. Note that 'bounce' 9574 effects update position to the decision where the action was 9575 attached, which is usually a no-op. This function returns the 9576 decision ID of the decision reached by the last decision-moving 9577 effect applied, or `None` if no such effects triggered. 9578 9579 TODO: What about situations where positions are updated in 9580 multiple domains or multiple foal points in a plural domain are 9581 independently updated? 9582 9583 TODO: Tests for this! 9584 """ 9585 active = self.getActiveDecisions() 9586 now = self.getSituation() 9587 graph = now.graph 9588 finalFollow = None 9589 for decision in sorted(active): 9590 for action in graph.decisionActions(decision): 9591 if ( 9592 'trigger' in graph.transitionTags(decision, action) 9593 and self.isTraversable(decision, action) 9594 ): 9595 followTo = self.applyTransitionConsequence( 9596 decision, 9597 action 9598 ) 9599 if followTo is not None: 9600 # TODO: How will triggers interact with 9601 # plural-focalized domains? Probably need to fix 9602 # this to detect moveWhich based on which focal 9603 # points are at the decision where the transition 9604 # is, and then apply this to each of them? 9605 base.updatePosition(now, followTo) 9606 finalFollow = followTo 9607 9608 return finalFollow
Finds all actions with the 'trigger' tag attached to currently active decisions, and applies their effects if their requirements are met (ordered by decision-ID with ties broken alphabetically by action name).
'bounce', 'goto' and 'follow' effects may apply. However, any
new triggers that would be activated because of decisions
reached by such effects will not apply. Note that 'bounce'
effects update position to the decision where the action was
attached, which is usually a no-op. This function returns the
decision ID of the decision reached by the last decision-moving
effect applied, or None
if no such effects triggered.
TODO: What about situations where positions are updated in multiple domains or multiple foal points in a plural domain are independently updated?
TODO: Tests for this!
9610 def explore( 9611 self, 9612 transition: base.AnyTransition, 9613 destination: Union[base.DecisionName, base.DecisionID, None], 9614 reciprocal: Optional[base.Transition] = None, 9615 zone: Union[ 9616 base.Zone, 9617 type[base.DefaultZone], 9618 None 9619 ] = base.DefaultZone, 9620 fromDecision: Optional[base.AnyDecisionSpecifier] = None, 9621 whichFocus: Optional[base.FocalPointSpecifier] = None, 9622 inCommon: Union[bool, Literal["auto"]] = "auto", 9623 decisionType: base.DecisionType = "active", 9624 challengePolicy: base.ChallengePolicy = "specified" 9625 ) -> base.DecisionID: 9626 """ 9627 Adds a new situation to the exploration representing the 9628 traversal of the specified transition (possibly with outcomes 9629 specified for challenges among that transitions consequences). 9630 Uses `deduceTransitionDetailsAtStep` to figure out from the 9631 transition name which specific transition is taken (and which 9632 focal point is updated if necessary). This uses the 9633 `fromDecision`, `whichFocus`, and `inCommon` optional 9634 parameters, and also determines whether to update the common or 9635 the active `FocalContext`. Sets the exploration status of the 9636 decision explored to 'exploring'. Returns the decision ID for 9637 the destination reached, accounting for goto/bounce/follow 9638 effects that might have triggered. 9639 9640 The `destination` will be used to name the newly-explored 9641 decision, except when it's a `DecisionID`, in which case that 9642 decision must be unvisited, and we'll connect the specified 9643 transition to that decision. 9644 9645 The focalization of the destination domain in the context to be 9646 updated determines how active decisions are changed: 9647 9648 - If the destination domain is focalized as 'single', then in 9649 the subsequent `Situation`, the destination decision will 9650 become the single active decision in that domain. 9651 - If it's focalized as 'plural', then one of the 9652 `FocalPointName`s for that domain will be moved to activate 9653 that decision; which one can be specified using `whichFocus` 9654 or if left unspecified, will be deduced: if the starting 9655 decision is in the same domain, then the 9656 alphabetically-earliest focal point which is at the starting 9657 decision will be moved. If the starting position is in a 9658 different domain, then the alphabetically earliest focal 9659 point among all focal points in the destination domain will 9660 be moved. 9661 - If it's focalized as 'spreading', then the destination 9662 decision will be added to the set of active decisions in 9663 that domain, without removing any. 9664 9665 The transition named must have been pointing to an unvisited 9666 decision (see `hasBeenVisited`), and the name of that decision 9667 will be updated if a `destination` value is given (a 9668 `DecisionCollisionWarning` will be issued if the destination 9669 name is a duplicate of another name in the graph, although this 9670 is not an error). Additionally: 9671 9672 - If a `reciprocal` name is specified, the reciprocal transition 9673 will be renamed using that name, or created with that name if 9674 it didn't already exist. If reciprocal is left as `None` (the 9675 default) then no change will be made to the reciprocal 9676 transition, and it will not be created if it doesn't exist. 9677 - If a `zone` is specified, the newly-explored decision will be 9678 added to that zone (and that zone will be created at level 0 9679 if it didn't already exist). If `zone` is set to `None` then 9680 it will not be added to any new zones. If `zone` is left as 9681 the default (the `DefaultZone` class) then the explored 9682 decision will be added to each zone that the decision it was 9683 explored from is a part of. If a zone needs to be created, 9684 that zone will be added as a sub-zone of each zone which is a 9685 parent of a zone that directly contains the origin decision. 9686 - An `ExplorationStatusError` will be raised if the specified 9687 transition leads to a decision whose `ExplorationStatus` is 9688 'exploring' or higher (i.e., `hasBeenVisited`). (Use 9689 `returnTo` instead to adjust things when a transition to an 9690 unknown destination turns out to lead to an already-known 9691 destination.) 9692 - A `TransitionBlockedWarning` will be issued if the specified 9693 transition is not traversable given the current game state 9694 (but in that last case the step will still be taken). 9695 - By default, the decision type for the new step will be 9696 'active', but a `decisionType` value can be specified to 9697 override that. 9698 - By default, the 'mostLikely' `ChallengePolicy` will be used to 9699 resolve challenges in the consequence of the transition 9700 taken, but an alternate policy can be supplied using the 9701 `challengePolicy` argument. 9702 """ 9703 now = self.getSituation() 9704 9705 transitionName, outcomes = base.nameAndOutcomes(transition) 9706 9707 # Deduce transition details from the name + optional specifiers 9708 ( 9709 using, 9710 fromID, 9711 destID, 9712 whichFocus 9713 ) = self.deduceTransitionDetailsAtStep( 9714 -1, 9715 transitionName, 9716 fromDecision, 9717 whichFocus, 9718 inCommon 9719 ) 9720 9721 # Issue a warning if the destination name is already in use 9722 if destination is not None: 9723 if isinstance(destination, base.DecisionName): 9724 try: 9725 existingID = now.graph.resolveDecision(destination) 9726 collision = existingID != destID 9727 except MissingDecisionError: 9728 collision = False 9729 except AmbiguousDecisionSpecifierError: 9730 collision = True 9731 9732 if collision and WARN_OF_NAME_COLLISIONS: 9733 warnings.warn( 9734 ( 9735 f"The destination name {repr(destination)} is" 9736 f" already in use when exploring transition" 9737 f" {repr(transition)} from decision" 9738 f" {now.graph.identityOf(fromID)} at step" 9739 f" {len(self) - 1}." 9740 ), 9741 DecisionCollisionWarning 9742 ) 9743 9744 # TODO: Different terminology for "exploration state above 9745 # noticed" vs. "DG thinks it's been visited"... 9746 if ( 9747 self.hasBeenVisited(destID) 9748 ): 9749 raise ExplorationStatusError( 9750 f"Cannot explore to decision" 9751 f" {now.graph.identityOf(destID)} because it has" 9752 f" already been visited. Use returnTo instead of" 9753 f" explore when discovering a connection back to a" 9754 f" previously-explored decision." 9755 ) 9756 9757 if ( 9758 isinstance(destination, base.DecisionID) 9759 and self.hasBeenVisited(destination) 9760 ): 9761 raise ExplorationStatusError( 9762 f"Cannot explore to decision" 9763 f" {now.graph.identityOf(destination)} because it has" 9764 f" already been visited. Use returnTo instead of" 9765 f" explore when discovering a connection back to a" 9766 f" previously-explored decision." 9767 ) 9768 9769 actionTaken: base.ExplorationAction = ( 9770 'explore', 9771 using, 9772 fromID, 9773 (transitionName, outcomes), 9774 destination, 9775 reciprocal, 9776 zone 9777 ) 9778 if whichFocus is not None: 9779 # A move-from-specific-focal-point action 9780 actionTaken = ( 9781 'explore', 9782 whichFocus, 9783 (transitionName, outcomes), 9784 destination, 9785 reciprocal, 9786 zone 9787 ) 9788 9789 # Advance the situation, applying transition effects and 9790 # updating the destination decision. 9791 _, finalDest = self.advanceSituation( 9792 actionTaken, 9793 decisionType, 9794 challengePolicy 9795 ) 9796 9797 # TODO: Is this assertion always valid? 9798 assert len(finalDest) == 1 9799 return next(x for x in finalDest)
Adds a new situation to the exploration representing the
traversal of the specified transition (possibly with outcomes
specified for challenges among that transitions consequences).
Uses deduceTransitionDetailsAtStep
to figure out from the
transition name which specific transition is taken (and which
focal point is updated if necessary). This uses the
fromDecision
, whichFocus
, and inCommon
optional
parameters, and also determines whether to update the common or
the active FocalContext
. Sets the exploration status of the
decision explored to 'exploring'. Returns the decision ID for
the destination reached, accounting for goto/bounce/follow
effects that might have triggered.
The destination
will be used to name the newly-explored
decision, except when it's a DecisionID
, in which case that
decision must be unvisited, and we'll connect the specified
transition to that decision.
The focalization of the destination domain in the context to be updated determines how active decisions are changed:
- If the destination domain is focalized as 'single', then in
the subsequent
Situation
, the destination decision will become the single active decision in that domain. - If it's focalized as 'plural', then one of the
FocalPointName
s for that domain will be moved to activate that decision; which one can be specified usingwhichFocus
or if left unspecified, will be deduced: if the starting decision is in the same domain, then the alphabetically-earliest focal point which is at the starting decision will be moved. If the starting position is in a different domain, then the alphabetically earliest focal point among all focal points in the destination domain will be moved. - If it's focalized as 'spreading', then the destination decision will be added to the set of active decisions in that domain, without removing any.
The transition named must have been pointing to an unvisited
decision (see hasBeenVisited
), and the name of that decision
will be updated if a destination
value is given (a
DecisionCollisionWarning
will be issued if the destination
name is a duplicate of another name in the graph, although this
is not an error). Additionally:
- If a
reciprocal
name is specified, the reciprocal transition will be renamed using that name, or created with that name if it didn't already exist. If reciprocal is left asNone
(the default) then no change will be made to the reciprocal transition, and it will not be created if it doesn't exist. - If a
zone
is specified, the newly-explored decision will be added to that zone (and that zone will be created at level 0 if it didn't already exist). Ifzone
is set toNone
then it will not be added to any new zones. Ifzone
is left as the default (theDefaultZone
class) then the explored decision will be added to each zone that the decision it was explored from is a part of. If a zone needs to be created, that zone will be added as a sub-zone of each zone which is a parent of a zone that directly contains the origin decision. - An
ExplorationStatusError
will be raised if the specified transition leads to a decision whoseExplorationStatus
is 'exploring' or higher (i.e.,hasBeenVisited
). (UsereturnTo
instead to adjust things when a transition to an unknown destination turns out to lead to an already-known destination.) - A
TransitionBlockedWarning
will be issued if the specified transition is not traversable given the current game state (but in that last case the step will still be taken). - By default, the decision type for the new step will be
'active', but a
decisionType
value can be specified to override that. - By default, the 'mostLikely'
ChallengePolicy
will be used to resolve challenges in the consequence of the transition taken, but an alternate policy can be supplied using thechallengePolicy
argument.
9801 def returnTo( 9802 self, 9803 transition: base.AnyTransition, 9804 destination: base.AnyDecisionSpecifier, 9805 reciprocal: Optional[base.Transition] = None, 9806 fromDecision: Optional[base.AnyDecisionSpecifier] = None, 9807 whichFocus: Optional[base.FocalPointSpecifier] = None, 9808 inCommon: Union[bool, Literal["auto"]] = "auto", 9809 decisionType: base.DecisionType = "active", 9810 challengePolicy: base.ChallengePolicy = "specified" 9811 ) -> base.DecisionID: 9812 """ 9813 Adds a new graph to the exploration that replaces the given 9814 transition at the current position (which must lead to an unknown 9815 node, or a `MissingDecisionError` will result). The new 9816 transition will connect back to the specified destination, which 9817 must already exist (or a different `ValueError` will be raised). 9818 Returns the decision ID for the destination reached. 9819 9820 Deduces transition details using the optional `fromDecision`, 9821 `whichFocus`, and `inCommon` arguments in addition to the 9822 `transition` value; see `deduceTransitionDetailsAtStep`. 9823 9824 If a `reciprocal` transition is specified, that transition must 9825 either not already exist in the destination decision or lead to 9826 an unknown region; it will be replaced (or added) as an edge 9827 leading back to the current position. 9828 9829 The `decisionType` and `challengePolicy` optional arguments are 9830 used for `advanceSituation`. 9831 9832 A `TransitionBlockedWarning` will be issued if the requirements 9833 for the transition are not met, but the step will still be taken. 9834 Raises a `MissingDecisionError` if there is no current 9835 transition. 9836 """ 9837 now = self.getSituation() 9838 9839 transitionName, outcomes = base.nameAndOutcomes(transition) 9840 9841 # Deduce transition details from the name + optional specifiers 9842 ( 9843 using, 9844 fromID, 9845 destID, 9846 whichFocus 9847 ) = self.deduceTransitionDetailsAtStep( 9848 -1, 9849 transitionName, 9850 fromDecision, 9851 whichFocus, 9852 inCommon 9853 ) 9854 9855 # Replace with connection to existing destination 9856 destID = now.graph.resolveDecision(destination) 9857 if not self.hasBeenVisited(destID): 9858 raise ExplorationStatusError( 9859 f"Cannot return to decision" 9860 f" {now.graph.identityOf(destID)} because it has NOT" 9861 f" already been at least partially explored. Use" 9862 f" explore instead of returnTo when discovering a" 9863 f" connection to a previously-unexplored decision." 9864 ) 9865 9866 now.graph.replaceUnconfirmed( 9867 fromID, 9868 transitionName, 9869 destID, 9870 reciprocal 9871 ) 9872 9873 # A move-from-decision action 9874 actionTaken: base.ExplorationAction = ( 9875 'take', 9876 using, 9877 fromID, 9878 (transitionName, outcomes) 9879 ) 9880 if whichFocus is not None: 9881 # A move-from-specific-focal-point action 9882 actionTaken = ('take', whichFocus, (transitionName, outcomes)) 9883 9884 # Next, advance the situation, applying transition effects 9885 _, finalDest = self.advanceSituation( 9886 actionTaken, 9887 decisionType, 9888 challengePolicy 9889 ) 9890 9891 assert len(finalDest) == 1 9892 return next(x for x in finalDest)
Adds a new graph to the exploration that replaces the given
transition at the current position (which must lead to an unknown
node, or a MissingDecisionError
will result). The new
transition will connect back to the specified destination, which
must already exist (or a different ValueError
will be raised).
Returns the decision ID for the destination reached.
Deduces transition details using the optional fromDecision
,
whichFocus
, and inCommon
arguments in addition to the
transition
value; see deduceTransitionDetailsAtStep
.
If a reciprocal
transition is specified, that transition must
either not already exist in the destination decision or lead to
an unknown region; it will be replaced (or added) as an edge
leading back to the current position.
The decisionType
and challengePolicy
optional arguments are
used for advanceSituation
.
A TransitionBlockedWarning
will be issued if the requirements
for the transition are not met, but the step will still be taken.
Raises a MissingDecisionError
if there is no current
transition.
9894 def takeAction( 9895 self, 9896 action: base.AnyTransition, 9897 requires: Optional[base.Requirement] = None, 9898 consequence: Optional[base.Consequence] = None, 9899 fromDecision: Optional[base.AnyDecisionSpecifier] = None, 9900 whichFocus: Optional[base.FocalPointSpecifier] = None, 9901 inCommon: Union[bool, Literal["auto"]] = "auto", 9902 decisionType: base.DecisionType = "active", 9903 challengePolicy: base.ChallengePolicy = "specified" 9904 ) -> base.DecisionID: 9905 """ 9906 Adds a new graph to the exploration based on taking the given 9907 action, which must be a self-transition in the graph. If the 9908 action does not already exist in the graph, it will be created. 9909 Either way if requirements and/or a consequence are supplied, 9910 the requirements and consequence of the action will be updated 9911 to match them, and those are the requirements/consequence that 9912 will count. 9913 9914 Returns the decision ID for the decision reached, which normally 9915 is the same action you were just at, but which might be altered 9916 by goto, bounce, and/or follow effects. 9917 9918 Issues a `TransitionBlockedWarning` if the current game state 9919 doesn't satisfy the requirements for the action. 9920 9921 The `fromDecision`, `whichFocus`, and `inCommon` arguments are 9922 used for `deduceTransitionDetailsAtStep`, while `decisionType` 9923 and `challengePolicy` are used for `advanceSituation`. 9924 9925 When an action is being created, `fromDecision` (or 9926 `whichFocus`) must be specified, since the source decision won't 9927 be deducible from the transition name. Note that if a transition 9928 with the given name exists from *any* active decision, it will 9929 be used instead of creating a new action (possibly resulting in 9930 an error if it's not a self-loop transition). Also, you may get 9931 an `AmbiguousTransitionError` if several transitions with that 9932 name exist; in that case use `fromDecision` and/or `whichFocus` 9933 to disambiguate. 9934 """ 9935 now = self.getSituation() 9936 graph = now.graph 9937 9938 actionName, outcomes = base.nameAndOutcomes(action) 9939 9940 try: 9941 ( 9942 using, 9943 fromID, 9944 destID, 9945 whichFocus 9946 ) = self.deduceTransitionDetailsAtStep( 9947 -1, 9948 actionName, 9949 fromDecision, 9950 whichFocus, 9951 inCommon 9952 ) 9953 9954 if destID != fromID: 9955 raise ValueError( 9956 f"Cannot take action {repr(action)} because it's a" 9957 f" transition to another decision, not an action" 9958 f" (use explore, returnTo, and/or retrace instead)." 9959 ) 9960 9961 except MissingTransitionError: 9962 using = 'active' 9963 if inCommon is True: 9964 using = 'common' 9965 9966 if fromDecision is not None: 9967 fromID = graph.resolveDecision(fromDecision) 9968 elif whichFocus is not None: 9969 maybeFromID = base.resolvePosition(now, whichFocus) 9970 if maybeFromID is None: 9971 raise MissingDecisionError( 9972 f"Focal point {repr(whichFocus)} was specified" 9973 f" in takeAction but that focal point doesn't" 9974 f" have a position." 9975 ) 9976 else: 9977 fromID = maybeFromID 9978 else: 9979 raise AmbiguousTransitionError( 9980 f"Taking action {repr(action)} is ambiguous because" 9981 f" the source decision has not been specified via" 9982 f" either fromDecision or whichFocus, and we" 9983 f" couldn't find an existing action with that name." 9984 ) 9985 9986 # Since the action doesn't exist, add it: 9987 graph.addAction(fromID, actionName, requires, consequence) 9988 9989 # Update the transition requirement/consequence if requested 9990 # (before the action is taken) 9991 if requires is not None: 9992 graph.setTransitionRequirement(fromID, actionName, requires) 9993 if consequence is not None: 9994 graph.setConsequence(fromID, actionName, consequence) 9995 9996 # A move-from-decision action 9997 actionTaken: base.ExplorationAction = ( 9998 'take', 9999 using, 10000 fromID, 10001 (actionName, outcomes) 10002 ) 10003 if whichFocus is not None: 10004 # A move-from-specific-focal-point action 10005 actionTaken = ('take', whichFocus, (actionName, outcomes)) 10006 10007 _, finalDest = self.advanceSituation( 10008 actionTaken, 10009 decisionType, 10010 challengePolicy 10011 ) 10012 10013 assert len(finalDest) in (0, 1) 10014 if len(finalDest) == 1: 10015 return next(x for x in finalDest) 10016 else: 10017 return fromID
Adds a new graph to the exploration based on taking the given action, which must be a self-transition in the graph. If the action does not already exist in the graph, it will be created. Either way if requirements and/or a consequence are supplied, the requirements and consequence of the action will be updated to match them, and those are the requirements/consequence that will count.
Returns the decision ID for the decision reached, which normally is the same action you were just at, but which might be altered by goto, bounce, and/or follow effects.
Issues a TransitionBlockedWarning
if the current game state
doesn't satisfy the requirements for the action.
The fromDecision
, whichFocus
, and inCommon
arguments are
used for deduceTransitionDetailsAtStep
, while decisionType
and challengePolicy
are used for advanceSituation
.
When an action is being created, fromDecision
(or
whichFocus
) must be specified, since the source decision won't
be deducible from the transition name. Note that if a transition
with the given name exists from any active decision, it will
be used instead of creating a new action (possibly resulting in
an error if it's not a self-loop transition). Also, you may get
an AmbiguousTransitionError
if several transitions with that
name exist; in that case use fromDecision
and/or whichFocus
to disambiguate.
10019 def retrace( 10020 self, 10021 transition: base.AnyTransition, 10022 fromDecision: Optional[base.AnyDecisionSpecifier] = None, 10023 whichFocus: Optional[base.FocalPointSpecifier] = None, 10024 inCommon: Union[bool, Literal["auto"]] = "auto", 10025 decisionType: base.DecisionType = "active", 10026 challengePolicy: base.ChallengePolicy = "specified" 10027 ) -> base.DecisionID: 10028 """ 10029 Adds a new graph to the exploration based on taking the given 10030 transition, which must already exist and which must not lead to 10031 an unknown region. Returns the ID of the destination decision, 10032 accounting for goto, bounce, and/or follow effects. 10033 10034 Issues a `TransitionBlockedWarning` if the current game state 10035 doesn't satisfy the requirements for the transition. 10036 10037 The `fromDecision`, `whichFocus`, and `inCommon` arguments are 10038 used for `deduceTransitionDetailsAtStep`, while `decisionType` 10039 and `challengePolicy` are used for `advanceSituation`. 10040 """ 10041 now = self.getSituation() 10042 10043 transitionName, outcomes = base.nameAndOutcomes(transition) 10044 10045 ( 10046 using, 10047 fromID, 10048 destID, 10049 whichFocus 10050 ) = self.deduceTransitionDetailsAtStep( 10051 -1, 10052 transitionName, 10053 fromDecision, 10054 whichFocus, 10055 inCommon 10056 ) 10057 10058 visited = self.hasBeenVisited(destID) 10059 confirmed = now.graph.isConfirmed(destID) 10060 if not confirmed: 10061 raise ExplorationStatusError( 10062 f"Cannot retrace transition {transition!r} from" 10063 f" decision {now.graph.identityOf(fromID)} because it" 10064 f" leads to an unconfirmed decision.\nUse" 10065 f" `DiscreteExploration.explore` and provide" 10066 f" destination decision details instead." 10067 ) 10068 if not visited: 10069 raise ExplorationStatusError( 10070 f"Cannot retrace transition {transition!r} from" 10071 f" decision {now.graph.identityOf(fromID)} because it" 10072 f" leads to an unvisited decision.\nUse" 10073 f" `DiscreteExploration.explore` and provide" 10074 f" destination decision details instead." 10075 ) 10076 10077 # A move-from-decision action 10078 actionTaken: base.ExplorationAction = ( 10079 'take', 10080 using, 10081 fromID, 10082 (transitionName, outcomes) 10083 ) 10084 if whichFocus is not None: 10085 # A move-from-specific-focal-point action 10086 actionTaken = ('take', whichFocus, (transitionName, outcomes)) 10087 10088 _, finalDest = self.advanceSituation( 10089 actionTaken, 10090 decisionType, 10091 challengePolicy 10092 ) 10093 10094 assert len(finalDest) == 1 10095 return next(x for x in finalDest)
Adds a new graph to the exploration based on taking the given transition, which must already exist and which must not lead to an unknown region. Returns the ID of the destination decision, accounting for goto, bounce, and/or follow effects.
Issues a TransitionBlockedWarning
if the current game state
doesn't satisfy the requirements for the transition.
The fromDecision
, whichFocus
, and inCommon
arguments are
used for deduceTransitionDetailsAtStep
, while decisionType
and challengePolicy
are used for advanceSituation
.
10097 def warp( 10098 self, 10099 destination: base.AnyDecisionSpecifier, 10100 consequence: Optional[base.Consequence] = None, 10101 domain: Optional[base.Domain] = None, 10102 zone: Union[ 10103 base.Zone, 10104 type[base.DefaultZone], 10105 None 10106 ] = base.DefaultZone, 10107 whichFocus: Optional[base.FocalPointSpecifier] = None, 10108 inCommon: Union[bool] = False, 10109 decisionType: base.DecisionType = "active", 10110 challengePolicy: base.ChallengePolicy = "specified" 10111 ) -> base.DecisionID: 10112 """ 10113 Adds a new graph to the exploration that's a copy of the current 10114 graph, with the position updated to be at the destination without 10115 actually creating a transition from the old position to the new 10116 one. Returns the ID of the decision warped to (accounting for 10117 any goto or follow effects triggered). 10118 10119 Any provided consequences are applied, but are not associated 10120 with any transition (so any delays and charges are ignored, and 10121 'bounce' effects don't actually cancel the warp). 'goto' or 10122 'follow' effects might change the warp destination; 'follow' 10123 effects take the original destination as their starting point. 10124 Any mechanisms mentioned in extra consequences will be found 10125 based on the destination. Outcomes in supplied challenges should 10126 be pre-specified, or else they will be resolved with the 10127 `challengePolicy`. 10128 10129 `whichFocus` may be specified when the destination domain's 10130 focalization is 'plural' but for 'singular' or 'spreading' 10131 destination domains it is not allowed. `inCommon` determines 10132 whether the common or the active focal context is updated 10133 (default is to update the active context). The `decisionType` 10134 and `challengePolicy` are used for `advanceSituation`. 10135 10136 - If the destination did not already exist, it will be created. 10137 Initially, it will be disconnected from all other decisions. 10138 In this case, the `domain` value can be used to put it in a 10139 non-default domain. 10140 - The position is set to the specified destination, and if a 10141 `consequence` is specified it is applied. Note that 10142 'deactivate' effects are NOT allowed, and 'edit' effects 10143 must establish their own transition target because there is 10144 no transition that the effects are being applied to. 10145 - If the destination had been unexplored, its exploration status 10146 will be set to 'exploring'. 10147 - If a `zone` is specified, the destination will be added to that 10148 zone (even if the destination already existed) and that zone 10149 will be created (as a level-0 zone) if need be. If `zone` is 10150 set to `None`, then no zone will be applied. If `zone` is 10151 left as the default (`DefaultZone`) and the focalization of 10152 the destination domain is 'singular' or 'plural' and the 10153 destination is newly created and there is an origin and the 10154 origin is in the same domain as the destination, then the 10155 destination will be added to all zones that the origin was a 10156 part of if the destination is newly created, but otherwise 10157 the destination will not be added to any zones. If the 10158 specified zone has to be created and there's an origin 10159 decision, it will be added as a sub-zone to all parents of 10160 zones directly containing the origin, as long as the origin 10161 is in the same domain as the destination. 10162 """ 10163 now = self.getSituation() 10164 graph = now.graph 10165 10166 fromID: Optional[base.DecisionID] 10167 10168 new = False 10169 try: 10170 destID = graph.resolveDecision(destination) 10171 except MissingDecisionError: 10172 if isinstance(destination, tuple): 10173 # just the name; ignore zone/domain 10174 destination = destination[-1] 10175 10176 if not isinstance(destination, base.DecisionName): 10177 raise TypeError( 10178 f"Warp destination {repr(destination)} does not" 10179 f" exist, and cannot be created as it is not a" 10180 f" decision name." 10181 ) 10182 destID = graph.addDecision(destination, domain) 10183 graph.tagDecision(destID, 'unconfirmed') 10184 self.setExplorationStatus(destID, 'unknown') 10185 new = True 10186 10187 using: base.ContextSpecifier 10188 if inCommon: 10189 targetContext = self.getCommonContext() 10190 using = "common" 10191 else: 10192 targetContext = self.getActiveContext() 10193 using = "active" 10194 10195 destDomain = graph.domainFor(destID) 10196 targetFocalization = base.getDomainFocalization( 10197 targetContext, 10198 destDomain 10199 ) 10200 if targetFocalization == 'singular': 10201 targetActive = targetContext['activeDecisions'] 10202 if destDomain in targetActive: 10203 fromID = cast( 10204 base.DecisionID, 10205 targetContext['activeDecisions'][destDomain] 10206 ) 10207 else: 10208 fromID = None 10209 elif targetFocalization == 'plural': 10210 if whichFocus is None: 10211 raise AmbiguousTransitionError( 10212 f"Warping to {repr(destination)} is ambiguous" 10213 f" becuase domain {repr(destDomain)} has plural" 10214 f" focalization, and no whichFocus value was" 10215 f" specified." 10216 ) 10217 10218 fromID = base.resolvePosition( 10219 self.getSituation(), 10220 whichFocus 10221 ) 10222 else: 10223 fromID = None 10224 10225 # Handle zones 10226 if zone is base.DefaultZone: 10227 if ( 10228 new 10229 and fromID is not None 10230 and graph.domainFor(fromID) == destDomain 10231 ): 10232 for prevZone in graph.zoneParents(fromID): 10233 graph.addDecisionToZone(destination, prevZone) 10234 # Otherwise don't update zones 10235 elif zone is not None: 10236 # Newness is ignored when a zone is specified 10237 zone = cast(base.Zone, zone) 10238 # Create the zone at level 0 if it didn't already exist 10239 if graph.getZoneInfo(zone) is None: 10240 graph.createZone(zone, 0) 10241 # Add the newly created zone to each 2nd-level parent of 10242 # the previous decision if there is one and it's in the 10243 # same domain 10244 if ( 10245 fromID is not None 10246 and graph.domainFor(fromID) == destDomain 10247 ): 10248 for prevZone in graph.zoneParents(fromID): 10249 for prevUpper in graph.zoneParents(prevZone): 10250 graph.addZoneToZone(zone, prevUpper) 10251 # Finally add the destination to the (maybe new) zone 10252 graph.addDecisionToZone(destID, zone) 10253 # else don't touch zones 10254 10255 # Encode the action taken 10256 actionTaken: base.ExplorationAction 10257 if whichFocus is None: 10258 actionTaken = ( 10259 'warp', 10260 using, 10261 destID 10262 ) 10263 else: 10264 actionTaken = ( 10265 'warp', 10266 whichFocus, 10267 destID 10268 ) 10269 10270 # Advance the situation 10271 _, finalDests = self.advanceSituation( 10272 actionTaken, 10273 decisionType, 10274 challengePolicy 10275 ) 10276 now = self.getSituation() # updating just in case 10277 10278 assert len(finalDests) == 1 10279 finalDest = next(x for x in finalDests) 10280 10281 # Apply additional consequences: 10282 if consequence is not None: 10283 altDest = self.applyExtraneousConsequence( 10284 consequence, 10285 where=(destID, None), 10286 # TODO: Mechanism search from both ends? 10287 moveWhich=( 10288 whichFocus[-1] 10289 if whichFocus is not None 10290 else None 10291 ) 10292 ) 10293 if altDest is not None: 10294 finalDest = altDest 10295 now = self.getSituation() # updating just in case 10296 10297 return finalDest
Adds a new graph to the exploration that's a copy of the current graph, with the position updated to be at the destination without actually creating a transition from the old position to the new one. Returns the ID of the decision warped to (accounting for any goto or follow effects triggered).
Any provided consequences are applied, but are not associated
with any transition (so any delays and charges are ignored, and
'bounce' effects don't actually cancel the warp). 'goto' or
'follow' effects might change the warp destination; 'follow'
effects take the original destination as their starting point.
Any mechanisms mentioned in extra consequences will be found
based on the destination. Outcomes in supplied challenges should
be pre-specified, or else they will be resolved with the
challengePolicy
.
whichFocus
may be specified when the destination domain's
focalization is 'plural' but for 'singular' or 'spreading'
destination domains it is not allowed. inCommon
determines
whether the common or the active focal context is updated
(default is to update the active context). The decisionType
and challengePolicy
are used for advanceSituation
.
- If the destination did not already exist, it will be created.
Initially, it will be disconnected from all other decisions.
In this case, the
domain
value can be used to put it in a non-default domain. - The position is set to the specified destination, and if a
consequence
is specified it is applied. Note that 'deactivate' effects are NOT allowed, and 'edit' effects must establish their own transition target because there is no transition that the effects are being applied to. - If the destination had been unexplored, its exploration status will be set to 'exploring'.
- If a
zone
is specified, the destination will be added to that zone (even if the destination already existed) and that zone will be created (as a level-0 zone) if need be. Ifzone
is set toNone
, then no zone will be applied. Ifzone
is left as the default (DefaultZone
) and the focalization of the destination domain is 'singular' or 'plural' and the destination is newly created and there is an origin and the origin is in the same domain as the destination, then the destination will be added to all zones that the origin was a part of if the destination is newly created, but otherwise the destination will not be added to any zones. If the specified zone has to be created and there's an origin decision, it will be added as a sub-zone to all parents of zones directly containing the origin, as long as the origin is in the same domain as the destination.
10299 def wait( 10300 self, 10301 consequence: Optional[base.Consequence] = None, 10302 decisionType: base.DecisionType = "active", 10303 challengePolicy: base.ChallengePolicy = "specified" 10304 ) -> Optional[base.DecisionID]: 10305 """ 10306 Adds a wait step. If a consequence is specified, it is applied, 10307 although it will not have any position/transition information 10308 available during resolution/application. 10309 10310 A decision type other than "active" and/or a challenge policy 10311 other than "specified" can be included (see `advanceSituation`). 10312 10313 The "pending" decision type may not be used, a `ValueError` will 10314 result. This allows None as the action for waiting while 10315 preserving the pending/None type/action combination for 10316 unresolved situations. 10317 10318 If a goto or follow effect in the applied consequence implies a 10319 position update, this will return the new destination ID; 10320 otherwise it will return `None`. Triggering a 'bounce' effect 10321 will be an error, because there is no position information for 10322 the effect. 10323 """ 10324 if decisionType == "pending": 10325 raise ValueError( 10326 "The 'pending' decision type may not be used for" 10327 " wait actions." 10328 ) 10329 self.advanceSituation(('noAction',), decisionType, challengePolicy) 10330 now = self.getSituation() 10331 if consequence is not None: 10332 if challengePolicy != "specified": 10333 base.resetChallengeOutcomes(consequence) 10334 observed = base.observeChallengeOutcomes( 10335 base.RequirementContext( 10336 state=now.state, 10337 graph=now.graph, 10338 searchFrom=set() 10339 ), 10340 consequence, 10341 location=None, # No position info 10342 policy=challengePolicy, 10343 knownOutcomes=None # bake outcomes into the consequence 10344 ) 10345 # No location information since we might have multiple 10346 # active decisions and there's no indication of which one 10347 # we're "waiting at." 10348 finalDest = self.applyExtraneousConsequence(observed) 10349 now = self.getSituation() # updating just in case 10350 10351 return finalDest 10352 else: 10353 return None
Adds a wait step. If a consequence is specified, it is applied, although it will not have any position/transition information available during resolution/application.
A decision type other than "active" and/or a challenge policy
other than "specified" can be included (see advanceSituation
).
The "pending" decision type may not be used, a ValueError
will
result. This allows None as the action for waiting while
preserving the pending/None type/action combination for
unresolved situations.
If a goto or follow effect in the applied consequence implies a
position update, this will return the new destination ID;
otherwise it will return None
. Triggering a 'bounce' effect
will be an error, because there is no position information for
the effect.
10355 def revert( 10356 self, 10357 slot: base.SaveSlot = base.DEFAULT_SAVE_SLOT, 10358 aspects: Optional[Set[str]] = None, 10359 decisionType: base.DecisionType = "active" 10360 ) -> None: 10361 """ 10362 Reverts the game state to a previously-saved game state (saved 10363 via a 'save' effect). The save slot name and set of aspects to 10364 revert are required. By default, all aspects except the graph 10365 are reverted. 10366 """ 10367 if aspects is None: 10368 aspects = set() 10369 10370 action: base.ExplorationAction = ("revertTo", slot, aspects) 10371 10372 self.advanceSituation(action, decisionType)
Reverts the game state to a previously-saved game state (saved via a 'save' effect). The save slot name and set of aspects to revert are required. By default, all aspects except the graph are reverted.
10374 def observeAll( 10375 self, 10376 where: base.AnyDecisionSpecifier, 10377 *transitions: Union[ 10378 base.Transition, 10379 Tuple[base.Transition, base.AnyDecisionSpecifier], 10380 Tuple[ 10381 base.Transition, 10382 base.AnyDecisionSpecifier, 10383 base.Transition 10384 ] 10385 ] 10386 ) -> List[base.DecisionID]: 10387 """ 10388 Observes one or more new transitions, applying changes to the 10389 current graph. The transitions can be specified in one of three 10390 ways: 10391 10392 1. A transition name. The transition will be created and will 10393 point to a new unexplored node. 10394 2. A pair containing a transition name and a destination 10395 specifier. If the destination does not exist it will be 10396 created as an unexplored node, although in that case the 10397 decision specifier may not be an ID. 10398 3. A triple containing a transition name, a destination 10399 specifier, and a reciprocal name. Works the same as the pair 10400 case but also specifies the name for the reciprocal 10401 transition. 10402 10403 The new transitions are outgoing from specified decision. 10404 10405 Yields the ID of each decision connected to, whether those are 10406 new or existing decisions. 10407 """ 10408 now = self.getSituation() 10409 fromID = now.graph.resolveDecision(where) 10410 result = [] 10411 for entry in transitions: 10412 if isinstance(entry, base.Transition): 10413 result.append(self.observe(fromID, entry)) 10414 else: 10415 result.append(self.observe(fromID, *entry)) 10416 return result
Observes one or more new transitions, applying changes to the current graph. The transitions can be specified in one of three ways:
- A transition name. The transition will be created and will point to a new unexplored node.
- A pair containing a transition name and a destination specifier. If the destination does not exist it will be created as an unexplored node, although in that case the decision specifier may not be an ID.
- A triple containing a transition name, a destination specifier, and a reciprocal name. Works the same as the pair case but also specifies the name for the reciprocal transition.
The new transitions are outgoing from specified decision.
Yields the ID of each decision connected to, whether those are new or existing decisions.
10418 def observe( 10419 self, 10420 where: base.AnyDecisionSpecifier, 10421 transition: base.Transition, 10422 destination: Optional[base.AnyDecisionSpecifier] = None, 10423 reciprocal: Optional[base.Transition] = None 10424 ) -> base.DecisionID: 10425 """ 10426 Observes a single new outgoing transition from the specified 10427 decision. If specified the transition connects to a specific 10428 destination and/or has a specific reciprocal. The specified 10429 destination will be created if it doesn't exist, or where no 10430 destination is specified, a new unexplored decision will be 10431 added. The ID of the decision connected to is returned. 10432 10433 Sets the exploration status of the observed destination to 10434 "noticed" if a destination is specified and needs to be created 10435 (but not when no destination is specified). 10436 10437 For example: 10438 10439 >>> e = DiscreteExploration() 10440 >>> e.start('start') 10441 0 10442 >>> e.observe('start', 'up') 10443 1 10444 >>> g = e.getSituation().graph 10445 >>> g.destinationsFrom('start') 10446 {'up': 1} 10447 >>> e.getExplorationStatus(1) # not given a name: assumed unknown 10448 'unknown' 10449 >>> e.observe('start', 'left', 'A') 10450 2 10451 >>> g.destinationsFrom('start') 10452 {'up': 1, 'left': 2} 10453 >>> g.nameFor(2) 10454 'A' 10455 >>> e.getExplorationStatus(2) # given a name: noticed 10456 'noticed' 10457 >>> e.observe('start', 'up2', 1) 10458 1 10459 >>> g.destinationsFrom('start') 10460 {'up': 1, 'left': 2, 'up2': 1} 10461 >>> e.getExplorationStatus(1) # existing decision: status unchanged 10462 'unknown' 10463 >>> e.observe('start', 'right', 'B', 'left') 10464 3 10465 >>> g.destinationsFrom('start') 10466 {'up': 1, 'left': 2, 'up2': 1, 'right': 3} 10467 >>> g.nameFor(3) 10468 'B' 10469 >>> e.getExplorationStatus(3) # new + name -> noticed 10470 'noticed' 10471 >>> e.observe('start', 'right') # repeat transition name 10472 Traceback (most recent call last): 10473 ... 10474 exploration.core.TransitionCollisionError... 10475 >>> e.observe('start', 'right2', 'B', 'left') # repeat reciprocal 10476 Traceback (most recent call last): 10477 ... 10478 exploration.core.TransitionCollisionError... 10479 >>> g = e.getSituation().graph 10480 >>> g.createZone('Z', 0) 10481 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 10482 annotations=[]) 10483 >>> g.addDecisionToZone('start', 'Z') 10484 >>> e.observe('start', 'down', 'C', 'up') 10485 4 10486 >>> g.destinationsFrom('start') 10487 {'up': 1, 'left': 2, 'up2': 1, 'right': 3, 'down': 4} 10488 >>> g.identityOf('C') 10489 '4 (C)' 10490 >>> g.zoneParents(4) # not in any zones, 'cause still unexplored 10491 set() 10492 >>> e.observe( 10493 ... 'C', 10494 ... 'right', 10495 ... base.DecisionSpecifier('main', 'Z2', 'D'), 10496 ... ) # creates zone 10497 5 10498 >>> g.destinationsFrom('C') 10499 {'up': 0, 'right': 5} 10500 >>> g.destinationsFrom('D') # default reciprocal name 10501 {'return': 4} 10502 >>> g.identityOf('D') 10503 '5 (Z2::D)' 10504 >>> g.zoneParents(5) 10505 {'Z2'} 10506 """ 10507 now = self.getSituation() 10508 fromID = now.graph.resolveDecision(where) 10509 10510 kwargs: Dict[ 10511 str, 10512 Union[base.Transition, base.DecisionName, None] 10513 ] = {} 10514 if reciprocal is not None: 10515 kwargs['reciprocal'] = reciprocal 10516 10517 if destination is not None: 10518 try: 10519 destID = now.graph.resolveDecision(destination) 10520 now.graph.addTransition( 10521 fromID, 10522 transition, 10523 destID, 10524 reciprocal 10525 ) 10526 return destID 10527 except MissingDecisionError: 10528 if isinstance(destination, base.DecisionSpecifier): 10529 kwargs['toDomain'] = destination.domain 10530 kwargs['placeInZone'] = destination.zone 10531 kwargs['destinationName'] = destination.name 10532 elif isinstance(destination, base.DecisionName): 10533 kwargs['destinationName'] = destination 10534 else: 10535 assert isinstance(destination, base.DecisionID) 10536 # We got to except by failing to resolve, so it's an 10537 # invalid ID 10538 raise 10539 10540 result = now.graph.addUnexploredEdge( 10541 fromID, 10542 transition, 10543 **kwargs # type: ignore [arg-type] 10544 ) 10545 if 'destinationName' in kwargs: 10546 self.setExplorationStatus(result, 'noticed', upgradeOnly=True) 10547 return result
Observes a single new outgoing transition from the specified decision. If specified the transition connects to a specific destination and/or has a specific reciprocal. The specified destination will be created if it doesn't exist, or where no destination is specified, a new unexplored decision will be added. The ID of the decision connected to is returned.
Sets the exploration status of the observed destination to "noticed" if a destination is specified and needs to be created (but not when no destination is specified).
For example:
>>> e = DiscreteExploration()
>>> e.start('start')
0
>>> e.observe('start', 'up')
1
>>> g = e.getSituation().graph
>>> g.destinationsFrom('start')
{'up': 1}
>>> e.getExplorationStatus(1) # not given a name: assumed unknown
'unknown'
>>> e.observe('start', 'left', 'A')
2
>>> g.destinationsFrom('start')
{'up': 1, 'left': 2}
>>> g.nameFor(2)
'A'
>>> e.getExplorationStatus(2) # given a name: noticed
'noticed'
>>> e.observe('start', 'up2', 1)
1
>>> g.destinationsFrom('start')
{'up': 1, 'left': 2, 'up2': 1}
>>> e.getExplorationStatus(1) # existing decision: status unchanged
'unknown'
>>> e.observe('start', 'right', 'B', 'left')
3
>>> g.destinationsFrom('start')
{'up': 1, 'left': 2, 'up2': 1, 'right': 3}
>>> g.nameFor(3)
'B'
>>> e.getExplorationStatus(3) # new + name -> noticed
'noticed'
>>> e.observe('start', 'right') # repeat transition name
Traceback (most recent call last):
...
TransitionCollisionError...
>>> e.observe('start', 'right2', 'B', 'left') # repeat reciprocal
Traceback (most recent call last):
...
TransitionCollisionError...
>>> g = e.getSituation().graph
>>> g.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone('start', 'Z')
>>> e.observe('start', 'down', 'C', 'up')
4
>>> g.destinationsFrom('start')
{'up': 1, 'left': 2, 'up2': 1, 'right': 3, 'down': 4}
>>> g.identityOf('C')
'4 (C)'
>>> g.zoneParents(4) # not in any zones, 'cause still unexplored
set()
>>> e.observe(
... 'C',
... 'right',
... base.DecisionSpecifier('main', 'Z2', 'D'),
... ) # creates zone
5
>>> g.destinationsFrom('C')
{'up': 0, 'right': 5}
>>> g.destinationsFrom('D') # default reciprocal name
{'return': 4}
>>> g.identityOf('D')
'5 (Z2::D)'
>>> g.zoneParents(5)
{'Z2'}
10549 def observeMechanisms( 10550 self, 10551 where: Optional[base.AnyDecisionSpecifier], 10552 *mechanisms: Union[ 10553 base.MechanismName, 10554 Tuple[base.MechanismName, base.MechanismState] 10555 ] 10556 ) -> List[base.MechanismID]: 10557 """ 10558 Adds one or more mechanisms to the exploration's current graph, 10559 located at the specified decision. Global mechanisms can be 10560 added by using `None` for the location. Mechanisms are named, or 10561 a (name, state) tuple can be used to set them into a specific 10562 state. Mechanisms not set to a state will be in the 10563 `base.DEFAULT_MECHANISM_STATE`. 10564 """ 10565 now = self.getSituation() 10566 result = [] 10567 for mSpec in mechanisms: 10568 setState = None 10569 if isinstance(mSpec, base.MechanismName): 10570 result.append(now.graph.addMechanism(mSpec, where)) 10571 elif ( 10572 isinstance(mSpec, tuple) 10573 and len(mSpec) == 2 10574 and isinstance(mSpec[0], base.MechanismName) 10575 and isinstance(mSpec[1], base.MechanismState) 10576 ): 10577 result.append(now.graph.addMechanism(mSpec[0], where)) 10578 setState = mSpec[1] 10579 else: 10580 raise TypeError( 10581 f"Invalid mechanism: {repr(mSpec)} (must be a" 10582 f" mechanism name or a (name, state) tuple." 10583 ) 10584 10585 if setState: 10586 self.setMechanismStateNow(result[-1], setState) 10587 10588 return result
Adds one or more mechanisms to the exploration's current graph,
located at the specified decision. Global mechanisms can be
added by using None
for the location. Mechanisms are named, or
a (name, state) tuple can be used to set them into a specific
state. Mechanisms not set to a state will be in the
base.DEFAULT_MECHANISM_STATE
.
10590 def reZone( 10591 self, 10592 zone: base.Zone, 10593 where: base.AnyDecisionSpecifier, 10594 replace: Union[base.Zone, int] = 0 10595 ) -> None: 10596 """ 10597 Alters the current graph without adding a new exploration step. 10598 10599 Calls `DecisionGraph.replaceZonesInHierarchy` targeting the 10600 specified decision. Note that per the logic of that method, ALL 10601 zones at the specified hierarchy level are replaced, even if a 10602 specific zone to replace is specified here. 10603 10604 TODO: not that? 10605 10606 The level value is either specified via `replace` (default 0) or 10607 deduced from the zone provided as the `replace` value using 10608 `DecisionGraph.zoneHierarchyLevel`. 10609 """ 10610 now = self.getSituation() 10611 10612 if isinstance(replace, int): 10613 level = replace 10614 else: 10615 level = now.graph.zoneHierarchyLevel(replace) 10616 10617 now.graph.replaceZonesInHierarchy(where, zone, level)
Alters the current graph without adding a new exploration step.
Calls DecisionGraph.replaceZonesInHierarchy
targeting the
specified decision. Note that per the logic of that method, ALL
zones at the specified hierarchy level are replaced, even if a
specific zone to replace is specified here.
TODO: not that?
The level value is either specified via replace
(default 0) or
deduced from the zone provided as the replace
value using
DecisionGraph.zoneHierarchyLevel
.
10619 def runCommand( 10620 self, 10621 command: commands.Command, 10622 scope: Optional[commands.Scope] = None, 10623 line: int = -1 10624 ) -> commands.CommandResult: 10625 """ 10626 Runs a single `Command` applying effects to the exploration, its 10627 current graph, and the provided execution context, and returning 10628 a command result, which contains the modified scope plus 10629 optional skip and label values (see `CommandResult`). This 10630 function also directly modifies the scope you give it. Variable 10631 references in the command are resolved via entries in the 10632 provided scope. If no scope is given, an empty one is created. 10633 10634 A line number may be supplied for use in error messages; if left 10635 out line -1 will be used. 10636 10637 Raises an error if the command is invalid. 10638 10639 For commands that establish a value as the 'current value', that 10640 value will be stored in the '_' variable. When this happens, the 10641 old contents of '_' are stored in '__' first, and the old 10642 contents of '__' are discarded. Note that non-automatic 10643 assignment to '_' does not move the old value to '__'. 10644 """ 10645 try: 10646 if scope is None: 10647 scope = {} 10648 10649 skip: Union[int, str, None] = None 10650 label: Optional[str] = None 10651 10652 if command.command == 'val': 10653 command = cast(commands.LiteralValue, command) 10654 result = commands.resolveValue(command.value, scope) 10655 commands.pushCurrentValue(scope, result) 10656 10657 elif command.command == 'empty': 10658 command = cast(commands.EstablishCollection, command) 10659 collection = commands.resolveVarName(command.collection, scope) 10660 commands.pushCurrentValue( 10661 scope, 10662 { 10663 'list': [], 10664 'tuple': (), 10665 'set': set(), 10666 'dict': {}, 10667 }[collection] 10668 ) 10669 10670 elif command.command == 'append': 10671 command = cast(commands.AppendValue, command) 10672 target = scope['_'] 10673 addIt = commands.resolveValue(command.value, scope) 10674 if isinstance(target, list): 10675 target.append(addIt) 10676 elif isinstance(target, tuple): 10677 scope['_'] = target + (addIt,) 10678 elif isinstance(target, set): 10679 target.add(addIt) 10680 elif isinstance(target, dict): 10681 raise TypeError( 10682 "'append' command cannot be used with a" 10683 " dictionary. Use 'set' instead." 10684 ) 10685 else: 10686 raise TypeError( 10687 f"Invalid current value for 'append' command." 10688 f" The current value must be a list, tuple, or" 10689 f" set, but it was a '{type(target).__name__}'." 10690 ) 10691 10692 elif command.command == 'set': 10693 command = cast(commands.SetValue, command) 10694 target = scope['_'] 10695 where = commands.resolveValue(command.location, scope) 10696 what = commands.resolveValue(command.value, scope) 10697 if isinstance(target, list): 10698 if not isinstance(where, int): 10699 raise TypeError( 10700 f"Cannot set item in list: index {where!r}" 10701 f" is not an integer." 10702 ) 10703 target[where] = what 10704 elif isinstance(target, tuple): 10705 if not isinstance(where, int): 10706 raise TypeError( 10707 f"Cannot set item in tuple: index {where!r}" 10708 f" is not an integer." 10709 ) 10710 if not ( 10711 0 <= where < len(target) 10712 or -1 >= where >= -len(target) 10713 ): 10714 raise IndexError( 10715 f"Cannot set item in tuple at index" 10716 f" {where}: Tuple has length {len(target)}." 10717 ) 10718 scope['_'] = target[:where] + (what,) + target[where + 1:] 10719 elif isinstance(target, set): 10720 if what: 10721 target.add(where) 10722 else: 10723 try: 10724 target.remove(where) 10725 except KeyError: 10726 pass 10727 elif isinstance(target, dict): 10728 target[where] = what 10729 10730 elif command.command == 'pop': 10731 command = cast(commands.PopValue, command) 10732 target = scope['_'] 10733 if isinstance(target, list): 10734 result = target.pop() 10735 commands.pushCurrentValue(scope, result) 10736 elif isinstance(target, tuple): 10737 result = target[-1] 10738 updated = target[:-1] 10739 scope['__'] = updated 10740 scope['_'] = result 10741 else: 10742 raise TypeError( 10743 f"Cannot 'pop' from a {type(target).__name__}" 10744 f" (current value must be a list or tuple)." 10745 ) 10746 10747 elif command.command == 'get': 10748 command = cast(commands.GetValue, command) 10749 target = scope['_'] 10750 where = commands.resolveValue(command.location, scope) 10751 if isinstance(target, list): 10752 if not isinstance(where, int): 10753 raise TypeError( 10754 f"Cannot get item from list: index" 10755 f" {where!r} is not an integer." 10756 ) 10757 elif isinstance(target, tuple): 10758 if not isinstance(where, int): 10759 raise TypeError( 10760 f"Cannot get item from tuple: index" 10761 f" {where!r} is not an integer." 10762 ) 10763 elif isinstance(target, set): 10764 result = where in target 10765 commands.pushCurrentValue(scope, result) 10766 elif isinstance(target, dict): 10767 result = target[where] 10768 commands.pushCurrentValue(scope, result) 10769 else: 10770 result = getattr(target, where) 10771 commands.pushCurrentValue(scope, result) 10772 10773 elif command.command == 'remove': 10774 command = cast(commands.RemoveValue, command) 10775 target = scope['_'] 10776 where = commands.resolveValue(command.location, scope) 10777 if isinstance(target, (list, tuple)): 10778 # this cast is not correct but suppresses warnings 10779 # given insufficient narrowing by MyPy 10780 target = cast(Tuple[Any, ...], target) 10781 if not isinstance(where, int): 10782 raise TypeError( 10783 f"Cannot remove item from list or tuple:" 10784 f" index {where!r} is not an integer." 10785 ) 10786 scope['_'] = target[:where] + target[where + 1:] 10787 elif isinstance(target, set): 10788 target.remove(where) 10789 elif isinstance(target, dict): 10790 del target[where] 10791 else: 10792 raise TypeError( 10793 f"Cannot use 'remove' on a/an" 10794 f" {type(target).__name__}." 10795 ) 10796 10797 elif command.command == 'op': 10798 command = cast(commands.ApplyOperator, command) 10799 left = commands.resolveValue(command.left, scope) 10800 right = commands.resolveValue(command.right, scope) 10801 op = command.op 10802 if op == '+': 10803 result = left + right 10804 elif op == '-': 10805 result = left - right 10806 elif op == '*': 10807 result = left * right 10808 elif op == '/': 10809 result = left / right 10810 elif op == '//': 10811 result = left // right 10812 elif op == '**': 10813 result = left ** right 10814 elif op == '%': 10815 result = left % right 10816 elif op == '^': 10817 result = left ^ right 10818 elif op == '|': 10819 result = left | right 10820 elif op == '&': 10821 result = left & right 10822 elif op == 'and': 10823 result = left and right 10824 elif op == 'or': 10825 result = left or right 10826 elif op == '<': 10827 result = left < right 10828 elif op == '>': 10829 result = left > right 10830 elif op == '<=': 10831 result = left <= right 10832 elif op == '>=': 10833 result = left >= right 10834 elif op == '==': 10835 result = left == right 10836 elif op == 'is': 10837 result = left is right 10838 else: 10839 raise RuntimeError("Invalid operator '{op}'.") 10840 10841 commands.pushCurrentValue(scope, result) 10842 10843 elif command.command == 'unary': 10844 command = cast(commands.ApplyUnary, command) 10845 value = commands.resolveValue(command.value, scope) 10846 op = command.op 10847 if op == '-': 10848 result = -value 10849 elif op == '~': 10850 result = ~value 10851 elif op == 'not': 10852 result = not value 10853 10854 commands.pushCurrentValue(scope, result) 10855 10856 elif command.command == 'assign': 10857 command = cast(commands.VariableAssignment, command) 10858 varname = commands.resolveVarName(command.varname, scope) 10859 value = commands.resolveValue(command.value, scope) 10860 scope[varname] = value 10861 10862 elif command.command == 'delete': 10863 command = cast(commands.VariableDeletion, command) 10864 varname = commands.resolveVarName(command.varname, scope) 10865 del scope[varname] 10866 10867 elif command.command == 'load': 10868 command = cast(commands.LoadVariable, command) 10869 varname = commands.resolveVarName(command.varname, scope) 10870 commands.pushCurrentValue(scope, scope[varname]) 10871 10872 elif command.command == 'call': 10873 command = cast(commands.FunctionCall, command) 10874 function = command.function 10875 if function.startswith('$'): 10876 function = commands.resolveValue(function, scope) 10877 10878 toCall: Callable 10879 args: Tuple[str, ...] 10880 kwargs: Dict[str, Any] 10881 10882 if command.target == 'builtin': 10883 toCall = commands.COMMAND_BUILTINS[function] 10884 args = (scope['_'],) 10885 kwargs = {} 10886 if toCall == round: 10887 if 'ndigits' in scope: 10888 kwargs['ndigits'] = scope['ndigits'] 10889 elif toCall == range and args[0] is None: 10890 start = scope.get('start', 0) 10891 stop = scope['stop'] 10892 step = scope.get('step', 1) 10893 args = (start, stop, step) 10894 10895 else: 10896 if command.target == 'stored': 10897 toCall = function 10898 elif command.target == 'graph': 10899 toCall = getattr(self.getSituation().graph, function) 10900 elif command.target == 'exploration': 10901 toCall = getattr(self, function) 10902 else: 10903 raise TypeError( 10904 f"Invalid call target '{command.target}'" 10905 f" (must be one of 'builtin', 'stored'," 10906 f" 'graph', or 'exploration'." 10907 ) 10908 10909 # Fill in arguments via kwargs defined in scope 10910 args = () 10911 kwargs = {} 10912 signature = inspect.signature(toCall) 10913 # TODO: Maybe try some type-checking here? 10914 for argName, param in signature.parameters.items(): 10915 if param.kind == inspect.Parameter.VAR_POSITIONAL: 10916 if argName in scope: 10917 args = args + tuple(scope[argName]) 10918 # Else leave args as-is 10919 elif param.kind == inspect.Parameter.KEYWORD_ONLY: 10920 # These must have a default 10921 if argName in scope: 10922 kwargs[argName] = scope[argName] 10923 elif param.kind == inspect.Parameter.VAR_KEYWORD: 10924 # treat as a dictionary 10925 if argName in scope: 10926 argsToUse = scope[argName] 10927 if not isinstance(argsToUse, dict): 10928 raise TypeError( 10929 f"Variable '{argName}' must" 10930 f" hold a dictionary when" 10931 f" calling function" 10932 f" '{toCall.__name__} which" 10933 f" uses that argument as a" 10934 f" keyword catchall." 10935 ) 10936 kwargs.update(scope[argName]) 10937 else: # a normal parameter 10938 if argName in scope: 10939 args = args + (scope[argName],) 10940 elif param.default == inspect.Parameter.empty: 10941 raise TypeError( 10942 f"No variable named '{argName}' has" 10943 f" been defined to supply the" 10944 f" required parameter with that" 10945 f" name for function" 10946 f" '{toCall.__name__}'." 10947 ) 10948 10949 result = toCall(*args, **kwargs) 10950 commands.pushCurrentValue(scope, result) 10951 10952 elif command.command == 'skip': 10953 command = cast(commands.SkipCommands, command) 10954 doIt = commands.resolveValue(command.condition, scope) 10955 if doIt: 10956 skip = commands.resolveValue(command.amount, scope) 10957 if not isinstance(skip, (int, str)): 10958 raise TypeError( 10959 f"Skip amount must be an integer or a label" 10960 f" name (got {skip!r})." 10961 ) 10962 10963 elif command.command == 'label': 10964 command = cast(commands.Label, command) 10965 label = commands.resolveValue(command.name, scope) 10966 if not isinstance(label, str): 10967 raise TypeError( 10968 f"Label name must be a string (got {label!r})." 10969 ) 10970 10971 else: 10972 raise ValueError( 10973 f"Invalid command type: {command.command!r}" 10974 ) 10975 except ValueError as e: 10976 raise commands.CommandValueError(command, line, e) 10977 except TypeError as e: 10978 raise commands.CommandTypeError(command, line, e) 10979 except IndexError as e: 10980 raise commands.CommandIndexError(command, line, e) 10981 except KeyError as e: 10982 raise commands.CommandKeyError(command, line, e) 10983 except Exception as e: 10984 raise commands.CommandOtherError(command, line, e) 10985 10986 return (scope, skip, label)
Runs a single Command
applying effects to the exploration, its
current graph, and the provided execution context, and returning
a command result, which contains the modified scope plus
optional skip and label values (see CommandResult
). This
function also directly modifies the scope you give it. Variable
references in the command are resolved via entries in the
provided scope. If no scope is given, an empty one is created.
A line number may be supplied for use in error messages; if left out line -1 will be used.
Raises an error if the command is invalid.
For commands that establish a value as the 'current value', that value will be stored in the '_' variable. When this happens, the old contents of '_' are stored in '__' first, and the old contents of '__' are discarded. Note that non-automatic assignment to '_' does not move the old value to '__'.
10988 def runCommandBlock( 10989 self, 10990 block: List[commands.Command], 10991 scope: Optional[commands.Scope] = None 10992 ) -> commands.Scope: 10993 """ 10994 Runs a list of commands, using the given scope (or creating a new 10995 empty scope if none was provided). Returns the scope after 10996 running all of the commands, which may also edit the exploration 10997 and/or the current graph of course. 10998 10999 Note that if a skip command would skip past the end of the 11000 block, execution will end. If a skip command would skip before 11001 the beginning of the block, execution will start from the first 11002 command. 11003 11004 Example: 11005 11006 >>> e = DiscreteExploration() 11007 >>> scope = e.runCommandBlock([ 11008 ... commands.command('assign', 'decision', "'START'"), 11009 ... commands.command('call', 'exploration', 'start'), 11010 ... commands.command('assign', 'where', '$decision'), 11011 ... commands.command('assign', 'transition', "'left'"), 11012 ... commands.command('call', 'exploration', 'observe'), 11013 ... commands.command('assign', 'transition', "'right'"), 11014 ... commands.command('call', 'exploration', 'observe'), 11015 ... commands.command('call', 'graph', 'destinationsFrom'), 11016 ... commands.command('call', 'builtin', 'print'), 11017 ... commands.command('assign', 'transition', "'right'"), 11018 ... commands.command('assign', 'destination', "'EastRoom'"), 11019 ... commands.command('call', 'exploration', 'explore'), 11020 ... ]) 11021 {'left': 1, 'right': 2} 11022 >>> scope['decision'] 11023 'START' 11024 >>> scope['where'] 11025 'START' 11026 >>> scope['_'] # result of 'explore' call is dest ID 11027 2 11028 >>> scope['transition'] 11029 'right' 11030 >>> scope['destination'] 11031 'EastRoom' 11032 >>> g = e.getSituation().graph 11033 >>> len(e) 11034 3 11035 >>> len(g) 11036 3 11037 >>> g.namesListing(g) 11038 ' 0 (START)\\n 1 (_u.0)\\n 2 (EastRoom)\\n' 11039 """ 11040 if scope is None: 11041 scope = {} 11042 11043 labelPositions: Dict[str, List[int]] = {} 11044 11045 # Keep going until we've exhausted the commands list 11046 index = 0 11047 while index < len(block): 11048 11049 # Execute the next command 11050 scope, skip, label = self.runCommand( 11051 block[index], 11052 scope, 11053 index + 1 11054 ) 11055 11056 # Increment our index, or apply a skip 11057 if skip is None: 11058 index = index + 1 11059 11060 elif isinstance(skip, int): # Integer skip value 11061 if skip < 0: 11062 index += skip 11063 if index < 0: # can't skip before the start 11064 index = 0 11065 else: 11066 index += skip + 1 # may end loop if we skip too far 11067 11068 else: # must be a label name 11069 if skip in labelPositions: # an established label 11070 # We jump to the last previous index, or if there 11071 # are none, to the first future index. 11072 prevIndices = [ 11073 x 11074 for x in labelPositions[skip] 11075 if x < index 11076 ] 11077 futureIndices = [ 11078 x 11079 for x in labelPositions[skip] 11080 if x >= index 11081 ] 11082 if len(prevIndices) > 0: 11083 index = max(prevIndices) 11084 else: 11085 index = min(futureIndices) 11086 else: # must be a forward-reference 11087 for future in range(index + 1, len(block)): 11088 inspect = block[future] 11089 if inspect.command == 'label': 11090 inspect = cast(commands.Label, inspect) 11091 if inspect.name == skip: 11092 index = future 11093 break 11094 else: 11095 raise KeyError( 11096 f"Skip command indicated a jump to label" 11097 f" {skip!r} but that label had not already" 11098 f" been defined and there is no future" 11099 f" label with that name either (future" 11100 f" labels based on variables cannot be" 11101 f" skipped to from above as their names" 11102 f" are not known yet)." 11103 ) 11104 11105 # If there's a label, record it 11106 if label is not None: 11107 labelPositions.setdefault(label, []).append(index) 11108 11109 # And now the while loop continues, or ends if we're at the 11110 # end of the commands list. 11111 11112 # Return the scope object. 11113 return scope
Runs a list of commands, using the given scope (or creating a new empty scope if none was provided). Returns the scope after running all of the commands, which may also edit the exploration and/or the current graph of course.
Note that if a skip command would skip past the end of the block, execution will end. If a skip command would skip before the beginning of the block, execution will start from the first command.
Example:
>>> e = DiscreteExploration()
>>> scope = e.runCommandBlock([
... commands.command('assign', 'decision', "'START'"),
... commands.command('call', 'exploration', 'start'),
... commands.command('assign', 'where', '$decision'),
... commands.command('assign', 'transition', "'left'"),
... commands.command('call', 'exploration', 'observe'),
... commands.command('assign', 'transition', "'right'"),
... commands.command('call', 'exploration', 'observe'),
... commands.command('call', 'graph', 'destinationsFrom'),
... commands.command('call', 'builtin', 'print'),
... commands.command('assign', 'transition', "'right'"),
... commands.command('assign', 'destination', "'EastRoom'"),
... commands.command('call', 'exploration', 'explore'),
... ])
{'left': 1, 'right': 2}
>>> scope['decision']
'START'
>>> scope['where']
'START'
>>> scope['_'] # result of 'explore' call is dest ID
2
>>> scope['transition']
'right'
>>> scope['destination']
'EastRoom'
>>> g = e.getSituation().graph
>>> len(e)
3
>>> len(g)
3
>>> g.namesListing(g)
' 0 (START)\n 1 (_u.0)\n 2 (EastRoom)\n'