exploration.base

   1"""
   2- Authors: Peter Mawhorter
   3- Consulted:
   4- Date: 2023-12-27
   5- Purpose: Basic types that structure information used for parts of the
   6    core types (see `core.py`).
   7
   8Besides very basic utility functions, code for dealing with these types
   9is defined in other files, notably `core.py` and `parsing.py`.
  10
  11Defines the following base types:
  12
  13- `Domain`
  14- `Zone`
  15- `DecisionID`
  16- `DecisionName`
  17- `DecisionSpecifier`
  18- `AnyDecisionSpecifier`
  19- `Transition`
  20- `TransitionWithOutcomes`
  21- `Capability`
  22- `Token`
  23- `TokenCount`
  24- `Skill`
  25- `Level`
  26- `MechanismID`
  27- `MechanismName`
  28- `MechanismState`
  29- `CapabilitySet`
  30- `DomainFocalization`
  31- `FocalPointName`
  32- `ContextSpecifier`
  33- `FocalPointSpecifier`
  34- `FocalContext`
  35- `FocalContextName`
  36- `State`
  37- `RequirementContext`
  38- `Effect`
  39- `SkillCombination`
  40    - `BestSkill`
  41    - `WorstSkill`
  42    - `CombinedSkill`
  43    - `InverseSkill`
  44    - `ConditionalSkill`
  45- `Challenge`
  46- `Condition`
  47- `Consequence`
  48- `Equivalences`
  49- `Requirement`
  50    - `ReqAny`
  51    - `ReqAll`
  52    - `ReqNot`
  53    - `ReqCapability`
  54    - `ReqTokens`
  55    - `ReqMechanism`
  56    - `ReqNothing`
  57    - `ReqImpossible`
  58- `Tag`
  59- `TagValueTypes`
  60- `TagValue`
  61- `NoTagValue`
  62- `TagUpdateFunction`
  63- `Annotations`
  64- `ZoneInfo`
  65- `ExplorationActionType`
  66- `ExplorationAction`
  67- `DecisionType`
  68- `Situation`
  69- `PointID`
  70- `Coords`
  71- `AnyPoint`
  72- `Feature`
  73- `FeatureID`
  74- `Part`
  75- `FeatureSpecifier`
  76- `AnyFeatureSpecifier`
  77- `MetricSpace`
  78- `FeatureType`
  79- `FeatureRelationshipType`
  80- `FeatureDecision`
  81- `FeatureAffordance`
  82- `FeatureEffect`
  83- `FeatureAction`
  84"""
  85
  86from typing import (
  87    Any, Optional, List, Set, Union, Iterable, Tuple, Dict, TypedDict,
  88    Literal, TypeAlias, TYPE_CHECKING, cast, Callable, get_args,
  89    Sequence, NamedTuple, Generator
  90)
  91
  92import copy
  93import random
  94import re
  95import warnings
  96import math
  97
  98from . import commands
  99
 100# `DecisionGraph` is defined in core.py, but `RequirementContext` needs to
 101# be defined here for a couple of reasons. Thankfully, the details of
 102# `DecisionGraph` don't matter here for type-checking purposes, so we
 103# can provide this fake definition for the type checker:
 104if TYPE_CHECKING:
 105    from .core import DecisionGraph
 106
 107
 108#---------#
 109# Globals #
 110#---------#
 111
 112DEFAULT_DOMAIN: 'Domain' = 'main'
 113"""
 114Default domain value for use when a domain is needed but not specified.
 115"""
 116
 117DEFAULT_FOCAL_CONTEXT_NAME: 'FocalContextName' = 'main'
 118"""
 119Default focal context name for use when a focal context name is needed
 120but not specified.
 121"""
 122
 123DEFAULT_MECHANISM_STATE: 'MechanismState' = 'off'
 124"""
 125Default state we assume in situations where a mechanism hasn't been
 126assigned a state.
 127"""
 128
 129DEFAULT_EXPLORATION_STATUS: 'ExplorationStatus' = 'noticed'
 130"""
 131Default exploration status we assume when no exploration status has been
 132set.
 133"""
 134
 135DEFAULT_SAVE_SLOT: 'SaveSlot' = "slot0"
 136"""
 137Default save slot to use when saving or reverting and the slot isn't
 138specified.
 139"""
 140
 141
 142#------------#
 143# Base Types #
 144#------------#
 145
 146Domain: 'TypeAlias' = str
 147"""
 148A type alias: Domains are identified by their names.
 149
 150A domain represents a separable sphere of action in a game, such as
 151movement in the in-game virtual space vs. movement in a menu (which is
 152really just another kind of virtual space). Progress along a quest or tech
 153tree can also be modeled as a separate domain.
 154
 155Conceptually, domains may be either currently-active or
 156currently-inactive (e.g., when a menu is open vs. closed, or when
 157real-world movement is paused (or not) during menuing; see `State`). Also,
 158the game state stores a set of 'active' decision points for each domain.
 159At each particular game step, the set of options available to the player
 160is the union of all outgoing transitions from active nodes in each active
 161domain.
 162
 163Each decision belongs to a single domain.
 164"""
 165
 166Zone: 'TypeAlias' = str
 167"""
 168A type alias: A zone as part of a `DecisionGraph` is identified using
 169its name.
 170
 171Zones contain decisions and/or other zones; one zone may be contained by
 172multiple other zones, but a zone may not contain itself or otherwise
 173form a containment loop.
 174
 175Note that zone names must be globally unique within a `DecisionGraph`,
 176and by extension, two zones with the same name at different steps of a
 177`DiscreteExploration` are assumed to represent the same space.
 178
 179The empty string is used to mean "default zone" in a few places, so it
 180should not be used as a real zone name.
 181"""
 182
 183DecisionID: 'TypeAlias' = int
 184"""
 185A type alias: decision points are defined by arbitrarily assigned
 186unique-per-`Exploration` ID numbers.
 187
 188A decision represents a location within a decision graph where a decision
 189can be made about where to go, or a dead-end reached by a previous
 190decision. Typically, one room can have multiple decision points in it,
 191even though many rooms have only one. Concepts like 'room' and 'area'
 192that group multiple decisions together (at various scales) are handled
 193by the idea of a `Zone`.
 194"""
 195
 196DecisionName: 'TypeAlias' = str
 197"""
 198A type alias: decisions have names which are strings.
 199
 200Two decisions might share the same name, but they can be disambiguated
 201because they may be in different `Zone`s, and ultimately, they will have
 202different `DecisionID`s.
 203"""
 204
 205
 206class DecisionSpecifier(NamedTuple):
 207    """
 208    A decision specifier attempts to uniquely identify a decision by
 209    name, rather than by ID. See `AnyDecisionSpecifier` for a type which
 210    can also be an ID.
 211
 212    Ambiguity is possible if two decisions share the same name; the
 213    decision specifier provides two means of disambiguation: a domain
 214    may be specified, and a zone may be specified; if either is
 215    specified only decisions within that domain and/or zone will match,
 216    but of course there could still be multiple decisions that match
 217    those criteria that still share names, in which case many operations
 218    will end up raising an `AmbiguousDecisionSpecifierError`.
 219    """
 220    domain: Optional[Domain]
 221    zone: Optional[Zone]
 222    name: DecisionName
 223
 224
 225AnyDecisionSpecifier: 'TypeAlias' = Union[DecisionID, DecisionSpecifier, str]
 226"""
 227A type alias: Collects three different ways of specifying a decision: by
 228ID, by `DecisionSpecifier`, or by a string which will be treated as
 229either a `DecisionName`, or as a `DecisionID` if it can be converted to
 230an integer.
 231"""
 232
 233
 234class InvalidDecisionSpecifierError(ValueError):
 235    """
 236    An error used when a decision specifier is in the wrong format.
 237    """
 238
 239
 240class InvalidMechanismSpecifierError(ValueError):
 241    """
 242    An error used when a mechanism specifier is invalid.
 243    """
 244
 245
 246Transition: 'TypeAlias' = str
 247"""
 248A type alias: transitions are defined by their names.
 249
 250A transition represents a means of travel from one decision to another.
 251Outgoing transition names have to be unique at each decision, but not
 252globally.
 253"""
 254
 255
 256TransitionWithOutcomes: 'TypeAlias' = Tuple[Transition, List[bool]]
 257"""
 258A type alias: a transition with an outcome attached is a tuple that has
 259a `Transition` and then a sequence of booleans indicating
 260success/failure of successive challenges attached to that transition.
 261Challenges encountered during application of transition effects will each
 262have their outcomes dictated by successive booleans in the sequence. If
 263the sequence is shorter than the number of challenges encountered,
 264additional challenges are resolved according to a `ChallengePolicy`
 265specified when applying effects.
 266TODO: Implement this, including parsing.
 267"""
 268
 269
 270AnyTransition: 'TypeAlias' = Union[Transition, TransitionWithOutcomes]
 271"""
 272Either a `Transition` or a `TransitionWithOutcomes`.
 273"""
 274
 275
 276def nameAndOutcomes(transition: AnyTransition) -> TransitionWithOutcomes:
 277    """
 278    Returns a `TransitionWithOutcomes` when given either one of those
 279    already or a base `Transition`. Outcomes will be an empty list when
 280    given a transition alone. Checks that the type actually matches.
 281    """
 282    if isinstance(transition, Transition):
 283        return (transition, [])
 284    else:
 285        if not isinstance(transition, tuple) or len(transition) != 2:
 286            raise TypeError(
 287                f"Transition with outcomes must be a length-2 tuple."
 288                f" Got: {transition!r}"
 289            )
 290        name, outcomes = transition
 291        if not isinstance(name, Transition):
 292            raise TypeError(
 293                f"Transition name must be a string."
 294                f" Got: {name!r}"
 295            )
 296        if (
 297            not isinstance(outcomes, list)
 298         or not all(isinstance(x, bool) for x in outcomes)
 299        ):
 300            raise TypeError(
 301                f"Transition outcomes must be a list of booleans."
 302                f" Got: {outcomes!r}"
 303            )
 304        return transition
 305
 306
 307Capability: 'TypeAlias' = str
 308"""
 309A type alias: capabilities are defined by their names.
 310
 311A capability represents the power to traverse certain transitions. These
 312transitions should have a `Requirement` specified to indicate which
 313capability/ies and/or token(s) can be used to traverse them. Capabilities
 314are usually permanent, but may in some cases be temporary or be
 315temporarily disabled. Capabilities might also combine (e.g., speed booster
 316can't be used underwater until gravity suit is acquired but this is
 317modeled through either `Requirement` expressions or equivalences (see
 318`DecisionGraph.addEquivalence`).
 319
 320By convention, a capability whose name starts with '?' indicates a
 321capability that the player considers unknown, to be filled in later via
 322equivalence. Similarly, by convention capabilities local to a particular
 323zone and/or decision will be prefixed with the name of that zone/decision
 324and '::' (subject to the restriction that capability names may NOT contain
 325the symbols '&', '|', '!', '*', '(', and ')'). Note that in most cases
 326zone-local capabilities can instead be `Mechanism`s, which are zone-local
 327by default.
 328"""
 329
 330Token: 'TypeAlias' = str
 331"""
 332A type alias: tokens are defined by their type names.
 333
 334A token represents an expendable item that can be used to traverse certain
 335transitions a limited number of times (normally once after which the
 336token is used up), or to permanently open certain transitions (perhaps
 337when a certain amount have been acquired).
 338
 339When a key permanently opens only one specific door, or is re-usable to
 340open many doors, that should be represented as a `Capability`, not a
 341token. Only when there is a choice of which door to unlock (and the key is
 342then used up) should keys be represented as tokens.
 343
 344Like capabilities, tokens can be unknown (names starting with '?') or may
 345be zone- or decision-specific (prefixed with a zone/decision name and
 346'::'). Also like capabilities, token names may not contain any of the
 347symbols '&', '|', '!', '*', '(', or ')'.
 348"""
 349
 350TokenCount: 'TypeAlias' = int
 351"""
 352A token count is just an integer.
 353"""
 354
 355Skill: 'TypeAlias' = str
 356"""
 357Names a skill to be used for a challenge. The agent's skill level along
 358with the challenge level determines the probability of success (see
 359`Challenge`). When an agent doesn't list a skill at all, the level is
 360assumed to be 0.
 361"""
 362
 363
 364Level: 'TypeAlias' = int
 365"""
 366A challenge or skill level is just an integer.
 367"""
 368
 369MechanismID: 'TypeAlias' = int
 370"""
 371A type alias: mechanism IDs are integers. See `MechanismName` and
 372`MechanismState`.
 373"""
 374
 375MechanismName: 'TypeAlias' = str
 376"""
 377A type alias: mechanism names are strings. See also `MechanismState`.
 378
 379A mechanism represents something in the world that can be toggled or can
 380otherwise change state, and which may alter the requirements for
 381transitions and/or actions. For example, a switch that opens and closes
 382one or more gates. Mechanisms can be used in `Requirement`s by writing
 383"mechanism:state", for example, "switch:on". Each mechanism can only be
 384in one of its possible states at a time, so an effect that puts a
 385mechanism in one state removes it from all other states. Mechanism states
 386can be the subject of equivalences (see `DecisionGraph.addEquivalence`).
 387
 388Mechanisms have `MechanismID`s and are each associated with a specific
 389decision (unless they are global), and when a mechanism name is
 390mentioned, we look for the first mechanism with that name at the current
 391decision, then in the lowest zone(s) containing that decision, etc. It's
 392an error if we find two mechanisms with the same name at the same level
 393of search. `DecisionGraph.addMechanism` will create a named mechanism
 394and assign it an ID.
 395
 396By convention, a capability whose name starts with '?' indicates a
 397mechanism that the player considers unknown, to be filled in later via
 398equivalence. Mechanism names are resolved by searching incrementally
 399through higher and higher-level zones, then a global mechanism set and
 400finally in all decisions. This means that the same mechanism name can
 401potentially be re-used in different zones, especially when all
 402transitions which depend on that mechanism's state are within the same
 403zone.
 404TODO: G: for global scope?
 405
 406Mechanism states are not tracked as part of `FocalContext`s but are
 407instead tracked as part of the `DecisionGraph` itself. If there are
 408mechanism-like things which operate on a per-character basis or otherwise
 409need to be tracked as part of focal contexts, use decision-local
 410`Capability` names to track them instead.
 411"""
 412
 413
 414class MechanismSpecifier(NamedTuple):
 415    """
 416    Specifies a mechanism either just by name, or with domain and/or
 417    zone and/or decision name hints.
 418    """
 419    domain: Optional[Domain]
 420    zone: Optional[Zone]
 421    decision: Optional[DecisionName]
 422    name: MechanismName
 423
 424
 425def mechanismAt(
 426    name: MechanismName,
 427    domain: Optional[Domain] = None,
 428    zone: Optional[Zone] = None,
 429    decision: Optional[DecisionName] = None
 430) -> MechanismSpecifier:
 431    """
 432    Builds a `MechanismSpecifier` using `None` default hints but
 433    accepting `domain`, `zone`, and/or `decision` hints.
 434    """
 435    return MechanismSpecifier(domain, zone, decision, name)
 436
 437
 438AnyMechanismSpecifier: 'TypeAlias' = Union[
 439    MechanismID,
 440    MechanismName,
 441    MechanismSpecifier
 442]
 443"""
 444Can be a mechanism ID, mechanism name, or a mechanism specifier.
 445"""
 446
 447MechanismState: 'TypeAlias' = str
 448"""
 449A type alias: the state of a mechanism is a string. See `Mechanism`.
 450
 451Each mechanism may have any number of states, but may only be in one of
 452them at once. Mechanism states may NOT be strings which can be
 453converted to integers using `int` because otherwise the 'set' effect
 454would have trouble figuring out whether a mechanism or item count was
 455being set.
 456"""
 457
 458EffectSpecifier: 'TypeAlias' = Tuple[DecisionID, Transition, int]
 459"""
 460Identifies a particular effect that's part of a consequence attached to
 461a certain transition in a `DecisionGraph`. Identifies the effect based
 462on the transition's source `DecisionID` and `Transition` name, plus an
 463integer. The integer specifies the index of the effect in depth-first
 464traversal order of the consequence of the specified transition.
 465
 466TODO: Ensure these are updated when merging/deleting/renaming stuff.
 467"""
 468
 469
 470class CapabilitySet(TypedDict):
 471    """
 472    Represents a set of capabilities, including boolean on/off
 473    `Capability` names, countable `Token`s accumulated, and
 474    integer-leveled skills. It has three slots:
 475
 476    - 'capabilities': A set representing which `Capability`s this
 477        `CapabilitySet` includes.
 478    - 'tokens': A dictionary mapping `Token` types to integers
 479        representing how many of that token type this `CapabilitySet` has
 480        accumulated.
 481    - 'skills': A dictionary mapping `Skill` types to `Level` integers,
 482        representing what skill levels this `CapabilitySet` has.
 483    """
 484    capabilities: Set[Capability]
 485    tokens: Dict[Token, TokenCount]
 486    skills: Dict[Skill, Level]
 487
 488
 489DomainFocalization: 'TypeAlias' = Literal[
 490    'singular',
 491    'plural',
 492    'spreading'
 493]
 494"""
 495How the player experiences decisions in a domain is controlled by
 496focalization, which is specific to a `FocalContext` and a `Domain`:
 497
 498- Typically, focalization is 'singular' and there's a particular avatar
 499    (or co-located group of avatars) that the player follows around, at
 500    each point making a decision based on the position of that avatar
 501    (that avatar is effectively "at" one decision in the graph). Position
 502    in a singular domain is represented as a single `DecisionID`. When the
 503    player picks a transition, this decision ID is updated to the decision
 504    on the other side of that transition.
 505- Less commonly, there can be multiple points of focalization which the
 506    player can freely switch between, meaning the player can at any given
 507    moment decide both which focal point to actually attend to, and what
 508    transition to take at that decision. This is called 'plural'
 509    focalization, and is common in tactics or strategy games where the
 510    player commands multiple units, although those games are often a poor
 511    match for decision mapping approaches. Position in a plural domain is
 512    represented by a dictionary mapping one or more focal-point name
 513    strings to single `DecisionID`s. When the player makes a decision,
 514    they need to specify the name of the focal point for which the
 515    decision is made along with the transition name at that focal point,
 516    and that focal point is updated to the decision on the other side of
 517    the chosen transition.
 518- Focalization can also be 'spreading' meaning that not only can the
 519    player pick options from one of multiple decisions, they also
 520    effectively expand the set of available decisions without having to
 521    give up access to old ones. This happens for example in a tech tree,
 522    where the player can invest some resource to unlock new nodes.
 523    Position in a spreading domain is represented by a set of
 524    `DecisionID`s, and when a transition is chosen, the decision on the
 525    other side is added to the set if it wasn't already present.
 526"""
 527
 528
 529FocalPointName: 'TypeAlias' = str
 530"""
 531The name of a particular focal point in 'plural' `DomainFocalization`.
 532"""
 533
 534
 535ContextSpecifier: 'TypeAlias' = Literal["common", "active"]
 536"""
 537Used when it's necessary to specify whether the common or the active
 538`FocalContext` is being referenced and/or updated.
 539"""
 540
 541
 542FocalPointSpecifier: 'TypeAlias' = Tuple[
 543    ContextSpecifier,
 544    Domain,
 545    FocalPointName
 546]
 547"""
 548Specifies a particular focal point by picking out whether it's in the
 549common or active context, which domain it's in, and the focal point name
 550within that domain. Only needed for domains with 'plural' focalization
 551(see `DomainFocalization`).
 552"""
 553
 554
 555class FocalContext(TypedDict):
 556    """
 557    Focal contexts identify an avatar or similar situation where the player
 558    has certain capabilities available (a `CapabilitySet`) and may also have
 559    position information in one or more `Domain`s (see `State` and
 560    `DomainFocalization`). Normally, only a single `FocalContext` is needed,
 561    but situations where the player swaps between capability sets and/or
 562    positions sometimes call for more.
 563
 564    At each decision step, only a single `FocalContext` is active, and the
 565    capabilities of that context (plus capabilities of the 'common'
 566    context) determine what transitions are traversable. At the same time,
 567    the set of reachable transitions is determined by the focal context's
 568    per-domain position information, including its per-domain
 569    `DomainFocalization` type.
 570
 571    The slots are:
 572
 573    - 'capabilities': A `CapabilitySet` representing what capabilities,
 574        tokens, and skills this context has. Note that capabilities from
 575        the common `FocalContext` are added to these to determine what
 576        transition requirements are met in a given step.
 577    - 'focalization': A mapping from `Domain`s to `DomainFocalization`
 578        specifying how this context is focalized in each domain.
 579    - 'activeDomains': A set of `Domain`s indicating which `Domain`(s) are
 580        active for this focal context right now.
 581    - 'activeDecisions': A mapping from `Domain`s to either single
 582        `DecisionID`s, dictionaries mapping `FocalPointName`s to
 583        optional `DecisionID`s, or sets of `DecisionID`s. Which one is
 584        used depends on the `DomainFocalization` of this context for
 585        that domain. May also be `None` for domains in which no
 586        decisions are active (and in 'plural'-focalization lists,
 587       individual entries may be `None`). Active decisions from the
 588        common `FocalContext` are also considered active at each step.
 589    """
 590    capabilities: CapabilitySet
 591    focalization: Dict[Domain, DomainFocalization]
 592    activeDomains: Set[Domain]
 593    activeDecisions: Dict[
 594        Domain,
 595        Union[
 596            None,
 597            DecisionID,
 598            Dict[FocalPointName, Optional[DecisionID]],
 599            Set[DecisionID]
 600        ]
 601    ]
 602
 603
 604FocalContextName: 'TypeAlias' = str
 605"""
 606`FocalContext`s are assigned names are are indexed under those names
 607within `State` objects (they don't contain their own name). Note that
 608the 'common' focal context does not have a name.
 609"""
 610
 611
 612def getDomainFocalization(
 613    context: FocalContext,
 614    domain: Domain,
 615    defaultFocalization: DomainFocalization = 'singular'
 616) -> DomainFocalization:
 617    """
 618    Fetches the focalization value for the given domain in the given
 619    focal context, setting it to the provided default first if that
 620    focal context didn't have an entry for that domain yet.
 621    """
 622    return context['focalization'].setdefault(domain, defaultFocalization)
 623
 624
 625class State(TypedDict):
 626    """
 627    Represents a game state, including certain exploration-relevant
 628    information, plus possibly extra custom information. Has the
 629    following slots:
 630
 631    - 'common': A single `FocalContext` containing capability and position
 632        information which is always active in addition to the current
 633        `FocalContext`'s information.
 634    - 'contexts': A dictionary mapping strings to `FocalContext`s, which
 635        store capability and position information.
 636    - 'activeContext': A string naming the currently-active
 637        `FocalContext` (a key of the 'contexts' slot).
 638    - 'primaryDecision': A `DecisionID` (or `None`) indicating the
 639        primary decision that is being considered in this state. Whereas
 640        the focalization structures can and often will indicate multiple
 641        active decisions, whichever decision the player just arrived at
 642        via the transition selected in a previous state will be the most
 643        relevant, and we track that here. Of course, for some states
 644        (like a pre-starting initial state) there is no primary
 645        decision.
 646    - 'mechanisms': A dictionary mapping `Mechanism` IDs to
 647        `MechanismState` strings.
 648    - 'exploration': A dictionary mapping decision IDs to exploration
 649        statuses, which tracks how much knowledge the player has of
 650        different decisions.
 651    - 'effectCounts': A dictionary mapping `EffectSpecifier`s to
 652        integers specifying how many times that effect has been
 653        triggered since the beginning of the exploration (including
 654        times that the actual effect was not applied due to delays
 655        and/or charges. This is used to figure out when effects with
 656        charges and/or delays should be applied.
 657    - 'deactivated':  A set of (`DecisionID`, `Transition`) tuples
 658        specifying which transitions have been deactivated. This is used
 659        in addition to transition requirements to figure out which
 660        transitions are traversable.
 661    - 'custom': An arbitrary sub-dictionary representing any kind of
 662        custom game state. In most cases, things can be reasonably
 663        approximated via capabilities and tokens and custom game state is
 664        not needed.
 665    """
 666    common: FocalContext
 667    contexts: Dict[FocalContextName, FocalContext]
 668    activeContext: FocalContextName
 669    primaryDecision: Optional[DecisionID]
 670    mechanisms: Dict[MechanismID, MechanismState]
 671    exploration: Dict[DecisionID, 'ExplorationStatus']
 672    effectCounts: Dict[EffectSpecifier, int]
 673    deactivated: Set[Tuple[DecisionID, Transition]]
 674    custom: dict
 675
 676
 677#-------------------#
 678# Utility Functions #
 679#-------------------#
 680
 681def idOrDecisionSpecifier(
 682    ds: DecisionSpecifier
 683) -> Union[DecisionSpecifier, int]:
 684    """
 685    Given a decision specifier which might use a name that's convertible
 686    to an integer ID, returns the appropriate ID if so, and the original
 687    decision specifier if not, raising an
 688    `InvalidDecisionSpecifierError` if given a specifier with a
 689    convertible name that also has other parts.
 690    """
 691    try:
 692        dID = int(ds.name)
 693    except ValueError:
 694        return ds
 695
 696    if ds.domain is None and ds.zone is None:
 697        return dID
 698    else:
 699        raise InvalidDecisionSpecifierError(
 700            f"Specifier {ds} has an ID name but also includes"
 701            f" domain and/or zone information."
 702        )
 703
 704
 705def spliceDecisionSpecifiers(
 706    base: DecisionSpecifier,
 707    default: DecisionSpecifier
 708) -> DecisionSpecifier:
 709    """
 710    Copies domain and/or zone info from the `default` specifier into the
 711    `base` specifier, returning a new `DecisionSpecifier` without
 712    modifying either argument. Info is only copied where the `base`
 713    specifier has a missing value, although if the base specifier has a
 714    domain but no zone and the domain is different from that of the
 715    default specifier, no zone info is copied.
 716
 717    For example:
 718
 719    >>> d1 = DecisionSpecifier('main', 'zone', 'name')
 720    >>> d2 = DecisionSpecifier('niam', 'enoz', 'eman')
 721    >>> spliceDecisionSpecifiers(d1, d2)
 722    DecisionSpecifier(domain='main', zone='zone', name='name')
 723    >>> spliceDecisionSpecifiers(d2, d1)
 724    DecisionSpecifier(domain='niam', zone='enoz', name='eman')
 725    >>> d3 = DecisionSpecifier(None, None, 'three')
 726    >>> spliceDecisionSpecifiers(d3, d1)
 727    DecisionSpecifier(domain='main', zone='zone', name='three')
 728    >>> spliceDecisionSpecifiers(d3, d2)
 729    DecisionSpecifier(domain='niam', zone='enoz', name='three')
 730    >>> d4 = DecisionSpecifier('niam', None, 'four')
 731    >>> spliceDecisionSpecifiers(d4, d1)  # diff domain -> no zone
 732    DecisionSpecifier(domain='niam', zone=None, name='four')
 733    >>> spliceDecisionSpecifiers(d4, d2)  # same domian -> copy zone
 734    DecisionSpecifier(domain='niam', zone='enoz', name='four')
 735    >>> d5 = DecisionSpecifier(None, 'cone', 'five')
 736    >>> spliceDecisionSpecifiers(d4, d5)  # None domain -> copy zone
 737    DecisionSpecifier(domain='niam', zone='cone', name='four')
 738    """
 739    newDomain = base.domain
 740    if newDomain is None:
 741        newDomain = default.domain
 742    newZone = base.zone
 743    if (
 744        newZone is None
 745    and (newDomain == default.domain or default.domain is None)
 746    ):
 747        newZone = default.zone
 748
 749    return DecisionSpecifier(domain=newDomain, zone=newZone, name=base.name)
 750
 751
 752def mergeCapabilitySets(A: CapabilitySet, B: CapabilitySet) -> CapabilitySet:
 753    """
 754    Merges two capability sets into a new one, where all capabilities in
 755    either original set are active, and token counts and skill levels are
 756    summed.
 757
 758    Example:
 759
 760    >>> cs1 = {
 761    ...    'capabilities': {'fly', 'whistle'},
 762    ...    'tokens': {'subway': 3},
 763    ...    'skills': {'agility': 1, 'puzzling': 3},
 764    ... }
 765    >>> cs2 = {
 766    ...    'capabilities': {'dig', 'fly'},
 767    ...    'tokens': {'subway': 1, 'goat': 2},
 768    ...    'skills': {'agility': -1},
 769    ... }
 770    >>> ms = mergeCapabilitySets(cs1, cs2)
 771    >>> ms['capabilities'] == {'fly', 'whistle', 'dig'}
 772    True
 773    >>> ms['tokens'] == {'subway': 4, 'goat': 2}
 774    True
 775    >>> ms['skills'] == {'agility': 0, 'puzzling': 3}
 776    True
 777    """
 778    # Set up our result
 779    result: CapabilitySet = {
 780        'capabilities': set(),
 781        'tokens': {},
 782        'skills': {}
 783    }
 784
 785    # Merge capabilities
 786    result['capabilities'].update(A['capabilities'])
 787    result['capabilities'].update(B['capabilities'])
 788
 789    # Merge tokens
 790    tokensA = A['tokens']
 791    tokensB = B['tokens']
 792    resultTokens = result['tokens']
 793    for tType, val in tokensA.items():
 794        if tType not in resultTokens:
 795            resultTokens[tType] = val
 796        else:
 797            resultTokens[tType] += val
 798    for tType, val in tokensB.items():
 799        if tType not in resultTokens:
 800            resultTokens[tType] = val
 801        else:
 802            resultTokens[tType] += val
 803
 804    # Merge skills
 805    skillsA = A['skills']
 806    skillsB = B['skills']
 807    resultSkills = result['skills']
 808    for skill, level in skillsA.items():
 809        if skill not in resultSkills:
 810            resultSkills[skill] = level
 811        else:
 812            resultSkills[skill] += level
 813    for skill, level in skillsB.items():
 814        if skill not in resultSkills:
 815            resultSkills[skill] = level
 816        else:
 817            resultSkills[skill] += level
 818
 819    return result
 820
 821
 822def emptyFocalContext() -> FocalContext:
 823    """
 824    Returns a completely empty focal context, which has no capabilities
 825    and which has no associated domains.
 826    """
 827    return {
 828        'capabilities': {
 829            'capabilities': set(),
 830            'tokens': {},
 831            'skills': {}
 832        },
 833        'focalization': {},
 834        'activeDomains': set(),
 835        'activeDecisions': {}
 836    }
 837
 838
 839def basicFocalContext(
 840    domain: Optional[Domain] = None,
 841    focalization: DomainFocalization = 'singular'
 842):
 843    """
 844    Returns a basic focal context, which has no capabilities and which
 845    uses the given focalization (default 'singular') for a single
 846    domain with the given name (default `DEFAULT_DOMAIN`) which is
 847    active but which has no position specified.
 848    """
 849    if domain is None:
 850        domain = DEFAULT_DOMAIN
 851    return {
 852        'capabilities': {
 853            'capabilities': set(),
 854            'tokens': {},
 855            'skills': {}
 856        },
 857        'focalization': {domain: focalization},
 858        'activeDomains': {domain},
 859        'activeDecisions': {domain: None}
 860    }
 861
 862
 863def emptyState() -> State:
 864    """
 865    Returns an empty `State` dictionary. The empty dictionary uses
 866    `DEFAULT_FOCAL_CONTEXT_NAME` as the name of the active
 867    `FocalContext`.
 868    """
 869    return {
 870        'common': emptyFocalContext(),
 871        'contexts': {DEFAULT_FOCAL_CONTEXT_NAME: basicFocalContext()},
 872        'activeContext': DEFAULT_FOCAL_CONTEXT_NAME,
 873        'primaryDecision': None,
 874        'mechanisms': {},
 875        'exploration': {},
 876        'effectCounts': {},
 877        'deactivated': set(),
 878        'custom': {}
 879    }
 880
 881
 882def basicState(
 883    context: Optional[FocalContextName] = None,
 884    domain: Optional[Domain] = None,
 885    focalization: DomainFocalization = 'singular'
 886) -> State:
 887    """
 888    Returns a `State` dictionary with a newly created single active
 889    focal context that uses the given name (default
 890    `DEFAULT_FOCAL_CONTEXT_NAME`). This context is created using
 891    `basicFocalContext` with the given domain and focalization as
 892    arguments (defaults `DEFAULT_DOMAIN` and 'singular').
 893    """
 894    if context is None:
 895        context = DEFAULT_FOCAL_CONTEXT_NAME
 896    return {
 897        'common': emptyFocalContext(),
 898        'contexts': {context: basicFocalContext(domain, focalization)},
 899        'activeContext': context,
 900        'primaryDecision': None,
 901        'mechanisms': {},
 902        'exploration': {},
 903        'effectCounts': {},
 904        'deactivated': set(),
 905        'custom': {}
 906    }
 907
 908
 909def effectiveCapabilitySet(state: State) -> CapabilitySet:
 910    """
 911    Given a `baseTypes.State` object, computes the effective capability
 912    set for that state, which merges capabilities and tokens from the
 913    common `baseTypes.FocalContext` with those of the active one.
 914
 915    Returns a `CapabilitySet`.
 916    """
 917    # Grab relevant contexts
 918    commonContext = state['common']
 919    activeContext = state['contexts'][state['activeContext']]
 920
 921    # Extract capability dictionaries
 922    commonCapabilities = commonContext['capabilities']
 923    activeCapabilities = activeContext['capabilities']
 924
 925    return mergeCapabilitySets(
 926        commonCapabilities,
 927        activeCapabilities
 928    )
 929
 930
 931def combinedDecisionSet(state: State) -> Set[DecisionID]:
 932    """
 933    Given a `State` object, computes the active decision set for that
 934    state, which is the set of decisions at which the player can make an
 935    immediate decision. This depends on the 'common' `FocalContext` as
 936    well as the active focal context, and of course each `FocalContext`
 937    may specify separate active decisions for different domains, separate
 938    sets of active domains, etc. See `FocalContext` and
 939    `DomainFocalization` for more details, as well as `activeDecisionSet`.
 940
 941    Returns a set of `DecisionID`s.
 942    """
 943    commonContext = state['common']
 944    activeContext = state['contexts'][state['activeContext']]
 945    result = set()
 946    for ctx in (commonContext, activeContext):
 947        result |= activeDecisionSet(ctx)
 948
 949    return result
 950
 951
 952def activeDecisionSet(context: FocalContext) -> Set[DecisionID]:
 953    """
 954    Given a `FocalContext`, returns the set of all `DecisionID`s which
 955    are active in that focal context. This includes only decisions which
 956    are in active domains.
 957
 958    For example:
 959
 960    >>> fc = emptyFocalContext()
 961    >>> activeDecisionSet(fc)
 962    set()
 963    >>> fc['focalization'] = {
 964    ...     'Si': 'singular',
 965    ...     'Pl': 'plural',
 966    ...     'Sp': 'spreading'
 967    ... }
 968    >>> fc['activeDomains'] = {'Si'}
 969    >>> fc['activeDecisions'] = {
 970    ...     'Si': 0,
 971    ...     'Pl': {'one': 1, 'two': 2},
 972    ...     'Sp': {3, 4}
 973    ... }
 974    >>> activeDecisionSet(fc)
 975    {0}
 976    >>> fc['activeDomains'] = {'Si', 'Pl'}
 977    >>> sorted(activeDecisionSet(fc))
 978    [0, 1, 2]
 979    >>> fc['activeDomains'] = {'Pl'}
 980    >>> sorted(activeDecisionSet(fc))
 981    [1, 2]
 982    >>> fc['activeDomains'] = {'Sp'}
 983    >>> sorted(activeDecisionSet(fc))
 984    [3, 4]
 985    >>> fc['activeDomains'] = {'Si', 'Pl', 'Sp'}
 986    >>> sorted(activeDecisionSet(fc))
 987    [0, 1, 2, 3, 4]
 988    """
 989    result = set()
 990    decisionsMap = context['activeDecisions']
 991    for domain in context['activeDomains']:
 992        activeGroup = decisionsMap[domain]
 993        if activeGroup is None:
 994            pass
 995        elif isinstance(activeGroup, DecisionID):
 996            result.add(activeGroup)
 997        elif isinstance(activeGroup, dict):
 998            for x in activeGroup.values():
 999                if x is not None:
1000                    result.add(x)
1001        elif isinstance(activeGroup, set):
1002            result.update(activeGroup)
1003        else:
1004            raise TypeError(
1005                f"The FocalContext {repr(context)} has an invalid"
1006                f" active group for domain {repr(domain)}."
1007                f"\nGroup is: {repr(activeGroup)}"
1008            )
1009
1010    return result
1011
1012
1013ExplorationStatus: 'TypeAlias' = Literal[
1014    'unknown',
1015    'hypothesized',
1016    'noticed',
1017    'exploring',
1018    'explored',
1019]
1020"""
1021Exploration statuses track what kind of knowledge the player has about a
1022decision. Note that this is independent of whether or not they've
1023visited it. They are one of the following strings:
1024
1025    - 'unknown': Indicates a decision that the player has absolutely no
1026        knowledge of, not even by implication. Normally such decisions
1027        are not part of a decision map, since the player can only write
1028        down what they've at least seen implied. But in cases where you
1029        want to track exploration of a pre-specified decision map,
1030        decisions that are pre-specified but which the player hasn't had
1031        any hint of yet would have this status.
1032    - 'hypothesized': Indicates a decision that the player can
1033        reasonably expect might be there, but which they haven't yet
1034        confirmed. This comes up when, for example, there's a flashback
1035        during which the player explores an older version of an area,
1036        which they then return to in the "present day." In this case,
1037        the player can hypothesize that the area layout will be the
1038        same, although in the end, it could in fact be different. The
1039        entire flashback area can be cloned and the cloned decisions
1040        marked as hypothesized to represent this. Note that this does
1041        NOT apply to decisions which are definitely implied, such as the
1042        decision on the other side of something the player recognizes as
1043        a door. Those kind of decisions should be marked as 'noticed'.
1044    - 'noticed': Indicates a decision that the player assumes will
1045        exist, and/or which the player has been able to observe some
1046        aspects of indirectly, such as in a cutscene. A decision on the
1047        other side of a closed door is in this category, since even
1048        though the player hasn't seen anything about it, they can pretty
1049        reliably assume there will be some decision there.
1050    - 'exploring': Indicates that a player has started to gain some
1051        knowledge of the transitions available at a decision (beyond the
1052        obvious reciprocals for connections to a 'noticed' decision,
1053        usually but not always by having now visited that decision. Even
1054        the most cursory visit should elevate a decision's exploration
1055        level to 'exploring', except perhaps if the visit is in a
1056        cutscene (although that can also count in some cases).
1057    - 'explored': Indicates that the player believes they have
1058        discovered all of the relevant transitions at this decision, and
1059        there is no need for them to explore it further. This notation
1060        should be based on the player's immediate belief, so even if
1061        it's known that the player will later discover another hidden
1062        option at this transition (or even if the options will later
1063        change), unless the player is cognizant of that, it should be
1064        marked as 'explored' as soon as the player believes they've
1065        exhausted observation of transitions. The player does not have
1066        to have explored all of those transitions yet, including
1067        actions, as long as they're satisfied for now that they've found
1068        all of the options available.
1069"""
1070
1071
1072def moreExplored(
1073    a: ExplorationStatus,
1074    b: ExplorationStatus
1075) -> ExplorationStatus:
1076    """
1077    Returns whichever of the two exploration statuses counts as 'more
1078    explored'.
1079    """
1080    eArgs = get_args(ExplorationStatus)
1081    try:
1082        aIndex = eArgs.index(a)
1083    except ValueError:
1084        raise ValueError(
1085            f"Status {a!r} is not a valid exploration status. Must be"
1086            f" one of: {eArgs!r}"
1087        )
1088    try:
1089        bIndex = eArgs.index(b)
1090    except ValueError:
1091        raise ValueError(
1092            f"Status {b!r} is not a valid exploration status. Must be"
1093            f" one of: {eArgs!r}"
1094        )
1095    if aIndex > bIndex:
1096        return a
1097    else:
1098        return b
1099
1100
1101def statusVisited(status: ExplorationStatus) -> bool:
1102    """
1103    Returns true or false depending on whether the provided status
1104    indicates a decision has been visited or not. The 'exploring' and
1105    'explored' statuses imply a decision has been visisted, but other
1106    statuses do not.
1107    """
1108    return status in ('exploring', 'explored')
1109
1110
1111RestoreFCPart: 'TypeAlias' = Literal[
1112    "capabilities",
1113    "tokens",
1114    "skills",
1115    "positions"
1116]
1117"""
1118Parts of a `FocalContext` that can be restored. Used in `revertedState`.
1119"""
1120
1121RestoreCapabilityPart = Literal["capabilities", "tokens", "skills"]
1122"""
1123Parts of a focal context `CapabilitySet` that can be restored. Used in
1124`revertedState`.
1125"""
1126
1127RestoreFCKey = Literal["focalization", "activeDomains", "activeDecisions"]
1128"""
1129Parts of a `FocalContext` besides the capability set that we can restore.
1130"""
1131
1132RestoreStatePart = Literal["mechanisms", "exploration", "custom"]
1133"""
1134Parts of a State that we can restore besides the `FocalContext` stuff.
1135Doesn't include the stuff covered by the 'effects' restore aspect. See
1136`revertedState` for more.
1137"""
1138
1139
1140def revertedState(
1141    currentStuff: Tuple['DecisionGraph', State],
1142    savedStuff: Tuple['DecisionGraph', State],
1143    revisionAspects: Set[str]
1144) -> Tuple['DecisionGraph', State]:
1145    """
1146    Given two (graph, state) pairs, as well as a set of reversion aspect
1147    strings, returns a (graph, state) pair representing the reverted
1148    graph and state. The provided graphs and states will not be
1149    modified, and the return value will not include references to them,
1150    so modifying the returned state will not modify the original or
1151    saved states or graphs.
1152
1153    If the `revisionAspects` set is empty, then all aspects except
1154    skills, exploration statuses, and the graph will be reverted.
1155
1156    Note that the reversion process can lead to impossible states if the
1157    wrong combination of reversion aspects is used (e.g., reverting the
1158    graph but not focal context position information might lead to
1159    positions that refer to decisions which do not exist).
1160
1161    Valid reversion aspect strings are:
1162    - "common-capabilities", "common-tokens", "common-skills,"
1163        "common-positions" or just "common" for all four. These
1164        control the parts of the common context's `CapabilitySet`
1165        that get reverted, as well as whether the focalization,
1166        active domains, and active decisions get reverted (those
1167        three as "positions").
1168    - "c-*NAME*-capabilities" as well as -tokens, -skills,
1169        -positions, and without a suffix, where *NAME* is the name of
1170        a specific focal context.
1171    - "all-capabilities" as well as -tokens, -skills, -positions,
1172        and -contexts, reverting the relevant part of all focal
1173        contexts except the common one, with "all-contexts" reverting
1174        every part of all non-common focal contexts.
1175    - "current-capabilities" as well as -tokens, -skills, -positions,
1176        and without a suffix, for the currently-active focal context.
1177    - "primary" which reverts the primary decision (some positions should
1178        also be reverted in this case).
1179    - "mechanisms" which reverts mechanism states.
1180    - "exploration" which reverts the exploration state of decisions
1181        (note that the `DecisionGraph` also stores "unconfirmed" tags
1182        which are NOT affected by a revert unless "graph" is specified).
1183    - "effects" which reverts the record of how many times transition
1184        effects have been triggered, plus whether transitions have
1185        been disabled or not.
1186    - "custom" which reverts custom state.
1187    - "graph" reverts the graph itself (but this is usually not
1188        desired). This will still preserve the next-ID value for
1189        assigning new nodes, so that nodes created in a reverted graph
1190        will not re-use IDs from nodes created before the reversion.
1191    - "-*NAME*" where *NAME* is a custom reversion specification
1192        defined using `core.DecisionGraph.reversionType` and available
1193        in the "current" decision graph (note the dash is required
1194        before the custom name). This allows complex reversion systems
1195        to be set up once and referenced repeatedly. Any strings
1196        specified along with a custom reversion type will revert the
1197        specified state in addition to what the custom reversion type
1198        specifies.
1199
1200    For example:
1201
1202    >>> from . import core
1203    >>> g = core.DecisionGraph.example("simple")  # A - B - C triangle
1204    >>> g.setTransitionRequirement('B', 'next', ReqCapability('helmet'))
1205    >>> g.addAction(
1206    ...     'A',
1207    ...     'getHelmet',
1208    ...     consequence=[effect(gain='helmet'), effect(deactivate=True)]
1209    ... )
1210    >>> s0 = basicState()
1211    >>> fc0 = s0['contexts']['main']
1212    >>> fc0['activeDecisions']['main'] = 0  # A
1213    >>> s1 = basicState()
1214    >>> fc1 = s1['contexts']['main']
1215    >>> fc1['capabilities']['capabilities'].add('helmet')
1216    >>> fc1['activeDecisions']['main'] = 1  # B
1217    >>> s1['exploration'] = {0: 'explored', 1: 'exploring', 2: 'unknown'}
1218    >>> s1['effectCounts'] = {(0, 'getHelmet', 1): 1}
1219    >>> s1['deactivated'] = {(0, "getHelmet")}
1220    >>> # Basic reversion of everything except graph & exploration
1221    >>> rg, rs = revertedState((g, s1), (g, s0), set())
1222    >>> rg == g
1223    True
1224    >>> rg is g
1225    False
1226    >>> rs == s0
1227    False
1228    >>> rs is s0
1229    False
1230    >>> rs['contexts'] == s0['contexts']
1231    True
1232    >>> rs['exploration'] == s1['exploration']
1233    True
1234    >>> rs['effectCounts'] = s0['effectCounts']
1235    >>> rs['deactivated'] = s0['deactivated']
1236    >>> # Reverting capabilities but not position, exploration, or effects
1237    >>> rg, rs = revertedState((g, s1), (g, s0), {"current-capabilities"})
1238    >>> rg == g
1239    True
1240    >>> rs == s0 or rs == s1
1241    False
1242    >>> s1['contexts']['main']['capabilities']['capabilities']
1243    {'helmet'}
1244    >>> s0['contexts']['main']['capabilities']['capabilities']
1245    set()
1246    >>> rs['contexts']['main']['capabilities']['capabilities']
1247    set()
1248    >>> s1['contexts']['main']['activeDecisions']['main']
1249    1
1250    >>> s0['contexts']['main']['activeDecisions']['main']
1251    0
1252    >>> rs['contexts']['main']['activeDecisions']['main']
1253    1
1254    >>> # Restore position and effects; that's all that wasn't reverted
1255    >>> rs['contexts']['main']['activeDecisions']['main'] = 0
1256    >>> rs['exploration'] = {}
1257    >>> rs['effectCounts'] = {}
1258    >>> rs['deactivated'] = set()
1259    >>> rs == s0
1260    True
1261    >>> # Reverting position but not state
1262    >>> rg, rs = revertedState((g, s1), (g, s0), {"current-positions"})
1263    >>> rg == g
1264    True
1265    >>> s1['contexts']['main']['capabilities']['capabilities']
1266    {'helmet'}
1267    >>> s0['contexts']['main']['capabilities']['capabilities']
1268    set()
1269    >>> rs['contexts']['main']['capabilities']['capabilities']
1270    {'helmet'}
1271    >>> s1['contexts']['main']['activeDecisions']['main']
1272    1
1273    >>> s0['contexts']['main']['activeDecisions']['main']
1274    0
1275    >>> rs['contexts']['main']['activeDecisions']['main']
1276    0
1277    >>> # Reverting based on specific focal context name
1278    >>> rg2, rs2 = revertedState((g, s1), (g, s0), {"c-main-positions"})
1279    >>> rg2 == rg
1280    True
1281    >>> rs2 == rs
1282    True
1283    >>> # Test of graph reversion
1284    >>> import copy
1285    >>> g2 = copy.deepcopy(g)
1286    >>> g2.addDecision('D')
1287    3
1288    >>> g2.addTransition(2, 'alt', 'D', 'return')
1289    >>> rg, rs = revertedState((g2, s1), (g, s0), {'graph'})
1290    >>> rg == g
1291    True
1292    >>> rg is g
1293    False
1294
1295    TODO: More tests for various other reversion aspects
1296    TODO: Implement per-token-type / per-capability / per-mechanism /
1297    per-skill reversion.
1298    """
1299    # Expand custom references
1300    expandedAspects = set()
1301    queue = list(revisionAspects)
1302    if len(queue) == 0:
1303        queue = [  # don't revert skills, exploration, and graph
1304            "common-capabilities",
1305            "common-tokens",
1306            "common-positions",
1307            "all-capabilities",
1308            "all-tokens",
1309            "all-positions",
1310            "mechanisms",
1311            "primary",
1312            "effects",
1313            "custom"
1314        ]  # we do not include "graph" or "exploration" here...
1315    customLookup = currentStuff[0].reversionTypes
1316    while len(queue) > 0:
1317        aspect = queue.pop(0)
1318        if aspect.startswith('-'):
1319            customName = aspect[1:]
1320            if customName not in customLookup:
1321                raise ValueError(
1322                    f"Custom reversion type {aspect[1:]!r} is invalid"
1323                    f" because that reversion type has not been"
1324                    f" defined. Defined types are:"
1325                    f"\n{list(customLookup.keys())}"
1326                )
1327            queue.extend(customLookup[customName])
1328        else:
1329            expandedAspects.add(aspect)
1330
1331    # Further expand focal-context-part collectives
1332    if "common" in expandedAspects:
1333        expandedAspects.add("common-capabilities")
1334        expandedAspects.add("common-tokens")
1335        expandedAspects.add("common-skills")
1336        expandedAspects.add("common-positions")
1337
1338    if "all-contexts" in expandedAspects:
1339        expandedAspects.add("all-capabilities")
1340        expandedAspects.add("all-tokens")
1341        expandedAspects.add("all-skills")
1342        expandedAspects.add("all-positions")
1343
1344    if "current" in expandedAspects:
1345        expandedAspects.add("current-capabilities")
1346        expandedAspects.add("current-tokens")
1347        expandedAspects.add("current-skills")
1348        expandedAspects.add("current-positions")
1349
1350    # Figure out things to revert that are specific to named focal
1351    # contexts
1352    perFC: Dict[FocalContextName, Set[RestoreFCPart]] = {}
1353    currentFCName = currentStuff[1]['activeContext']
1354    for aspect in expandedAspects:
1355        # For current- stuff, look up current context name
1356        if aspect.startswith("current"):
1357            found = False
1358            part: RestoreFCPart
1359            for part in get_args(RestoreFCPart):
1360                if aspect == f"current-{part}":
1361                    perFC.setdefault(currentFCName, set()).add(part)
1362                    found = True
1363            if not found and aspect != "current":
1364                raise RuntimeError(f"Invalid reversion aspect: {aspect!r}")
1365        elif aspect.startswith("c-"):
1366            if aspect.endswith("-capabilities"):
1367                fcName = aspect[2:-13]
1368                perFC.setdefault(fcName, set()).add("capabilities")
1369            elif aspect.endswith("-tokens"):
1370                fcName = aspect[2:-7]
1371                perFC.setdefault(fcName, set()).add("tokens")
1372            elif aspect.endswith("-skills"):
1373                fcName = aspect[2:-7]
1374                perFC.setdefault(fcName, set()).add("skills")
1375            elif aspect.endswith("-positions"):
1376                fcName = aspect[2:-10]
1377                perFC.setdefault(fcName, set()).add("positions")
1378            else:
1379                fcName = aspect[2:]
1380                forThis = perFC.setdefault(fcName, set())
1381                forThis.add("capabilities")
1382                forThis.add("tokens")
1383                forThis.add("skills")
1384                forThis.add("positions")
1385
1386    currentState = currentStuff[1]
1387    savedState = savedStuff[1]
1388
1389    # Expand all-FC reversions to per-FC entries for each FC in both
1390    # current and prior states
1391    allFCs = set(currentState['contexts']) | set(savedState['contexts'])
1392    for part in get_args(RestoreFCPart):
1393        if f"all-{part}" in expandedAspects:
1394            for fcName in allFCs:
1395                perFC.setdefault(fcName, set()).add(part)
1396
1397    # Revert graph or not
1398    if "graph" in expandedAspects:
1399        resultGraph = copy.deepcopy(savedStuff[0])
1400        # Patch nextID to avoid spurious ID matches
1401        resultGraph.nextID = currentStuff[0].nextID
1402    else:
1403        resultGraph = copy.deepcopy(currentStuff[0])
1404
1405    # Start from non-reverted state copy
1406    resultState = copy.deepcopy(currentState)
1407
1408    # Revert primary decision or not
1409    if "primary" in expandedAspects:
1410        resultState['primaryDecision'] = savedState['primaryDecision']
1411
1412    # Revert specified aspects of the common focal context
1413    savedCommon = savedState['common']
1414    capKey: RestoreCapabilityPart
1415    for capKey in get_args(RestoreCapabilityPart):
1416        if f"common-{part}" in expandedAspects:
1417            resultState['common']['capabilities'][capKey] = copy.deepcopy(
1418                savedCommon['capabilities'][capKey]
1419            )
1420    if "common-positions" in expandedAspects:
1421        fcKey: RestoreFCKey
1422        for fcKey in get_args(RestoreFCKey):
1423            resultState['common'][fcKey] = copy.deepcopy(savedCommon[fcKey])
1424
1425    # Update focal context parts for named focal contexts:
1426    savedContextMap = savedState['contexts']
1427    for fcName, restore in perFC.items():
1428        thisFC = resultState['contexts'].setdefault(
1429            fcName,
1430            emptyFocalContext()
1431        )  # Create FC by name if it didn't exist already
1432        thatFC = savedContextMap.get(fcName)
1433        if thatFC is None:  # what if it's a new one?
1434            if restore == set(get_args(RestoreFCPart)):
1435                # If we're restoring everything and the context didn't
1436                # exist in the prior state, delete it in the restored
1437                # state
1438                del resultState['contexts'][fcName]
1439            else:
1440                # Otherwise update parts of it to be blank since prior
1441                # state didn't have any info
1442                for part in restore:
1443                    if part == "positions":
1444                        thisFC['focalization'] = {}
1445                        thisFC['activeDomains'] = set()
1446                        thisFC['activeDecisions'] = {}
1447                    elif part == "capabilities":
1448                        thisFC['capabilities'][part] = set()
1449                    else:
1450                        thisFC['capabilities'][part] = {}
1451        else:  # same context existed in saved data; update parts
1452            for part in restore:
1453                if part == "positions":
1454                    for fcKey in get_args(RestoreFCKey):  # typed above
1455                        thisFC[fcKey] = copy.deepcopy(thatFC[fcKey])
1456                else:
1457                    thisFC['capabilities'][part] = copy.deepcopy(
1458                        thatFC['capabilities'][part]
1459                    )
1460
1461    # Revert mechanisms, exploration, and/or custom state if specified
1462    statePart: RestoreStatePart
1463    for statePart in get_args(RestoreStatePart):
1464        if statePart in expandedAspects:
1465            resultState[statePart] = copy.deepcopy(savedState[statePart])
1466
1467    # Revert effect tracking if specified
1468    if "effects" in expandedAspects:
1469        resultState['effectCounts'] = copy.deepcopy(
1470            savedState['effectCounts']
1471        )
1472        resultState['deactivated'] = copy.deepcopy(savedState['deactivated'])
1473
1474    return (resultGraph, resultState)
1475
1476
1477#--------------#
1478# Consequences #
1479#--------------#
1480
1481class RequirementContext(NamedTuple):
1482    """
1483    The context necessary to check whether a requirement is satisfied or
1484    not. Also used for computing effective skill levels for
1485    `SkillCombination`s. Includes a `State` that specifies `Capability`
1486    and `Token` states, a `DecisionGraph` (which includes equivalences),
1487    and a set of `DecisionID`s to use as the starting place for finding
1488    mechanisms by name.
1489    """
1490    state: State
1491    graph: 'DecisionGraph'
1492    searchFrom: Set[DecisionID]
1493
1494
1495def getSkillLevel(state: State, skill: Skill) -> Level:
1496    """
1497    Given a `State` and a `Skill`, looks up that skill in both the
1498    common and active `FocalContext`s of the state, and adds those
1499    numbers together to get an effective skill level for that skill.
1500    Note that `SkillCombination`s can be used to set up more complex
1501    logic for skill combinations across different skills; if adding
1502    levels isn't desired between `FocalContext`s, use different skill
1503    names.
1504
1505    If the skill isn't mentioned, the level will count as 0.
1506    """
1507    commonContext = state['common']
1508    activeContext = state['contexts'][state['activeContext']]
1509    return (
1510        commonContext['capabilities']['skills'].get(skill, 0)
1511      + activeContext['capabilities']['skills'].get(skill, 0)
1512    )
1513
1514
1515SaveSlot: TypeAlias = str
1516
1517
1518EffectType = Literal[
1519    'gain',
1520    'lose',
1521    'set',
1522    'toggle',
1523    'deactivate',
1524    'edit',
1525    'goto',
1526    'bounce',
1527    'follow',
1528    'save'
1529]
1530"""
1531The types that effects can use. See `Effect` for details.
1532"""
1533
1534AnyEffectValue: TypeAlias = Union[
1535    Capability,
1536    Tuple[Token, TokenCount],
1537    Tuple[AnyMechanismSpecifier, MechanismState],
1538    Tuple[Literal['skill'], Skill, Level],
1539    Tuple[AnyMechanismSpecifier, List[MechanismState]],
1540    List[Capability],
1541    None,
1542    List[List[commands.Command]],
1543    AnyDecisionSpecifier,
1544    Tuple[AnyDecisionSpecifier, FocalPointName],
1545    Transition,
1546    SaveSlot
1547]
1548"""
1549A union of all possible effect types.
1550"""
1551
1552
1553class Effect(TypedDict):
1554    """
1555    Represents one effect of a transition on the decision graph and/or
1556    game state. The `type` slot is an `EffectType` that indicates what
1557    type of effect it is, and determines what the `value` slot will hold.
1558    The `charges` slot is normally `None`, but when set to an integer,
1559    the effect will only trigger that many times, subtracting one charge
1560    each time until it reaches 0, after which the effect will remain but
1561    be ignored. The `delay` slot is also normally `None`, but when set to
1562    an integer, the effect won't trigger but will instead subtract one
1563    from the delay until it reaches zero, at which point it will start to
1564    trigger (and use up charges if there are any). The 'applyTo' slot
1565    should be either 'common' or 'active' (a `ContextSpecifier`) and
1566    determines which focal context the effect applies to.
1567
1568    The `value` values for each `type` are:
1569
1570    - `'gain'`: A `Capability`, (`Token`, `TokenCount`) pair, or
1571        ('skill', `Skill`, `Level`) triple indicating a capability
1572        gained, some tokens acquired, or skill levels gained.
1573    - `'lose'`: A `Capability`, (`Token`, `TokenCount`) pair, or
1574        ('skill', `Skill`, `Level`) triple indicating a capability lost,
1575        some tokens spent, or skill levels lost. Note that the literal
1576        string 'skill' is added to disambiguate skills from tokens.
1577    - `'set'`: A (`Token`, `TokenCount`) pair, a (`MechanismSpecifier`,
1578        `MechanismState`) pair, or a ('skill', `Skill`, `Level`) triple
1579        indicating the new number of tokens, new mechanism state, or new
1580        skill level to establish. Ignores the old count/level, unlike
1581        'gain' and 'lose.'
1582    - `'toggle'`: A list of capabilities which will be toggled on one
1583        after the other, toggling the rest off, OR, a tuple containing a
1584        mechanism name followed by a list of states to be set one after
1585        the other. Does not work for tokens or skills. If a `Capability`
1586        list only has one item, it will be toggled on or off depending
1587        on whether the player currently has that capability or not,
1588        otherwise, whichever capability in the toggle list is currently
1589        active will determine which one gets activated next (the
1590        subsequent one in the list, wrapping from the end to the start).
1591        Note that equivalences are NOT considered when determining which
1592        capability to turn on, and ALL capabilities in the toggle list
1593        except the next one to turn on are turned off. Also, if none of
1594        the capabilities in the list is currently activated, the first
1595        one will be. For mechanisms, `DEFAULT_MECHANISM_STATE` will be
1596        used as the default state if only one state is provided, since
1597        mechanisms can't be "not in a state." `Mechanism` toggles
1598        function based on the current mechanism state; if it's not in
1599        the list they set the first given state.
1600    - `'deactivate'`: `None`. When the effect is activated, the
1601        transition it applies on will be added to the deactivated set in
1602        the current state. This effect type ignores the 'applyTo' value
1603        since it does not make changes to a `FocalContext`.
1604    - `'edit'`: A list of lists of `Command`s, with each list to be
1605        applied in succession on every subsequent activation of the
1606        transition (like toggle). These can use extra variables '$@' to
1607        refer to the source decision of the transition the edit effect is
1608        attached to, '$@d' to refer to the destination decision, '$@t' to
1609        refer to the transition, and '$@r' to refer to its reciprocal.
1610        Commands are powerful and might edit more than just the
1611        specified focal context.
1612        TODO: How to handle list-of-lists format?
1613    - `'goto'`: Either an `AnyDecisionSpecifier` specifying where the
1614        player should end up, or an (`AnyDecisionSpecifier`,
1615        `FocalPointName`) specifying both where they should end up and
1616        which focal point in the relevant domain should be moved. If
1617        multiple 'goto' values are present on different effects of a
1618        transition, they each trigger in turn (and e.g., might activate
1619        multiple decision points in a spreading-focalized domain). Every
1620        transition has a destination, so 'goto' is not necessary: use it
1621        only when an attempt to take a transition is diverted (and
1622        normally, in conjunction with 'charges', 'delay', and/or as an
1623        effect that's behind a `Challenge` or `Conditional`). If a goto
1624        specifies a destination in a plural-focalized domain, but does
1625        not include a focal point name, then the focal point which was
1626        taking the transition will be the one to move. If that
1627        information is not available, the first focal point created in
1628        that domain will be moved by default. Note that when using
1629        something other than a destination ID as the
1630        `AnyDecisionSpecifier`, it's up to you to ensure that the
1631        specifier is not ambiguous, otherwise taking the transition will
1632        crash the program.
1633    - `'bounce'`: Value will be `None`. Prevents the normal position
1634        update associated with a transition that this effect applies to.
1635        Normally, a transition should be marked with an appropriate
1636        requirement to prevent access, even in cases where access seems
1637        possible until tested (just add the requirement on a step after
1638        the transition is observed where relevant). However, 'bounce' can
1639        be used in cases where there's a challenge to fail, for example.
1640        `bounce` is redundant with `goto`: if a `goto` effect applies on
1641        a certain transition, the presence or absence of `bounce` on the
1642        same transition is ignored, since the new position will be
1643        specified by the `goto` value anyways.
1644    - `'follow'`: Value will be a `Transition` name. A transition with
1645        that name must exist at the destination of the action, and when
1646        the follow effect triggers, the player will immediately take
1647        that transition (triggering any consequences it has) after
1648        arriving at their normal destination (so the exploration status
1649        of the normal destination will also be updated). This can result
1650        in an infinite loop if two 'follow' effects imply transitions
1651        which trigger each other, so don't do that.
1652    - `'save'`: Value will be a string indicating a save-slot name.
1653        Indicates a save point, which can be returned to using a
1654        'revertTo' `ExplorationAction`. The entire game state and current
1655        graph is recorded, including effects of the current consequence
1656        before, but not after, the 'save' effect. However, the graph
1657        configuration is not restored by default (see 'revert'). A revert
1658        effect may specify only parts of the state to revert.
1659
1660    TODO:
1661        'focus',
1662        'foreground',
1663        'background',
1664    """
1665    type: EffectType
1666    applyTo: ContextSpecifier
1667    value: AnyEffectValue
1668    charges: Optional[int]
1669    delay: Optional[int]
1670    hidden: bool
1671
1672
1673def effect(
1674    *,
1675    applyTo: ContextSpecifier = 'active',
1676    gain: Optional[Union[
1677        Capability,
1678        Tuple[Token, TokenCount],
1679        Tuple[Literal['skill'], Skill, Level]
1680    ]] = None,
1681    lose: Optional[Union[
1682        Capability,
1683        Tuple[Token, TokenCount],
1684        Tuple[Literal['skill'], Skill, Level]
1685    ]] = None,
1686    set: Optional[Union[
1687        Tuple[Token, TokenCount],
1688        Tuple[AnyMechanismSpecifier, MechanismState],
1689        Tuple[Literal['skill'], Skill, Level]
1690    ]] = None,
1691    toggle: Optional[Union[
1692        Tuple[AnyMechanismSpecifier, List[MechanismState]],
1693        List[Capability]
1694    ]] = None,
1695    deactivate: Optional[bool] = None,
1696    edit: Optional[List[List[commands.Command]]] = None,
1697    goto: Optional[Union[
1698        AnyDecisionSpecifier,
1699        Tuple[AnyDecisionSpecifier, FocalPointName]
1700    ]] = None,
1701    bounce: Optional[bool] = None,
1702    follow: Optional[Transition] = None,
1703    save: Optional[SaveSlot] = None,
1704    delay: Optional[int] = None,
1705    charges: Optional[int] = None,
1706    hidden: bool = False
1707) -> Effect:
1708    """
1709    Factory for a transition effect which includes default values so you
1710    can just specify effect types that are relevant to a particular
1711    situation. You may not supply values for more than one of
1712    gain/lose/set/toggle/deactivate/edit/goto/bounce, since which one
1713    you use determines the effect type.
1714    """
1715    tCount = len([
1716        x
1717        for x in (
1718            gain,
1719            lose,
1720            set,
1721            toggle,
1722            deactivate,
1723            edit,
1724            goto,
1725            bounce,
1726            follow,
1727            save
1728        )
1729        if x is not None
1730    ])
1731    if tCount == 0:
1732        raise ValueError(
1733            "You must specify one of gain, lose, set, toggle, deactivate,"
1734            " edit, goto, bounce, follow, or save."
1735        )
1736    elif tCount > 1:
1737        raise ValueError(
1738            f"You may only specify one of gain, lose, set, toggle,"
1739            f" deactivate, edit, goto, bounce, follow, or save"
1740            f" (you provided values for {tCount} of those)."
1741        )
1742
1743    result: Effect = {
1744        'type': 'edit',
1745        'applyTo': applyTo,
1746        'value': [],
1747        'delay': delay,
1748        'charges': charges,
1749        'hidden': hidden
1750    }
1751
1752    if gain is not None:
1753        result['type'] = 'gain'
1754        result['value'] = gain
1755    elif lose is not None:
1756        result['type'] = 'lose'
1757        result['value'] = lose
1758    elif set is not None:
1759        result['type'] = 'set'
1760        if (
1761            len(set) == 2
1762        and isinstance(set[0], MechanismName)
1763        and isinstance(set[1], MechanismState)
1764        ):
1765            result['value'] = (
1766                MechanismSpecifier(None, None, None, set[0]),
1767                set[1]
1768            )
1769        else:
1770            result['value'] = set
1771    elif toggle is not None:
1772        result['type'] = 'toggle'
1773        result['value'] = toggle
1774    elif deactivate is not None:
1775        result['type'] = 'deactivate'
1776        result['value'] = None
1777    elif edit is not None:
1778        result['type'] = 'edit'
1779        result['value'] = edit
1780    elif goto is not None:
1781        result['type'] = 'goto'
1782        result['value'] = goto
1783    elif bounce is not None:
1784        result['type'] = 'bounce'
1785        result['value'] = None
1786    elif follow is not None:
1787        result['type'] = 'follow'
1788        result['value'] = follow
1789    elif save is not None:
1790        result['type'] = 'save'
1791        result['value'] = save
1792    else:
1793        raise RuntimeError(
1794            "No effect specified in effect function & check failed."
1795        )
1796
1797    return result
1798
1799
1800class SkillCombination:
1801    """
1802    Represents which skill(s) are used for a `Challenge`, including under
1803    what circumstances different skills might apply using
1804    `Requirement`s. This is an abstract class, use the subclasses
1805    `BestSkill`, `WorstSkill`, `CombinedSkill`, `InverseSkill`, and/or
1806    `ConditionalSkill` to represent a specific situation. To represent a
1807    single required skill, use a `BestSkill` or `CombinedSkill` with
1808    that skill as the only skill.
1809
1810    Use `SkillCombination.effectiveLevel` to figure out the effective
1811    level of the entire requirement in a given situation. Note that
1812    levels from the common and active `FocalContext`s are added together
1813    whenever a specific skill level is referenced.
1814
1815    Some examples:
1816
1817    >>> from . import core
1818    >>> ctx = RequirementContext(emptyState(), core.DecisionGraph(), set())
1819    >>> ctx.state['common']['capabilities']['skills']['brawn'] = 1
1820    >>> ctx.state['common']['capabilities']['skills']['brains'] = 3
1821    >>> ctx.state['common']['capabilities']['skills']['luck'] = -1
1822
1823    1. To represent using just the 'brains' skill, you would use:
1824
1825        `BestSkill('brains')`
1826
1827        >>> sr = BestSkill('brains')
1828        >>> sr.effectiveLevel(ctx)
1829        3
1830
1831        If a skill isn't listed, its level counts as 0:
1832
1833        >>> sr = BestSkill('agility')
1834        >>> sr.effectiveLevel(ctx)
1835        0
1836
1837        To represent using the higher of 'brains' or 'brawn' you'd use:
1838
1839        `BestSkill('brains', 'brawn')`
1840
1841        >>> sr = BestSkill('brains', 'brawn')
1842        >>> sr.effectiveLevel(ctx)
1843        3
1844
1845        The zero default only applies if an unknown skill is in the mix:
1846
1847        >>> sr = BestSkill('luck')
1848        >>> sr.effectiveLevel(ctx)
1849        -1
1850        >>> sr = BestSkill('luck', 'agility')
1851        >>> sr.effectiveLevel(ctx)
1852        0
1853
1854    2. To represent using the lower of 'brains' or 'brawn' you'd use:
1855
1856        `WorstSkill('brains', 'brawn')`
1857
1858        >>> sr = WorstSkill('brains', 'brawn')
1859        >>> sr.effectiveLevel(ctx)
1860        1
1861
1862    3. To represent using 'brawn' if the focal context has the 'brawny'
1863        capability, but brains if not, use:
1864
1865        ```
1866        ConditionalSkill(
1867            ReqCapability('brawny'),
1868            'brawn',
1869            'brains'
1870        )
1871        ```
1872
1873        >>> sr = ConditionalSkill(
1874        ...     ReqCapability('brawny'),
1875        ...     'brawn',
1876        ...     'brains'
1877        ... )
1878        >>> sr.effectiveLevel(ctx)
1879        3
1880        >>> brawny = copy.deepcopy(ctx)
1881        >>> brawny.state['common']['capabilities']['capabilities'].add(
1882        ...     'brawny'
1883        ... )
1884        >>> sr.effectiveLevel(brawny)
1885        1
1886
1887        If the player can still choose to use 'brains' even when they
1888        have the 'brawny' capability, you would do:
1889
1890        >>> sr = ConditionalSkill(
1891        ...     ReqCapability('brawny'),
1892        ...     BestSkill('brawn', 'brains'),
1893        ...     'brains'
1894        ... )
1895        >>> sr.effectiveLevel(ctx)
1896        3
1897        >>> sr.effectiveLevel(brawny)  # can still use brains if better
1898        3
1899
1900    4. To represent using the combined level of the 'brains' and
1901        'brawn' skills, you would use:
1902
1903        `CombinedSkill('brains', 'brawn')`
1904
1905        >>> sr = CombinedSkill('brains', 'brawn')
1906        >>> sr.effectiveLevel(ctx)
1907        4
1908
1909    5. Skill names can be replaced by entire sub-`SkillCombination`s in
1910        any position, so more complex forms are possible:
1911
1912        >>> sr = BestSkill(CombinedSkill('brains', 'luck'), 'brawn')
1913        >>> sr.effectiveLevel(ctx)
1914        2
1915        >>> sr = BestSkill(
1916        ...     ConditionalSkill(
1917        ...         ReqCapability('brawny'),
1918        ...         'brawn',
1919        ...         'brains',
1920        ...     ),
1921        ...     CombinedSkill('brains', 'luck')
1922        ... )
1923        >>> sr.effectiveLevel(ctx)
1924        3
1925        >>> sr.effectiveLevel(brawny)
1926        2
1927    """
1928    def effectiveLevel(self, context: 'RequirementContext') -> Level:
1929        """
1930        Returns the effective `Level` of the skill combination, given
1931        the situation specified by the provided `RequirementContext`.
1932        """
1933        raise NotImplementedError(
1934            "SkillCombination is an abstract class. Use one of its"
1935            " subclsases instead."
1936        )
1937
1938    def __eq__(self, other: Any) -> bool:
1939        raise NotImplementedError(
1940            "SkillCombination is an abstract class and cannot be compared."
1941        )
1942
1943    def __hash__(self) -> int:
1944        raise NotImplementedError(
1945            "SkillCombination is an abstract class and cannot be hashed."
1946        )
1947
1948    def walk(self) -> Generator[
1949        Union['SkillCombination', Skill, Level],
1950        None,
1951        None
1952    ]:
1953        """
1954        Yields this combination and each sub-part in depth-first
1955        traversal order.
1956        """
1957        raise NotImplementedError(
1958            "SkillCombination is an abstract class and cannot be walked."
1959        )
1960
1961    def unparse(self) -> str:
1962        """
1963        Returns a string that `SkillCombination.parse` would turn back
1964        into a `SkillCombination` equivalent to this one. For example:
1965
1966        >>> BestSkill('brains').unparse()
1967        'best(brains)'
1968        >>> WorstSkill('brains', 'brawn').unparse()
1969        'worst(brains, brawn)'
1970        >>> CombinedSkill(
1971        ...     ConditionalSkill(ReqTokens('orb', 3), 'brains', 0),
1972        ...     InverseSkill('luck')
1973        ... ).unparse()
1974        'sum(if(orb*3, brains, 0), ~luck)'
1975        """
1976        raise NotImplementedError(
1977            "SkillCombination is an abstract class and cannot be"
1978            " unparsed."
1979        )
1980
1981
1982class BestSkill(SkillCombination):
1983    def __init__(
1984        self,
1985        *skills: Union[SkillCombination, Skill, Level]
1986    ):
1987        """
1988        Given one or more `SkillCombination` sub-items and/or skill
1989        names or levels, represents a situation where the highest
1990        effective level among the sub-items is used. Skill names
1991        translate to the player's level for that skill (with 0 as a
1992        default) while level numbers translate to that number.
1993        """
1994        if len(skills) == 0:
1995            raise ValueError(
1996                "Cannot create a `BestSkill` with 0 sub-skills."
1997            )
1998        self.skills = skills
1999
2000    def __eq__(self, other: Any) -> bool:
2001        return isinstance(other, BestSkill) and other.skills == self.skills
2002
2003    def __hash__(self) -> int:
2004        result = 1829
2005        for sk in self.skills:
2006            result += hash(sk)
2007        return result
2008
2009    def __repr__(self) -> str:
2010        subs = ', '.join(repr(sk) for sk in self.skills)
2011        return "BestSkill(" + subs + ")"
2012
2013    def walk(self) -> Generator[
2014        Union[SkillCombination, Skill, Level],
2015        None,
2016        None
2017    ]:
2018        yield self
2019        for sub in self.skills:
2020            if isinstance(sub, (Skill, Level)):
2021                yield sub
2022            else:
2023                yield from sub.walk()
2024
2025    def effectiveLevel(self, ctx: 'RequirementContext') -> Level:
2026        """
2027        Determines the effective level of each sub-skill-combo and
2028        returns the highest of those.
2029        """
2030        result = None
2031        level: Level
2032        if len(self.skills) == 0:
2033            raise RuntimeError("Invalid BestSkill: has zero sub-skills.")
2034        for sk in self.skills:
2035            if isinstance(sk, Level):
2036                level = sk
2037            elif isinstance(sk, Skill):
2038                level = getSkillLevel(ctx.state, sk)
2039            elif isinstance(sk, SkillCombination):
2040                level = sk.effectiveLevel(ctx)
2041            else:
2042                raise RuntimeError(
2043                    f"Invalid BestSkill: found sub-skill '{repr(sk)}'"
2044                    f" which is not a skill name string, level integer,"
2045                    f" or SkillCombination."
2046                )
2047            if result is None or result < level:
2048                result = level
2049
2050        assert result is not None
2051        return result
2052
2053    def unparse(self):
2054        result = "best("
2055        for sk in self.skills:
2056            if isinstance(sk, SkillCombination):
2057                result += sk.unparse()
2058            else:
2059                result += str(sk)
2060            result += ', '
2061        return result[:-2] + ')'
2062
2063
2064class WorstSkill(SkillCombination):
2065    def __init__(
2066        self,
2067        *skills: Union[SkillCombination, Skill, Level]
2068    ):
2069        """
2070        Given one or more `SkillCombination` sub-items and/or skill
2071        names or levels, represents a situation where the lowest
2072        effective level among the sub-items is used. Skill names
2073        translate to the player's level for that skill (with 0 as a
2074        default) while level numbers translate to that number.
2075        """
2076        if len(skills) == 0:
2077            raise ValueError(
2078                "Cannot create a `WorstSkill` with 0 sub-skills."
2079            )
2080        self.skills = skills
2081
2082    def __eq__(self, other: Any) -> bool:
2083        return isinstance(other, WorstSkill) and other.skills == self.skills
2084
2085    def __hash__(self) -> int:
2086        result = 7182
2087        for sk in self.skills:
2088            result += hash(sk)
2089        return result
2090
2091    def __repr__(self) -> str:
2092        subs = ', '.join(repr(sk) for sk in self.skills)
2093        return "WorstSkill(" + subs + ")"
2094
2095    def walk(self) -> Generator[
2096        Union[SkillCombination, Skill, Level],
2097        None,
2098        None
2099    ]:
2100        yield self
2101        for sub in self.skills:
2102            if isinstance(sub, (Skill, Level)):
2103                yield sub
2104            else:
2105                yield from sub.walk()
2106
2107    def effectiveLevel(self, ctx: 'RequirementContext') -> Level:
2108        """
2109        Determines the effective level of each sub-skill-combo and
2110        returns the lowest of those.
2111        """
2112        result = None
2113        level: Level
2114        if len(self.skills) == 0:
2115            raise RuntimeError("Invalid WorstSkill: has zero sub-skills.")
2116        for sk in self.skills:
2117            if isinstance(sk, Level):
2118                level = sk
2119            elif isinstance(sk, Skill):
2120                level = getSkillLevel(ctx.state, sk)
2121            elif isinstance(sk, SkillCombination):
2122                level = sk.effectiveLevel(ctx)
2123            else:
2124                raise RuntimeError(
2125                    f"Invalid WorstSkill: found sub-skill '{repr(sk)}'"
2126                    f" which is not a skill name string, level integer,"
2127                    f" or SkillCombination."
2128                )
2129            if result is None or result > level:
2130                result = level
2131
2132        assert result is not None
2133        return result
2134
2135    def unparse(self):
2136        result = "worst("
2137        for sk in self.skills:
2138            if isinstance(sk, SkillCombination):
2139                result += sk.unparse()
2140            else:
2141                result += str(sk)
2142            result += ', '
2143        return result[:-2] + ')'
2144
2145
2146class CombinedSkill(SkillCombination):
2147    def __init__(
2148        self,
2149        *skills: Union[SkillCombination, Skill, Level]
2150    ):
2151        """
2152        Given one or more `SkillCombination` sub-items and/or skill
2153        names or levels, represents a situation where the sum of the
2154        effective levels of each sub-item is used. Skill names
2155        translate to the player's level for that skill (with 0 as a
2156        default) while level numbers translate to that number.
2157        """
2158        if len(skills) == 0:
2159            raise ValueError(
2160                "Cannot create a `CombinedSkill` with 0 sub-skills."
2161            )
2162        self.skills = skills
2163
2164    def __eq__(self, other: Any) -> bool:
2165        return (
2166            isinstance(other, CombinedSkill)
2167        and other.skills == self.skills
2168        )
2169
2170    def __hash__(self) -> int:
2171        result = 2871
2172        for sk in self.skills:
2173            result += hash(sk)
2174        return result
2175
2176    def __repr__(self) -> str:
2177        subs = ', '.join(repr(sk) for sk in self.skills)
2178        return "CombinedSkill(" + subs + ")"
2179
2180    def walk(self) -> Generator[
2181        Union[SkillCombination, Skill, Level],
2182        None,
2183        None
2184    ]:
2185        yield self
2186        for sub in self.skills:
2187            if isinstance(sub, (Skill, Level)):
2188                yield sub
2189            else:
2190                yield from sub.walk()
2191
2192    def effectiveLevel(self, ctx: 'RequirementContext') -> Level:
2193        """
2194        Determines the effective level of each sub-skill-combo and
2195        returns the sum of those, with 0 as a default.
2196        """
2197        result = 0
2198        level: Level
2199        if len(self.skills) == 0:
2200            raise RuntimeError(
2201                "Invalid CombinedSkill: has zero sub-skills."
2202            )
2203        for sk in self.skills:
2204            if isinstance(sk, Level):
2205                level = sk
2206            elif isinstance(sk, Skill):
2207                level = getSkillLevel(ctx.state, sk)
2208            elif isinstance(sk, SkillCombination):
2209                level = sk.effectiveLevel(ctx)
2210            else:
2211                raise RuntimeError(
2212                    f"Invalid CombinedSkill: found sub-skill '{repr(sk)}'"
2213                    f" which is not a skill name string, level integer,"
2214                    f" or SkillCombination."
2215                )
2216            result += level
2217
2218        assert result is not None
2219        return result
2220
2221    def unparse(self):
2222        result = "sum("
2223        for sk in self.skills:
2224            if isinstance(sk, SkillCombination):
2225                result += sk.unparse()
2226            else:
2227                result += str(sk)
2228            result += ', '
2229        return result[:-2] + ')'
2230
2231
2232class InverseSkill(SkillCombination):
2233    def __init__(
2234        self,
2235        invert: Union[SkillCombination, Skill, Level]
2236    ):
2237        """
2238        Represents the effective level of the given `SkillCombination`,
2239        the level of the given `Skill`, or just the provided specific
2240        `Level`, except inverted (multiplied by -1).
2241        """
2242        self.invert = invert
2243
2244    def __eq__(self, other: Any) -> bool:
2245        return (
2246            isinstance(other, InverseSkill)
2247        and other.invert == self.invert
2248        )
2249
2250    def __hash__(self) -> int:
2251        return 3193 + hash(self.invert)
2252
2253    def __repr__(self) -> str:
2254        return "InverseSkill(" + repr(self.invert) + ")"
2255
2256    def walk(self) -> Generator[
2257        Union[SkillCombination, Skill, Level],
2258        None,
2259        None
2260    ]:
2261        yield self
2262        if isinstance(self.invert, SkillCombination):
2263            yield from self.invert.walk()
2264        else:
2265            yield self.invert
2266
2267    def effectiveLevel(self, ctx: 'RequirementContext') -> Level:
2268        """
2269        Determines whether the requirement is satisfied or not and then
2270        returns the effective level of either the `ifSatisfied` or
2271        `ifNot` skill combination, as appropriate.
2272        """
2273        if isinstance(self.invert, Level):
2274            return -self.invert
2275        elif isinstance(self.invert, Skill):
2276            return -getSkillLevel(ctx.state, self.invert)
2277        elif isinstance(self.invert, SkillCombination):
2278            return -self.invert.effectiveLevel(ctx)
2279        else:
2280            raise RuntimeError(
2281                f"Invalid InverseSkill: invert value {repr(self.invert)}"
2282                f" The invert value must be a Level (int), a Skill"
2283                f" (str), or a SkillCombination."
2284            )
2285
2286    def unparse(self):
2287        # TODO: Move these to `parsing` to avoid hard-coded tokens here?
2288        if isinstance(self.invert, SkillCombination):
2289            return '~' + self.invert.unparse()
2290        else:
2291            return '~' + str(self.invert)
2292
2293
2294class ConditionalSkill(SkillCombination):
2295    def __init__(
2296        self,
2297        requirement: 'Requirement',
2298        ifSatisfied: Union[SkillCombination, Skill, Level],
2299        ifNot: Union[SkillCombination, Skill, Level] = 0
2300    ):
2301        """
2302        Given a `Requirement` and two different sub-`SkillCombination`s,
2303        which can also be `Skill` names or fixed `Level`s, represents
2304        situations where which skills come into play depends on what
2305        capabilities the player has. In situations where the given
2306        requirement is satisfied, the `ifSatisfied` combination's
2307        effective level is used, and otherwise the `ifNot` level is
2308        used. By default `ifNot` is just the fixed level 0.
2309        """
2310        self.requirement = requirement
2311        self.ifSatisfied = ifSatisfied
2312        self.ifNot = ifNot
2313
2314    def __eq__(self, other: Any) -> bool:
2315        return (
2316            isinstance(other, ConditionalSkill)
2317        and other.requirement == self.requirement
2318        and other.ifSatisfied == self.ifSatisfied
2319        and other.ifNot == self.ifNot
2320        )
2321
2322    def __hash__(self) -> int:
2323        return (
2324            1278
2325          + hash(self.requirement)
2326          + hash(self.ifSatisfied)
2327          + hash(self.ifNot)
2328        )
2329
2330    def __repr__(self) -> str:
2331        return (
2332            "ConditionalSkill("
2333          + repr(self.requirement) + ", "
2334          + repr(self.ifSatisfied) + ", "
2335          + repr(self.ifNot)
2336          + ")"
2337        )
2338
2339    def walk(self) -> Generator[
2340        Union[SkillCombination, Skill, Level],
2341        None,
2342        None
2343    ]:
2344        yield self
2345        for sub in (self.ifSatisfied, self.ifNot):
2346            if isinstance(sub, SkillCombination):
2347                yield from sub.walk()
2348            else:
2349                yield sub
2350
2351    def effectiveLevel(self, ctx: 'RequirementContext') -> Level:
2352        """
2353        Determines whether the requirement is satisfied or not and then
2354        returns the effective level of either the `ifSatisfied` or
2355        `ifNot` skill combination, as appropriate.
2356        """
2357        if self.requirement.satisfied(ctx):
2358            use = self.ifSatisfied
2359            sat = True
2360        else:
2361            use = self.ifNot
2362            sat = False
2363
2364        if isinstance(use, Level):
2365            return use
2366        elif isinstance(use, Skill):
2367            return getSkillLevel(ctx.state, use)
2368        elif isinstance(use, SkillCombination):
2369            return use.effectiveLevel(ctx)
2370        else:
2371            raise RuntimeError(
2372                f"Invalid ConditionalSkill: Requirement was"
2373                f" {'not ' if not sat else ''}satisfied, and the"
2374                f" corresponding skill value was not a level, skill, or"
2375                f" SkillCombination: {repr(use)}"
2376            )
2377
2378    def unparse(self):
2379        result = f"if({self.requirement.unparse()}, "
2380        if isinstance(self.ifSatisfied, SkillCombination):
2381            result += self.ifSatisfied.unparse()
2382        else:
2383            result += str(self.ifSatisfied)
2384        result += ', '
2385        if isinstance(self.ifNot, SkillCombination):
2386            result += self.ifNot.unparse()
2387        else:
2388            result += str(self.ifNot)
2389        return result + ')'
2390
2391
2392class Challenge(TypedDict):
2393    """
2394    Represents a random binary decision between two possible outcomes,
2395    only one of which will actually occur. The 'outcome' can be set to
2396    `True` or `False` to represent that the outcome of the challenge has
2397    been observed, or to `None` (the default) to represent a pending
2398    challenge. The chance of 'success' is determined by the associated
2399    skill(s) and the challenge level, although one or both may be
2400    unknown in which case a variable is used in place of a concrete
2401    value. Probabilities that are of the form 1/2**n or (2**n - 1) /
2402    (2**n) can be represented, the specific formula for the chance of
2403    success is for a challenge with a single skill is:
2404
2405        s = interacting entity's skill level in associated skill
2406        c = challenge level
2407        P(success) = {
2408          1 - 1/2**(1 + s - c)    if s > c
2409          1/2                     if s == c
2410          1/2**(1 + c - s)        if c > s
2411        }
2412
2413    This probability formula is equivalent to the following procedure:
2414
2415    1. Flip one coin, plus one additional coin for each level difference
2416        between the skill and challenge levels.
2417    2. If the skill level is equal to or higher than the challenge
2418        level, the outcome is success if any single coin comes up heads.
2419    3. If the skill level is less than the challenge level, then the
2420        outcome is success only if *all* coins come up heads.
2421    4. If the outcome is not success, it is failure.
2422
2423    Multiple skills can be combined into a `SkillCombination`, which can
2424    use the max or min of several skills, add skill levels together,
2425    and/or have skills which are only relevant when a certain
2426    `Requirement` is satisfied. If a challenge has no skills associated
2427    with it, then the player's skill level counts as 0.
2428
2429    The slots are:
2430
2431    - 'skills': A `SkillCombination` that specifies the relevant
2432        skill(s).
2433    - 'level': An integer specifying the level of the challenge. Along
2434        with the appropriate skill level of the interacting entity, this
2435        determines the probability of success or failure.
2436    - 'success': A `Consequence` which will happen when the outcome is
2437        success. Note that since a `Consequence` can be a `Challenge`,
2438        multi-outcome challenges can be represented by chaining multiple
2439        challenges together.
2440    - 'failure': A `Consequence` which will happen when the outcome is
2441        failure.
2442    - 'outcome': The outcome of the challenge: `True` means success,
2443        `False` means failure, and `None` means "not known (yet)."
2444    """
2445    skills: SkillCombination
2446    level: Level
2447    success: 'Consequence'
2448    failure: 'Consequence'
2449    outcome: Optional[bool]
2450
2451
2452def challenge(
2453    skills: Optional[SkillCombination] = None,
2454    level: Level = 0,
2455    success: Optional['Consequence'] = None,
2456    failure: Optional['Consequence'] = None,
2457    outcome: Optional[bool] = None
2458):
2459    """
2460    Factory for `Challenge`s, defaults to empty effects for both success
2461    and failure outcomes, so that you can just provide one or the other
2462    if you need to. Skills defaults to an empty list, the level defaults
2463    to 0 and the outcome defaults to `None` which means "not (yet)
2464    known."
2465    """
2466    if skills is None:
2467        skills = BestSkill(0)
2468    if success is None:
2469        success = []
2470    if failure is None:
2471        failure = []
2472    return {
2473        'skills': skills,
2474        'level': level,
2475        'success': success,
2476        'failure': failure,
2477        'outcome': outcome
2478    }
2479
2480
2481class Condition(TypedDict):
2482    """
2483    Represents a condition over `Capability`, `Token`, and/or `Mechanism`
2484    states which applies to one or more `Effect`s or `Challenge`s as part
2485    of a `Consequence`. If the specified `Requirement` is satisfied, the
2486    included `Consequence` is treated as if it were part of the
2487    `Consequence` that the `Condition` is inside of, if the requirement
2488    is not satisfied, then the internal `Consequence` is skipped and the
2489    alternate consequence is used instead. Either sub-consequence may of
2490    course be an empty list.
2491    """
2492    condition: 'Requirement'
2493    consequence: 'Consequence'
2494    alternative: 'Consequence'
2495
2496
2497def condition(
2498    condition: 'Requirement',
2499    consequence: 'Consequence',
2500    alternative: Optional['Consequence'] = None
2501):
2502    """
2503    Factory for conditions that just glues the given requirement,
2504    consequence, and alternative together. The alternative defaults to
2505    an empty list if not specified.
2506    """
2507    if alternative is None:
2508        alternative = []
2509    return {
2510        'condition': condition,
2511        'consequence': consequence,
2512        'alternative': alternative
2513    }
2514
2515
2516Consequence: 'TypeAlias' = List[Union[Challenge, Effect, Condition]]
2517"""
2518Represents a theoretical space of consequences that can occur as a
2519result of attempting an action, or as the success or failure outcome for
2520a challenge. It includes multiple effects and/or challenges, and since
2521challenges have consequences as their outcomes, consequences form a tree
2522structure, with `Effect`s as their leaves. Items in a `Consequence` are
2523applied in-order resolving all outcomes and sub-outcomes of a challenge
2524before considering the next item in the top-level consequence.
2525
2526The `Challenge`s in a `Consequence` may have their 'outcome' set to
2527`None` to represent a theoretical challenge, or it may be set to either
2528`True` or `False` to represent an observed outcome.
2529"""
2530
2531
2532ChallengePolicy: 'TypeAlias' = Literal[
2533    'random',
2534    'mostLikely',
2535    'fewestEffects',
2536    'success',
2537    'failure',
2538    'specified',
2539]
2540"""
2541Specifies how challenges should be resolved. See
2542`observeChallengeOutcomes`.
2543"""
2544
2545
2546#-------------------------------#
2547# Consequence Utility Functions #
2548#-------------------------------#
2549
2550
2551def resetChallengeOutcomes(consequence: Consequence) -> None:
2552    """
2553    Traverses all sub-consequences of the given consequence, setting the
2554    outcomes of any `Challenge`s it encounters to `None`, to prepare for
2555    a fresh call to `observeChallengeOutcomes`.
2556
2557    Resets all outcomes in every branch, regardless of previous
2558    outcomes.
2559
2560    For example:
2561
2562    >>> from . import core
2563    >>> e = core.emptySituation()
2564    >>> c = challenge(
2565    ...     success=[effect(gain=('money', 12))],
2566    ...     failure=[effect(lose=('money', 10))]
2567    ... )  # skill defaults to 'luck', level to 0, and outcome to None
2568    >>> c['outcome'] is None  # default outcome is None
2569    True
2570    >>> r = observeChallengeOutcomes(e, [c], policy='mostLikely')
2571    >>> r[0]['outcome']
2572    True
2573    >>> c['outcome']  # original outcome is changed from None
2574    True
2575    >>> r[0] is c
2576    True
2577    >>> resetChallengeOutcomes([c])
2578    >>> c['outcome'] is None  # now has been reset
2579    True
2580    >>> r[0]['outcome'] is None  # same object...
2581    True
2582    >>> resetChallengeOutcomes(c)  # can't reset just a Challenge
2583    Traceback (most recent call last):
2584    ...
2585    TypeError...
2586    >>> r = observeChallengeOutcomes(e, [c], policy='success')
2587    >>> r[0]['outcome']
2588    True
2589    >>> r = observeChallengeOutcomes(e, [c], policy='failure')
2590    >>> r[0]['outcome']  # wasn't reset
2591    True
2592    >>> resetChallengeOutcomes([c])  # now reset it
2593    >>> c['outcome'] is None
2594    True
2595    >>> r = observeChallengeOutcomes(e, [c], policy='failure')
2596    >>> r[0]['outcome']  # was reset
2597    False
2598    """
2599    if not isinstance(consequence, list):
2600        raise TypeError(
2601            f"Invalid consequence: must be a list."
2602            f"\nGot: {repr(consequence)}"
2603        )
2604
2605    for item in consequence:
2606        if not isinstance(item, dict):
2607            raise TypeError(
2608                f"Invalid consequence: items in the list must be"
2609                f" Effects, Challenges, or Conditions."
2610                f"\nGot item: {repr(item)}"
2611            )
2612        if 'skills' in item:  # must be a Challenge
2613            item = cast(Challenge, item)
2614            item['outcome'] = None
2615            # reset both branches
2616            resetChallengeOutcomes(item['success'])
2617            resetChallengeOutcomes(item['failure'])
2618
2619        elif 'value' in item:  # an Effect
2620            continue  # Effects don't have sub-outcomes
2621
2622        elif 'condition' in item:  # a Condition
2623            item = cast(Condition, item)
2624            resetChallengeOutcomes(item['consequence'])
2625            resetChallengeOutcomes(item['alternative'])
2626
2627        else:  # bad dict
2628            raise TypeError(
2629                f"Invalid consequence: items in the list must be"
2630                f" Effects, Challenges, or Conditions (got a dictionary"
2631                f" without 'skills', 'value', or 'condition' keys)."
2632                f"\nGot item: {repr(item)}"
2633            )
2634
2635
2636def observeChallengeOutcomes(
2637    context: RequirementContext,
2638    consequence: Consequence,
2639    location: Optional[Set[DecisionID]] = None,
2640    policy: ChallengePolicy = 'random',
2641    knownOutcomes: Optional[List[bool]] = None,
2642    makeCopy: bool = False
2643) -> Consequence:
2644    """
2645    Given a `RequirementContext` (for `Capability`, `Token`, and `Skill`
2646    info as well as equivalences in the `DecisionGraph` and a
2647    search-from location for mechanism names) and a `Conseqeunce` to be
2648    observed, sets the 'outcome' value for each `Challenge` in it to
2649    either `True` or `False` by determining an outcome for each
2650    `Challenge` that's relevant (challenges locked behind unsatisfied
2651    `Condition`s or on untaken branches of other challenges are not
2652    given outcomes). `Challenge`s that already have assigned outcomes
2653    re-use those outcomes, call `resetChallengeOutcomes` beforehand if
2654    you want to re-decide each challenge with a new policy, and use the
2655    'specified' policy if you want to ensure only pre-specified outcomes
2656    are used.
2657
2658    Normally, the return value is just the original `consequence`
2659    object. However, if `makeCopy` is set to `True`, a deep copy is made
2660    and returned, so the original is not modified. One potential problem
2661    with this is that effects will be copied in this process, which
2662    means that if they are applied, things like delays and toggles won't
2663    update properly. `makeCopy` should thus normally not be used.
2664
2665    The 'policy' value can be one of the `ChallengePolicy` values. The
2666    default is 'random', in which case the `random.random` function is
2667    used to determine each outcome, based on the probability derived
2668    from the challenge level and the associated skill level. The other
2669    policies are:
2670
2671    - 'mostLikely': the result of each challenge will be whichever
2672        outcome is more likely, with success always happening instead of
2673        failure when the probabilities are 50/50.
2674    - 'fewestEffects`: whichever combination of outcomes leads to the
2675        fewest total number of effects will be chosen (modulo satisfying
2676        requirements of `Condition`s). Note that there's no estimation
2677        of the severity of effects, just the raw number. Ties in terms
2678        of number of effects are broken towards successes. This policy
2679        involves evaluating all possible outcome combinations to figure
2680        out which one has the fewest effects.
2681    - 'success' or 'failure': all outcomes will either succeed, or
2682        fail, as specified. Note that success/failure may cut off some
2683        challenges, so it's not the case that literally every challenge
2684        will succeed/fail; some may be skipped because of the
2685        specified success/failure of a prior challenge.
2686    - 'specified': all outcomes have already been specified, and those
2687        pre-specified outcomes should be used as-is.
2688
2689
2690    In call cases, outcomes specified via `knownOutcomes` take precedence
2691    over the challenge policy. The `knownOutcomes` list will be emptied
2692    out as this function works, but extra consequences beyond what's
2693    needed will be ignored (and left in the list).
2694
2695    Note that there are limits on the resolution of Python's random
2696    number generation; for challenges with extremely high or low levels
2697    relative to the associated skill(s) where the probability of success
2698    is very close to 1 or 0, there may not actually be any chance of
2699    success/failure at all. Typically you can ignore this, because such
2700    cases should not normally come up in practice, and because the odds
2701    of success/failure in those cases are such that to notice the
2702    missing possibility share you'd have to simulate outcomes a
2703    ridiculous number of times.
2704
2705    TODO: Location examples; move some of these to a separate testing
2706    file.
2707
2708    For example:
2709
2710    >>> random.seed(17)
2711    >>> warnings.filterwarnings('error')
2712    >>> from . import core
2713    >>> e = core.emptySituation()
2714    >>> c = challenge(
2715    ...     success=[effect(gain=('money', 12))],
2716    ...     failure=[effect(lose=('money', 10))]
2717    ... )  # skill defaults to 'luck', level to 0, and outcome to None
2718    >>> c['outcome'] is None  # default outcome is None
2719    True
2720    >>> r = observeChallengeOutcomes(e, [c])
2721    >>> r[0]['outcome']
2722    False
2723    >>> c['outcome']  # original outcome is changed from None
2724    False
2725    >>> all(
2726    ...     observeChallengeOutcomes(e, [c])[0]['outcome'] is False
2727    ...     for i in range(20)
2728    ... )  # no reset -> same outcome
2729    True
2730    >>> resetChallengeOutcomes([c])
2731    >>> observeChallengeOutcomes(e, [c])[0]['outcome']  # Random after reset
2732    False
2733    >>> resetChallengeOutcomes([c])
2734    >>> observeChallengeOutcomes(e, [c])[0]['outcome']  # Random after reset
2735    False
2736    >>> resetChallengeOutcomes([c])
2737    >>> observeChallengeOutcomes(e, [c])[0]['outcome'] # Random after reset
2738    True
2739    >>> observeChallengeOutcomes(e, c)  # Can't resolve just a Challenge
2740    Traceback (most recent call last):
2741    ...
2742    TypeError...
2743    >>> allSame = []
2744    >>> for i in range(20):
2745    ...    resetChallengeOutcomes([c])
2746    ...    obs = observeChallengeOutcomes(e, [c, c])
2747    ...    allSame.append(obs[0]['outcome'] == obs[1]['outcome'])
2748    >>> allSame == [True]*20
2749    True
2750    >>> different = []
2751    >>> for i in range(20):
2752    ...    resetChallengeOutcomes([c])
2753    ...    obs = observeChallengeOutcomes(e, [c, copy.deepcopy(c)])
2754    ...    different.append(obs[0]['outcome'] == obs[1]['outcome'])
2755    >>> False in different
2756    True
2757    >>> all(  # Tie breaks towards success
2758    ...     (
2759    ...         resetChallengeOutcomes([c]),
2760    ...         observeChallengeOutcomes(e, [c], policy='mostLikely')
2761    ...     )[1][0]['outcome'] is True
2762    ...     for i in range(20)
2763    ... )
2764    True
2765    >>> all(  # Tie breaks towards success
2766    ...     (
2767    ...         resetChallengeOutcomes([c]),
2768    ...         observeChallengeOutcomes(e, [c], policy='fewestEffects')
2769    ...     )[1][0]['outcome'] is True
2770    ...     for i in range(20)
2771    ... )
2772    True
2773    >>> all(
2774    ...     (
2775    ...         resetChallengeOutcomes([c]),
2776    ...         observeChallengeOutcomes(e, [c], policy='success')
2777    ...     )[1][0]['outcome'] is True
2778    ...     for i in range(20)
2779    ... )
2780    True
2781    >>> all(
2782    ...     (
2783    ...         resetChallengeOutcomes([c]),
2784    ...         observeChallengeOutcomes(e, [c], policy='failure')
2785    ...     )[1][0]['outcome'] is False
2786    ...     for i in range(20)
2787    ... )
2788    True
2789    >>> c['outcome'] = False  # Fix the outcome; now policy is ignored
2790    >>> observeChallengeOutcomes(e, [c], policy='success')[0]['outcome']
2791    False
2792    >>> c = challenge(
2793    ...     skills=BestSkill('charisma'),
2794    ...     level=8,
2795    ...     success=[
2796    ...         challenge(
2797    ...             skills=BestSkill('strength'),
2798    ...             success=[effect(gain='winner')]
2799    ...         )
2800    ...     ],  # level defaults to 0
2801    ...     failure=[
2802    ...         challenge(
2803    ...             skills=BestSkill('strength'),
2804    ...             failure=[effect(gain='loser')]
2805    ...         ),
2806    ...         effect(gain='sad')
2807    ...     ]
2808    ... )
2809    >>> r = observeChallengeOutcomes(e, [c])  # random
2810    >>> r[0]['outcome']
2811    False
2812    >>> r[0]['failure'][0]['outcome']  # also random
2813    True
2814    >>> r[0]['success'][0]['outcome'] is None  # skipped so not assigned
2815    True
2816    >>> resetChallengeOutcomes([c])
2817    >>> r2 = observeChallengeOutcomes(e, [c])  # random
2818    >>> r[0]['outcome']
2819    False
2820    >>> r[0]['success'][0]['outcome'] is None  # untaken branch no outcome
2821    True
2822    >>> r[0]['failure'][0]['outcome']  # also random
2823    False
2824    >>> def outcomeList(consequence):
2825    ...     'Lists outcomes from each challenge attempted.'
2826    ...     result = []
2827    ...     for item in consequence:
2828    ...         if 'skills' in item:
2829    ...             result.append(item['outcome'])
2830    ...             if item['outcome'] is True:
2831    ...                 result.extend(outcomeList(item['success']))
2832    ...             elif item['outcome'] is False:
2833    ...                 result.extend(outcomeList(item['failure']))
2834    ...             else:
2835    ...                 pass  # end here
2836    ...     return result
2837    >>> def skilled(**skills):
2838    ...     'Create a clone of our Situation with specific skills.'
2839    ...     r = copy.deepcopy(e)
2840    ...     r.state['common']['capabilities']['skills'].update(skills)
2841    ...     return r
2842    >>> resetChallengeOutcomes([c])
2843    >>> r = observeChallengeOutcomes(  # 'mostLikely' policy
2844    ...     skilled(charisma=9, strength=1),
2845    ...     [c],
2846    ...     policy='mostLikely'
2847    ... )
2848    >>> outcomeList(r)
2849    [True, True]
2850    >>> resetChallengeOutcomes([c])
2851    >>> outcomeList(observeChallengeOutcomes(
2852    ...     skilled(charisma=7, strength=-1),
2853    ...     [c],
2854    ...     policy='mostLikely'
2855    ... ))
2856    [False, False]
2857    >>> resetChallengeOutcomes([c])
2858    >>> outcomeList(observeChallengeOutcomes(
2859    ...     skilled(charisma=8, strength=-1),
2860    ...     [c],
2861    ...     policy='mostLikely'
2862    ... ))
2863    [True, False]
2864    >>> resetChallengeOutcomes([c])
2865    >>> outcomeList(observeChallengeOutcomes(
2866    ...     skilled(charisma=7, strength=0),
2867    ...     [c],
2868    ...     policy='mostLikely'
2869    ... ))
2870    [False, True]
2871    >>> resetChallengeOutcomes([c])
2872    >>> outcomeList(observeChallengeOutcomes(
2873    ...     skilled(charisma=20, strength=10),
2874    ...     [c],
2875    ...     policy='mostLikely'
2876    ... ))
2877    [True, True]
2878    >>> resetChallengeOutcomes([c])
2879    >>> outcomeList(observeChallengeOutcomes(
2880    ...     skilled(charisma=-10, strength=-10),
2881    ...     [c],
2882    ...     policy='mostLikely'
2883    ... ))
2884    [False, False]
2885    >>> resetChallengeOutcomes([c])
2886    >>> outcomeList(observeChallengeOutcomes(
2887    ...     e,
2888    ...     [c],
2889    ...     policy='fewestEffects'
2890    ... ))
2891    [True, False]
2892    >>> resetChallengeOutcomes([c])
2893    >>> outcomeList(observeChallengeOutcomes(
2894    ...     skilled(charisma=-100, strength=100),
2895    ...     [c],
2896    ...     policy='fewestEffects'
2897    ... ))  # unaffected by stats
2898    [True, False]
2899    >>> resetChallengeOutcomes([c])
2900    >>> outcomeList(observeChallengeOutcomes(e, [c], policy='success'))
2901    [True, True]
2902    >>> resetChallengeOutcomes([c])
2903    >>> outcomeList(observeChallengeOutcomes(e, [c], policy='failure'))
2904    [False, False]
2905    >>> cc = copy.deepcopy(c)
2906    >>> resetChallengeOutcomes([cc])
2907    >>> cc['outcome'] = False
2908    >>> outcomeList(observeChallengeOutcomes(
2909    ...     skilled(charisma=10, strength=10),
2910    ...     [cc],
2911    ...     policy='mostLikely'
2912    ... ))  # pre-observed outcome won't be changed
2913    [False, True]
2914    >>> resetChallengeOutcomes([cc])
2915    >>> cc['outcome'] = False
2916    >>> outcomeList(observeChallengeOutcomes(
2917    ...     e,
2918    ...     [cc],
2919    ...     policy='fewestEffects'
2920    ... ))  # pre-observed outcome won't be changed
2921    [False, True]
2922    >>> cc['success'][0]['outcome'] is None  # not assigned on other branch
2923    True
2924    >>> resetChallengeOutcomes([cc])
2925    >>> r = observeChallengeOutcomes(e, [cc], policy='fewestEffects')
2926    >>> r[0] is cc  # results are aliases, not clones
2927    True
2928    >>> outcomeList(r)
2929    [True, False]
2930    >>> cc['success'][0]['outcome']  # inner outcome now assigned
2931    False
2932    >>> cc['failure'][0]['outcome'] is None  # now this is other branch
2933    True
2934    >>> resetChallengeOutcomes([cc])
2935    >>> r = observeChallengeOutcomes(
2936    ...     e,
2937    ...     [cc],
2938    ...     policy='fewestEffects',
2939    ...     makeCopy=True
2940    ... )
2941    >>> r[0] is cc  # now result is a clone
2942    False
2943    >>> outcomeList(r)
2944    [True, False]
2945    >>> observedEffects(genericContextForSituation(e), r)
2946    []
2947    >>> r[0]['outcome']  # outcome was assigned
2948    True
2949    >>> cc['outcome'] is None  # only to the copy, not to the original
2950    True
2951    >>> cn = [
2952    ...     condition(
2953    ...         ReqCapability('boost'),
2954    ...         [
2955    ...             challenge(success=[effect(gain=('$', 1))]),
2956    ...             effect(gain=('$', 2))
2957    ...         ]
2958    ...     ),
2959    ...     challenge(failure=[effect(gain=('$', 4))])
2960    ... ]
2961    >>> o = observeChallengeOutcomes(e, cn, policy='fewestEffects')
2962    >>> # Without 'boost', inner challenge does not get an outcome
2963    >>> o[0]['consequence'][0]['outcome'] is None
2964    True
2965    >>> o[1]['outcome']  # avoids effect
2966    True
2967    >>> hasBoost = copy.deepcopy(e)
2968    >>> hasBoost.state['common']['capabilities']['capabilities'].add('boost')
2969    >>> resetChallengeOutcomes(cn)
2970    >>> o = observeChallengeOutcomes(hasBoost, cn, policy='fewestEffects')
2971    >>> o[0]['consequence'][0]['outcome']  # now assigned an outcome
2972    False
2973    >>> o[1]['outcome']  # avoids effect
2974    True
2975    >>> from . import core
2976    >>> e = core.emptySituation()
2977    >>> c = challenge(
2978    ...     skills=BestSkill('skill'),
2979    ...     level=4,  # very unlikely at level 0
2980    ...     success=[],
2981    ...     failure=[effect(lose=('money', 10))],
2982    ...     outcome=True
2983    ... )  # pre-assigned outcome
2984    >>> c['outcome']  # verify
2985    True
2986    >>> r = observeChallengeOutcomes(e, [c], policy='specified')
2987    >>> r[0]['outcome']
2988    True
2989    >>> c['outcome']  # original outcome is unchanged
2990    True
2991    >>> c['outcome'] = False  # the more likely outcome
2992    >>> r = observeChallengeOutcomes(e, [c], policy='specified')
2993    >>> r[0]['outcome']  # re-uses the new outcome
2994    False
2995    >>> c['outcome']  # outcome is unchanged
2996    False
2997    >>> c['outcome'] = True  # change it back
2998    >>> r = observeChallengeOutcomes(e, [c], policy='specified')
2999    >>> r[0]['outcome']  # re-use the outcome again
3000    True
3001    >>> c['outcome']  # outcome is unchanged
3002    True
3003    >>> c['outcome'] = None  # set it to no info; will crash
3004    >>> r = observeChallengeOutcomes(e, [c], policy='specified')
3005    Traceback (most recent call last):
3006    ...
3007    ValueError...
3008    >>> warnings.filterwarnings('default')
3009    >>> c['outcome'] is None  # same after crash
3010    True
3011    >>> r = observeChallengeOutcomes(
3012    ...     e,
3013    ...     [c],
3014    ...     policy='specified',
3015    ...     knownOutcomes=[True]
3016    ... )
3017    >>> r[0]['outcome']  # picked up known outcome
3018    True
3019    >>> c['outcome']  # outcome is changed
3020    True
3021    >>> resetChallengeOutcomes([c])
3022    >>> c['outcome'] is None  # has been reset
3023    True
3024    >>> r = observeChallengeOutcomes(
3025    ...     e,
3026    ...     [c],
3027    ...     policy='specified',
3028    ...     knownOutcomes=[True]
3029    ... )
3030    >>> c['outcome']  # from known outcomes
3031    True
3032    >>> ko = [False]
3033    >>> r = observeChallengeOutcomes(
3034    ...     e,
3035    ...     [c],
3036    ...     policy='specified',
3037    ...     knownOutcomes=ko
3038    ... )
3039    >>> c['outcome']  # from known outcomes
3040    False
3041    >>> ko  # known outcomes list gets used up
3042    []
3043    >>> ko = [False, False]
3044    >>> r = observeChallengeOutcomes(
3045    ...     e,
3046    ...     [c],
3047    ...     policy='specified',
3048    ...     knownOutcomes=ko
3049    ... )  # too many outcomes is an error
3050    >>> ko
3051    [False]
3052    """
3053    if not isinstance(consequence, list):
3054        raise TypeError(
3055            f"Invalid consequence: must be a list."
3056            f"\nGot: {repr(consequence)}"
3057        )
3058
3059    if knownOutcomes is None:
3060        knownOutcomes = []
3061
3062    if makeCopy:
3063        result = copy.deepcopy(consequence)
3064    else:
3065        result = consequence
3066
3067    for item in result:
3068        if not isinstance(item, dict):
3069            raise TypeError(
3070                f"Invalid consequence: items in the list must be"
3071                f" Effects, Challenges, or Conditions."
3072                f"\nGot item: {repr(item)}"
3073            )
3074        if 'skills' in item:  # must be a Challenge
3075            item = cast(Challenge, item)
3076            if len(knownOutcomes) > 0:
3077                item['outcome'] = knownOutcomes.pop(0)
3078            if item['outcome'] is not None:
3079                if item['outcome']:
3080                    observeChallengeOutcomes(
3081                        context,
3082                        item['success'],
3083                        location=location,
3084                        policy=policy,
3085                        knownOutcomes=knownOutcomes,
3086                        makeCopy=False
3087                    )
3088                else:
3089                    observeChallengeOutcomes(
3090                        context,
3091                        item['failure'],
3092                        location=location,
3093                        policy=policy,
3094                        knownOutcomes=knownOutcomes,
3095                        makeCopy=False
3096                    )
3097            else:  # need to assign an outcome
3098                if policy == 'specified':
3099                    raise ValueError(
3100                        f"Challenge has unspecified outcome so the"
3101                        f" 'specified' policy cannot be used when"
3102                        f" observing its outcomes:"
3103                        f"\n{item}"
3104                    )
3105                level = item['skills'].effectiveLevel(context)
3106                against = item['level']
3107                if level < against:
3108                    p = 1 / (2 ** (1 + against - level))
3109                else:
3110                    p = 1 - (1 / (2 ** (1 + level - against)))
3111                if policy == 'random':
3112                    if random.random() < p:  # success
3113                        item['outcome'] = True
3114                    else:
3115                        item['outcome'] = False
3116                elif policy == 'mostLikely':
3117                    if p >= 0.5:
3118                        item['outcome'] = True
3119                    else:
3120                        item['outcome'] = False
3121                elif policy == 'fewestEffects':
3122                    # Resolve copies so we don't affect original
3123                    subSuccess = observeChallengeOutcomes(
3124                        context,
3125                        item['success'],
3126                        location=location,
3127                        policy=policy,
3128                        knownOutcomes=knownOutcomes[:],
3129                        makeCopy=True
3130                    )
3131                    subFailure = observeChallengeOutcomes(
3132                        context,
3133                        item['failure'],
3134                        location=location,
3135                        policy=policy,
3136                        knownOutcomes=knownOutcomes[:],
3137                        makeCopy=True
3138                    )
3139                    if (
3140                        len(observedEffects(context, subSuccess))
3141                     <= len(observedEffects(context, subFailure))
3142                    ):
3143                        item['outcome'] = True
3144                    else:
3145                        item['outcome'] = False
3146                elif policy == 'success':
3147                    item['outcome'] = True
3148                elif policy == 'failure':
3149                    item['outcome'] = False
3150
3151                # Figure out outcomes for sub-consequence if we don't
3152                # already have them...
3153                if item['outcome'] not in (True, False):
3154                    raise TypeError(
3155                        f"Challenge has invalid outcome type"
3156                        f" {type(item['outcome'])} after observation."
3157                        f"\nOutcome value: {repr(item['outcome'])}"
3158                    )
3159
3160                if item['outcome']:
3161                    observeChallengeOutcomes(
3162                        context,
3163                        item['success'],
3164                        location=location,
3165                        policy=policy,
3166                        knownOutcomes=knownOutcomes,
3167                        makeCopy=False
3168                    )
3169                else:
3170                    observeChallengeOutcomes(
3171                        context,
3172                        item['failure'],
3173                        location=location,
3174                        policy=policy,
3175                        knownOutcomes=knownOutcomes,
3176                        makeCopy=False
3177                    )
3178
3179        elif 'value' in item:
3180            continue  # Effects do not need success/failure assigned
3181
3182        elif 'condition' in item:  # a Condition
3183            if item['condition'].satisfied(context):
3184                observeChallengeOutcomes(
3185                    context,
3186                    item['consequence'],
3187                    location=location,
3188                    policy=policy,
3189                    knownOutcomes=knownOutcomes,
3190                    makeCopy=False
3191                )
3192            else:
3193                observeChallengeOutcomes(
3194                    context,
3195                    item['alternative'],
3196                    location=location,
3197                    policy=policy,
3198                    knownOutcomes=knownOutcomes,
3199                    makeCopy=False
3200                )
3201
3202        else:  # bad dict
3203            raise TypeError(
3204                f"Invalid consequence: items in the list must be"
3205                f" Effects, Challenges, or Conditions (got a dictionary"
3206                f" without 'skills', 'value', or 'condition' keys)."
3207                f"\nGot item: {repr(item)}"
3208            )
3209
3210    # Return copy or original, now with options selected
3211    return result
3212
3213
3214class UnassignedOutcomeWarning(Warning):
3215    """
3216    A warning issued when asking for observed effects of a `Consequence`
3217    whose `Challenge` outcomes have not been fully assigned.
3218    """
3219    pass
3220
3221
3222def observedEffects(
3223    context: RequirementContext,
3224    observed: Consequence,
3225    skipWarning=False,
3226    baseIndex: int = 0
3227) -> List[int]:
3228    """
3229    Given a `Situation` and a `Consequence` whose challenges have
3230    outcomes assigned, returns a tuple containing a list of the
3231    depth-first-indices of each effect to apply. You can use
3232    `consequencePart` to extract the actual `Effect` values from the
3233    consequence based on their indices.
3234
3235    Only effects that actually apply are included, based on the observed
3236    outcomes as well as which `Condition`(s) are met, although charges
3237    and delays for the effects are not taken into account.
3238
3239    `baseIndex` can be set to something other than 0 to start indexing
3240    at that value. Issues an `UnassignedOutcomeWarning` if it encounters
3241    a challenge whose outcome has not been observed, unless
3242    `skipWarning` is set to `True`. In that case, no effects are listed
3243    for outcomes of that challenge.
3244
3245    For example:
3246
3247    >>> from . import core
3248    >>> warnings.filterwarnings('error')
3249    >>> e = core.emptySituation()
3250    >>> def skilled(**skills):
3251    ...     'Create a clone of our FocalContext with specific skills.'
3252    ...     r = copy.deepcopy(e)
3253    ...     r.state['common']['capabilities']['skills'].update(skills)
3254    ...     return r
3255    >>> c = challenge(  # index 1 in [c] (index 0 is the outer list)
3256    ...     skills=BestSkill('charisma'),
3257    ...     level=8,
3258    ...     success=[
3259    ...         effect(gain='happy'),  # index 3 in [c]
3260    ...         challenge(
3261    ...             skills=BestSkill('strength'),
3262    ...             success=[effect(gain='winner')]  # index 6 in [c]
3263    ...             # failure is index 7
3264    ...         )  # level defaults to 0
3265    ...     ],
3266    ...     failure=[
3267    ...         challenge(
3268    ...             skills=BestSkill('strength'),
3269    ...             # success is index 10
3270    ...             failure=[effect(gain='loser')]  # index 12 in [c]
3271    ...         ),
3272    ...         effect(gain='sad')  # index 13 in [c]
3273    ...     ]
3274    ... )
3275    >>> import pytest
3276    >>> with pytest.warns(UnassignedOutcomeWarning):
3277    ...     observedEffects(e, [c])
3278    []
3279    >>> with pytest.warns(UnassignedOutcomeWarning):
3280    ...     observedEffects(e, [c, c])
3281    []
3282    >>> observedEffects(e, [c, c], skipWarning=True)
3283    []
3284    >>> c['outcome'] = 'invalid value'  # must be True, False, or None
3285    >>> observedEffects(e, [c])
3286    Traceback (most recent call last):
3287    ...
3288    TypeError...
3289    >>> yesYes = skilled(charisma=10, strength=5)
3290    >>> yesNo = skilled(charisma=10, strength=-1)
3291    >>> noYes = skilled(charisma=4, strength=5)
3292    >>> noNo = skilled(charisma=4, strength=-1)
3293    >>> resetChallengeOutcomes([c])
3294    >>> observedEffects(
3295    ...     yesYes,
3296    ...     observeChallengeOutcomes(yesYes, [c], policy='mostLikely')
3297    ... )
3298    [3, 6]
3299    >>> resetChallengeOutcomes([c])
3300    >>> observedEffects(
3301    ...     yesNo,
3302    ...     observeChallengeOutcomes(yesNo, [c], policy='mostLikely')
3303    ... )
3304    [3]
3305    >>> resetChallengeOutcomes([c])
3306    >>> observedEffects(
3307    ...     noYes,
3308    ...     observeChallengeOutcomes(noYes, [c], policy='mostLikely')
3309    ... )
3310    [13]
3311    >>> resetChallengeOutcomes([c])
3312    >>> observedEffects(
3313    ...     noNo,
3314    ...     observeChallengeOutcomes(noNo, [c], policy='mostLikely')
3315    ... )
3316    [12, 13]
3317    >>> warnings.filterwarnings('default')
3318    >>> # known outcomes override policy & pre-specified outcomes
3319    >>> observedEffects(
3320    ...     noNo,
3321    ...     observeChallengeOutcomes(
3322    ...         noNo,
3323    ...         [c],
3324    ...         policy='mostLikely',
3325    ...         knownOutcomes=[True, True])
3326    ... )
3327    [3, 6]
3328    >>> observedEffects(
3329    ...     yesYes,
3330    ...     observeChallengeOutcomes(
3331    ...         yesYes,
3332    ...         [c],
3333    ...         policy='mostLikely',
3334    ...         knownOutcomes=[False, False])
3335    ... )
3336    [12, 13]
3337    >>> resetChallengeOutcomes([c])
3338    >>> observedEffects(
3339    ...     yesYes,
3340    ...     observeChallengeOutcomes(
3341    ...         yesYes,
3342    ...         [c],
3343    ...         policy='mostLikely',
3344    ...         knownOutcomes=[False, False])
3345    ... )
3346    [12, 13]
3347    """
3348    result: List[int] = []
3349    totalCount: int = baseIndex + 1  # +1 for the outer list
3350    if not isinstance(observed, list):
3351        raise TypeError(
3352            f"Invalid consequence: must be a list."
3353            f"\nGot: {repr(observed)}"
3354        )
3355    for item in observed:
3356        if not isinstance(item, dict):
3357            raise TypeError(
3358                f"Invalid consequence: items in the list must be"
3359                f" Effects, Challenges, or Conditions."
3360                f"\nGot item: {repr(item)}"
3361            )
3362
3363        if 'skills' in item:  # must be a Challenge
3364            item = cast(Challenge, item)
3365            succeeded = item['outcome']
3366            useCh: Optional[Literal['success', 'failure']]
3367            if succeeded is True:
3368                useCh = 'success'
3369            elif succeeded is False:
3370                useCh = 'failure'
3371            else:
3372                useCh = None
3373                level = item["level"]
3374                if succeeded is not None:
3375                    raise TypeError(
3376                        f"Invalid outcome for level-{level} challenge:"
3377                        f" should be True, False, or None, but got:"
3378                        f" {repr(succeeded)}"
3379                    )
3380                else:
3381                    if not skipWarning:
3382                        warnings.warn(
3383                            (
3384                                f"A level-{level} challenge in the"
3385                                f" consequence being observed has no"
3386                                f" observed outcome; no effects from"
3387                                f" either success or failure branches"
3388                                f" will be included. Use"
3389                                f" observeChallengeOutcomes to fill in"
3390                                f" unobserved outcomes."
3391                            ),
3392                            UnassignedOutcomeWarning
3393                        )
3394
3395            if useCh is not None:
3396                skipped = 0
3397                if useCh == 'failure':
3398                    skipped = countParts(item['success'])
3399                subEffects = observedEffects(
3400                    context,
3401                    item[useCh],
3402                    skipWarning=skipWarning,
3403                    baseIndex=totalCount + skipped + 1
3404                )
3405                result.extend(subEffects)
3406
3407            # TODO: Go back to returning tuples but fix counts to include
3408            # skipped stuff; this is horribly inefficient :(
3409            totalCount += countParts(item)
3410
3411        elif 'value' in item:  # an effect, not a challenge
3412            item = cast(Effect, item)
3413            result.append(totalCount)
3414            totalCount += 1
3415
3416        elif 'condition' in item:  # a Condition
3417            item = cast(Condition, item)
3418            useCo: Literal['consequence', 'alternative']
3419            if item['condition'].satisfied(context):
3420                useCo = 'consequence'
3421                skipped = 0
3422            else:
3423                useCo = 'alternative'
3424                skipped = countParts(item['consequence'])
3425            subEffects = observedEffects(
3426                context,
3427                item[useCo],
3428                skipWarning=skipWarning,
3429                baseIndex=totalCount + skipped + 1
3430            )
3431            result.extend(subEffects)
3432            totalCount += countParts(item)
3433
3434        else:  # bad dict
3435            raise TypeError(
3436                f"Invalid consequence: items in the list must be"
3437                f" Effects, Challenges, or Conditions (got a dictionary"
3438                f" without 'skills', 'value', or 'condition' keys)."
3439                f"\nGot item: {repr(item)}"
3440            )
3441
3442    return result
3443
3444
3445#--------------#
3446# Requirements #
3447#--------------#
3448
3449MECHANISM_STATE_SUFFIX_RE = re.compile('(.*)(?<!:):([^:]+)$')
3450"""
3451Regular expression for finding mechanism state suffixes. These are a
3452single colon followed by any amount of non-colon characters until the
3453end of a token.
3454"""
3455
3456
3457class Requirement:
3458    """
3459    Represents a precondition for traversing an edge or taking an action.
3460    This can be any boolean expression over `Capability`, mechanism (see
3461    `MechanismName`), and/or `Token` states that must obtain, with
3462    numerical values for the number of tokens required, and specific
3463    mechanism states or active capabilities necessary. For example, if
3464    the player needs either the wall-break capability or the wall-jump
3465    capability plus a balloon token, or for the switch mechanism to be
3466    on, you could represent that using:
3467
3468        ReqAny(
3469            ReqCapability('wall-break'),
3470            ReqAll(
3471                ReqCapability('wall-jump'),
3472                ReqTokens('balloon', 1)
3473            ),
3474            ReqMechanism('switch', 'on')
3475        )
3476
3477    The subclasses define concrete requirements.
3478
3479    Note that mechanism names are searched for using `lookupMechanism`,
3480    starting from the `DecisionID`s of the decisions on either end of
3481    the transition where a requirement is being checked. You may need to
3482    rename mechanisms to avoid a `MechanismCollisionError`if decisions
3483    on either end of a transition use the same mechanism name.
3484    """
3485    def satisfied(
3486        self,
3487        context: RequirementContext,
3488        dontRecurse: Optional[
3489            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
3490        ] = None
3491    ) -> bool:
3492        """
3493        This will return `True` if the requirement is satisfied in the
3494        given `RequirementContext`, resolving mechanisms from the
3495        context's set of decisions and graph, and respecting the
3496        context's equivalences. It returns `False` otherwise.
3497
3498        The `dontRecurse` set should be unspecified to start, and will
3499        be used to avoid infinite recursion in cases of circular
3500        equivalences (requirements are not considered satisfied by
3501        equivalence loops).
3502
3503        TODO: Examples
3504        """
3505        raise NotImplementedError(
3506            "Requirement is an abstract class and cannot be"
3507            " used directly."
3508        )
3509
3510    def __eq__(self, other: Any) -> bool:
3511        raise NotImplementedError(
3512            "Requirement is an abstract class and cannot be compared."
3513        )
3514
3515    def __hash__(self) -> int:
3516        raise NotImplementedError(
3517            "Requirement is an abstract class and cannot be hashed."
3518        )
3519
3520    def walk(self) -> Generator['Requirement', None, None]:
3521        """
3522        Yields every part of the requirement in depth-first traversal
3523        order.
3524        """
3525        raise NotImplementedError(
3526            "Requirement is an abstract class and cannot be walked."
3527        )
3528
3529    def asEffectList(self) -> List[Effect]:
3530        """
3531        Transforms this `Requirement` into a list of `Effect`
3532        objects that gain the `Capability`, set the `Token` amounts, and
3533        set the `Mechanism` states mentioned by the requirement. The
3534        requirement must be either a `ReqTokens`, a `ReqCapability`, a
3535        `ReqMechanism`, or a `ReqAll` which includes nothing besides
3536        those types as sub-requirements. The token and capability
3537        requirements at the leaves of the tree will be collected into a
3538        list for the result (note that whether `ReqAny` or `ReqAll` is
3539        used is ignored, all of the tokens/capabilities/mechanisms
3540        mentioned are listed). For each `Capability` requirement a
3541        'gain' effect for that capability will be included. For each
3542        `Mechanism` or `Token` requirement, a 'set' effect for that
3543        mechanism state or token count will be included. Note that if
3544        the requirement has contradictory clauses (e.g., two different
3545        mechanism states) multiple effects which cancel each other out
3546        will be included. Also note that setting token amounts may end
3547        up decreasing them unnecessarily.
3548
3549        Raises a `TypeError` if this requirement is not suitable for
3550        transformation into an effect list.
3551        """
3552        raise NotImplementedError("Requirement is an abstract class.")
3553
3554    def flatten(self) -> 'Requirement':
3555        """
3556        Returns a simplified version of this requirement that merges
3557        multiple redundant layers of `ReqAny`/`ReqAll` into single
3558        `ReqAny`/`ReqAll` structures, including recursively. May return
3559        the original requirement if there's no simplification to be done.
3560
3561        Default implementation just returns `self`.
3562        """
3563        return self
3564
3565    def unparse(self) -> str:
3566        """
3567        Returns a string which would convert back into this `Requirement`
3568        object if you fed it to `parsing.ParseFormat.parseRequirement`.
3569
3570        TODO: Move this over into `parsing`?
3571
3572        Examples:
3573
3574        >>> r = ReqAny([
3575        ...     ReqCapability('capability'),
3576        ...     ReqTokens('token', 3),
3577        ...     ReqMechanism('mechanism', 'state')
3578        ... ])
3579        >>> rep = r.unparse()
3580        >>> rep
3581        '(capability|token*3|mechanism:state)'
3582        >>> from . import parsing
3583        >>> pf = parsing.ParseFormat()
3584        >>> back = pf.parseRequirement(rep)
3585        >>> back == r
3586        True
3587        >>> ReqNot(ReqNothing()).unparse()
3588        '!(O)'
3589        >>> ReqImpossible().unparse()
3590        'X'
3591        >>> r = ReqAny([ReqCapability('A'), ReqCapability('B'),
3592        ...     ReqCapability('C')])
3593        >>> rep = r.unparse()
3594        >>> rep
3595        '(A|B|C)'
3596        >>> back = pf.parseRequirement(rep)
3597        >>> back == r
3598        True
3599        """
3600        raise NotImplementedError("Requirement is an abstract class.")
3601
3602
3603class ReqAny(Requirement):
3604    """
3605    A disjunction requirement satisfied when any one of its
3606    sub-requirements is satisfied.
3607    """
3608    def __init__(self, subs: Iterable[Requirement]) -> None:
3609        self.subs = list(subs)
3610
3611    def __hash__(self) -> int:
3612        result = 179843
3613        for sub in self.subs:
3614            result = 31 * (result + hash(sub))
3615        return result
3616
3617    def __eq__(self, other: Any) -> bool:
3618        return isinstance(other, ReqAny) and other.subs == self.subs
3619
3620    def __repr__(self):
3621        return "ReqAny(" + repr(self.subs) + ")"
3622
3623    def satisfied(
3624        self,
3625        context: RequirementContext,
3626        dontRecurse: Optional[
3627            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
3628        ] = None
3629    ) -> bool:
3630        """
3631        True as long as any one of the sub-requirements is satisfied.
3632        """
3633        return any(
3634            sub.satisfied(context, dontRecurse)
3635            for sub in self.subs
3636        )
3637
3638    def walk(self) -> Generator[Requirement, None, None]:
3639        yield self
3640        for sub in self.subs:
3641            yield from sub.walk()
3642
3643    def asEffectList(self) -> List[Effect]:
3644        """
3645        Raises a `TypeError` since disjunctions don't have a translation
3646        into a simple list of effects to satisfy them.
3647        """
3648        raise TypeError(
3649            "Cannot convert ReqAny into an effect list:"
3650            " contradictory token or mechanism requirements on"
3651            " different branches are not easy to synthesize."
3652        )
3653
3654    def flatten(self) -> Requirement:
3655        """
3656        Flattens this requirement by merging any sub-requirements which
3657        are also `ReqAny` instances into this one.
3658        """
3659        merged = []
3660        for sub in self.subs:
3661            flat = sub.flatten()
3662            if isinstance(flat, ReqAny):
3663                merged.extend(flat.subs)
3664            else:
3665                merged.append(flat)
3666
3667        return ReqAny(merged)
3668
3669    def unparse(self) -> str:
3670        return '(' + '|'.join(sub.unparse() for sub in self.subs) + ')'
3671
3672
3673class ReqAll(Requirement):
3674    """
3675    A conjunction requirement satisfied when all of its sub-requirements
3676    are satisfied.
3677    """
3678    def __init__(self, subs: Iterable[Requirement]) -> None:
3679        self.subs = list(subs)
3680
3681    def __hash__(self) -> int:
3682        result = 182971
3683        for sub in self.subs:
3684            result = 17 * (result + hash(sub))
3685        return result
3686
3687    def __eq__(self, other: Any) -> bool:
3688        return isinstance(other, ReqAll) and other.subs == self.subs
3689
3690    def __repr__(self):
3691        return "ReqAll(" + repr(self.subs) + ")"
3692
3693    def satisfied(
3694        self,
3695        context: RequirementContext,
3696        dontRecurse: Optional[
3697            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
3698        ] = None
3699    ) -> bool:
3700        """
3701        True as long as all of the sub-requirements are satisfied.
3702        """
3703        return all(
3704            sub.satisfied(context, dontRecurse)
3705            for sub in self.subs
3706        )
3707
3708    def walk(self) -> Generator[Requirement, None, None]:
3709        yield self
3710        for sub in self.subs:
3711            yield from sub.walk()
3712
3713    def asEffectList(self) -> List[Effect]:
3714        """
3715        Returns a gain list composed by adding together the gain lists
3716        for each sub-requirement. Note that some types of requirement
3717        will raise a `TypeError` during this process if they appear as a
3718        sub-requirement.
3719        """
3720        result = []
3721        for sub in self.subs:
3722            result += sub.asEffectList()
3723
3724        return result
3725
3726    def flatten(self) -> Requirement:
3727        """
3728        Flattens this requirement by merging any sub-requirements which
3729        are also `ReqAll` instances into this one.
3730        """
3731        merged = []
3732        for sub in self.subs:
3733            flat = sub.flatten()
3734            if isinstance(flat, ReqAll):
3735                merged.extend(flat.subs)
3736            else:
3737                merged.append(flat)
3738
3739        return ReqAll(merged)
3740
3741    def unparse(self) -> str:
3742        return '(' + '&'.join(sub.unparse() for sub in self.subs) + ')'
3743
3744
3745class ReqNot(Requirement):
3746    """
3747    A negation requirement satisfied when its sub-requirement is NOT
3748    satisfied.
3749    """
3750    def __init__(self, sub: Requirement) -> None:
3751        self.sub = sub
3752
3753    def __hash__(self) -> int:
3754        return 17293 + hash(self.sub)
3755
3756    def __eq__(self, other: Any) -> bool:
3757        return isinstance(other, ReqNot) and other.sub == self.sub
3758
3759    def __repr__(self):
3760        return "ReqNot(" + repr(self.sub) + ")"
3761
3762    def satisfied(
3763        self,
3764        context: RequirementContext,
3765        dontRecurse: Optional[
3766            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
3767        ] = None
3768    ) -> bool:
3769        """
3770        True as long as the sub-requirement is not satisfied.
3771        """
3772        return not self.sub.satisfied(context, dontRecurse)
3773
3774    def walk(self) -> Generator[Requirement, None, None]:
3775        yield self
3776        yield self.sub
3777
3778    def asEffectList(self) -> List[Effect]:
3779        """
3780        Raises a `TypeError` since understanding a `ReqNot` in terms of
3781        capabilities/tokens to be gained is not straightforward, and would
3782        need to be done relative to a game state in any case.
3783        """
3784        raise TypeError(
3785            "Cannot convert ReqNot into an effect list:"
3786            " capabilities or tokens would have to be lost, not gained to"
3787            " satisfy this requirement."
3788        )
3789
3790    def flatten(self) -> Requirement:
3791        return ReqNot(self.sub.flatten())
3792
3793    def unparse(self) -> str:
3794        return '!(' + self.sub.unparse() + ')'
3795
3796
3797class ReqCapability(Requirement):
3798    """
3799    A capability requirement is satisfied if the specified capability is
3800    possessed by the player according to the given state.
3801    """
3802    def __init__(self, capability: Capability) -> None:
3803        self.capability = capability
3804
3805    def __hash__(self) -> int:
3806        return 47923 + hash(self.capability)
3807
3808    def __eq__(self, other: Any) -> bool:
3809        return (
3810            isinstance(other, ReqCapability)
3811        and other.capability == self.capability
3812        )
3813
3814    def __repr__(self):
3815        return "ReqCapability(" + repr(self.capability) + ")"
3816
3817    def satisfied(
3818        self,
3819        context: RequirementContext,
3820        dontRecurse: Optional[
3821            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
3822        ] = None
3823    ) -> bool:
3824        return hasCapabilityOrEquivalent(
3825            self.capability,
3826            context,
3827            dontRecurse
3828        )
3829
3830    def walk(self) -> Generator[Requirement, None, None]:
3831        yield self
3832
3833    def asEffectList(self) -> List[Effect]:
3834        """
3835        Returns a list containing a single 'gain' effect which grants
3836        the required capability.
3837        """
3838        return [effect(gain=self.capability)]
3839
3840    def unparse(self) -> str:
3841        return self.capability
3842
3843
3844class ReqTokens(Requirement):
3845    """
3846    A token requirement satisfied if the player possesses at least a
3847    certain number of a given type of token.
3848
3849    Note that checking the satisfaction of individual doors in a specific
3850    state is not enough to guarantee they're jointly traversable, since
3851    if a series of doors requires the same kind of token and they use up
3852    those tokens, further logic is needed to understand that as the
3853    tokens get used up, their requirements may no longer be satisfied.
3854
3855    Also note that a requirement for tokens does NOT mean that tokens
3856    will be subtracted when traversing the door (you can have re-usable
3857    tokens after all). To implement a token cost, use both a requirement
3858    and a 'lose' effect.
3859    """
3860    def __init__(self, tokenType: Token, cost: TokenCount) -> None:
3861        self.tokenType = tokenType
3862        self.cost = cost
3863
3864    def __hash__(self) -> int:
3865        return (17 * hash(self.tokenType)) + (11 * self.cost)
3866
3867    def __eq__(self, other: Any) -> bool:
3868        return (
3869            isinstance(other, ReqTokens)
3870        and other.tokenType == self.tokenType
3871        and other.cost == self.cost
3872        )
3873
3874    def __repr__(self):
3875        return f"ReqTokens({repr(self.tokenType)}, {repr(self.cost)})"
3876
3877    def satisfied(
3878        self,
3879        context: RequirementContext,
3880        dontRecurse: Optional[
3881            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
3882        ] = None
3883    ) -> bool:
3884        return combinedTokenCount(context.state, self.tokenType) >= self.cost
3885
3886    def walk(self) -> Generator[Requirement, None, None]:
3887        yield self
3888
3889    def asEffectList(self) -> List[Effect]:
3890        """
3891        Returns a list containing a single 'set' effect which sets the
3892        required tokens (note that this may unnecessarily subtract
3893        tokens if the state had more than enough tokens beforehand).
3894        """
3895        return [effect(set=(self.tokenType, self.cost))]
3896
3897    def unparse(self) -> str:
3898        return f'{self.tokenType}*{self.cost}'
3899
3900
3901class ReqMechanism(Requirement):
3902    """
3903    A mechanism requirement satisfied if the specified mechanism is in
3904    the specified state. The mechanism is specified by name and a lookup
3905    on that name will be performed when assessing the requirement, based
3906    on the specific position at which the requirement applies. However,
3907    if a `where` value is supplied, the lookup on the mechanism name will
3908    always start from that decision, regardless of where the requirement
3909    is being evaluated.
3910    """
3911    def __init__(
3912        self,
3913        mechanism: AnyMechanismSpecifier,
3914        state: MechanismState,
3915    ) -> None:
3916        self.mechanism = mechanism
3917        self.reqState = state
3918
3919        # Normalize mechanism specifiers without any position information
3920        if isinstance(mechanism, tuple):
3921            if len(mechanism) != 4:
3922                raise ValueError(
3923                    f"Mechanism specifier must have 4 parts if it's a"
3924                    f" tuple. (Got: {mechanism})."
3925                )
3926            elif all(x is None for x in mechanism[:3]):
3927                self.mechanism = mechanism[3]
3928
3929    def __hash__(self) -> int:
3930        return (
3931            (11 * hash(self.mechanism))
3932          + (31 * hash(self.reqState))
3933        )
3934
3935    def __eq__(self, other: Any) -> bool:
3936        return (
3937            isinstance(other, ReqMechanism)
3938        and other.mechanism == self.mechanism
3939        and other.reqState == self.reqState
3940        )
3941
3942    def __repr__(self):
3943        mRep = repr(self.mechanism)
3944        sRep = repr(self.reqState)
3945        return f"ReqMechanism({mRep}, {sRep})"
3946
3947    def satisfied(
3948        self,
3949        context: RequirementContext,
3950        dontRecurse: Optional[
3951            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
3952        ] = None
3953    ) -> bool:
3954        return mechanismInStateOrEquivalent(
3955            self.mechanism,
3956            self.reqState,
3957            context,
3958            dontRecurse
3959        )
3960
3961    def walk(self) -> Generator[Requirement, None, None]:
3962        yield self
3963
3964    def asEffectList(self) -> List[Effect]:
3965        """
3966        Returns a list containing a single 'set' effect which sets the
3967        required mechanism to the required state.
3968        """
3969        return [effect(set=(self.mechanism, self.reqState))]
3970
3971    def unparse(self) -> str:
3972        if isinstance(self.mechanism, (MechanismID, MechanismName)):
3973            return f'{self.mechanism}:{self.reqState}'
3974        else:  # Must be a MechanismSpecifier
3975            # TODO: This elsewhere!
3976            domain, zone, decision, mechanism = self.mechanism
3977            mspec = ''
3978            if domain is not None:
3979                mspec += domain + '//'
3980            if zone is not None:
3981                mspec += zone + '::'
3982            if decision is not None:
3983                mspec += decision + '::'
3984            mspec += mechanism
3985            return f'{mspec}:{self.reqState}'
3986
3987
3988class ReqLevel(Requirement):
3989    """
3990    A tag requirement satisfied if a specific skill is at or above the
3991    specified level.
3992    """
3993    def __init__(
3994        self,
3995        skill: Skill,
3996        minLevel: Level,
3997    ) -> None:
3998        self.skill = skill
3999        self.minLevel = minLevel
4000
4001    def __hash__(self) -> int:
4002        return (
4003            (79 * hash(self.skill))
4004          + (55 * hash(self.minLevel))
4005        )
4006
4007    def __eq__(self, other: Any) -> bool:
4008        return (
4009            isinstance(other, ReqLevel)
4010        and other.skill == self.skill
4011        and other.minLevel == self.minLevel
4012        )
4013
4014    def __repr__(self):
4015        sRep = repr(self.skill)
4016        lRep = repr(self.minLevel)
4017        return f"ReqLevel({sRep}, {lRep})"
4018
4019    def satisfied(
4020        self,
4021        context: RequirementContext,
4022        dontRecurse: Optional[
4023            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
4024        ] = None
4025    ) -> bool:
4026        return getSkillLevel(context.state, self.skill) >= self.minLevel
4027
4028    def walk(self) -> Generator[Requirement, None, None]:
4029        yield self
4030
4031    def asEffectList(self) -> List[Effect]:
4032        """
4033        Returns a list containing a single 'set' effect which sets the
4034        required skill to the minimum required level. Note that this may
4035        reduce a skill level that was more than sufficient to meet the
4036        requirement.
4037        """
4038        return [effect(set=("skill", self.skill, self.minLevel))]
4039
4040    def unparse(self) -> str:
4041        return f'{self.skill}^{self.minLevel}'
4042
4043
4044class ReqTag(Requirement):
4045    """
4046    A tag requirement satisfied if there is any active decision that has
4047    the specified value for the given tag (default value is 1 for tags
4048    where a value wasn't specified). Zone tags also satisfy the
4049    requirement if they're applied to zones that include active
4050    decisions.
4051    """
4052    def __init__(
4053        self,
4054        tag: "Tag",
4055        value: "TagValue",
4056    ) -> None:
4057        self.tag = tag
4058        self.value = value
4059
4060    def __hash__(self) -> int:
4061        return (
4062            (71 * hash(self.tag))
4063          + (43 * hash(self.value))
4064        )
4065
4066    def __eq__(self, other: Any) -> bool:
4067        return (
4068            isinstance(other, ReqTag)
4069        and other.tag == self.tag
4070        and other.value == self.value
4071        )
4072
4073    def __repr__(self):
4074        tRep = repr(self.tag)
4075        vRep = repr(self.value)
4076        return f"ReqTag({tRep}, {vRep})"
4077
4078    def satisfied(
4079        self,
4080        context: RequirementContext,
4081        dontRecurse: Optional[
4082            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
4083        ] = None
4084    ) -> bool:
4085        active = combinedDecisionSet(context.state)
4086        graph = context.graph
4087        zones = set()
4088        for decision in active:
4089            tags = graph.decisionTags(decision)
4090            if self.tag in tags and tags[self.tag] == self.value:
4091                return True
4092            zones |= graph.zoneAncestors(decision)
4093        for zone in zones:
4094            zTags = graph.zoneTags(zone)
4095            if self.tag in zTags and zTags[self.tag] == self.value:
4096                return True
4097
4098        return False
4099
4100    def walk(self) -> Generator[Requirement, None, None]:
4101        yield self
4102
4103    def asEffectList(self) -> List[Effect]:
4104        """
4105        Returns a list containing a single 'set' effect which sets the
4106        required mechanism to the required state.
4107        """
4108        raise TypeError(
4109            "Cannot convert ReqTag into an effect list:"
4110            " effects cannot apply/remove/change tags"
4111        )
4112
4113    def unparse(self) -> str:
4114        return f'{self.tag}~{self.value!r}'
4115
4116
4117class ReqNothing(Requirement):
4118    """
4119    A requirement representing that something doesn't actually have a
4120    requirement. This requirement is always satisfied.
4121    """
4122    def __hash__(self) -> int:
4123        return 127942
4124
4125    def __eq__(self, other: Any) -> bool:
4126        return isinstance(other, ReqNothing)
4127
4128    def __repr__(self):
4129        return "ReqNothing()"
4130
4131    def satisfied(
4132        self,
4133        context: RequirementContext,
4134        dontRecurse: Optional[
4135            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
4136        ] = None
4137    ) -> bool:
4138        return True
4139
4140    def walk(self) -> Generator[Requirement, None, None]:
4141        yield self
4142
4143    def asEffectList(self) -> List[Effect]:
4144        """
4145        Returns an empty list, since nothing is required.
4146        """
4147        return []
4148
4149    def unparse(self) -> str:
4150        return 'O'
4151
4152
4153class ReqImpossible(Requirement):
4154    """
4155    A requirement representing that something is impossible. This
4156    requirement is never satisfied.
4157    """
4158    def __hash__(self) -> int:
4159        return 478743
4160
4161    def __eq__(self, other: Any) -> bool:
4162        return isinstance(other, ReqImpossible)
4163
4164    def __repr__(self):
4165        return "ReqImpossible()"
4166
4167    def satisfied(
4168        self,
4169        context: RequirementContext,
4170        dontRecurse: Optional[
4171            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
4172        ] = None
4173    ) -> bool:
4174        return False
4175
4176    def walk(self) -> Generator[Requirement, None, None]:
4177        yield self
4178
4179    def asEffectList(self) -> List[Effect]:
4180        """
4181        Raises a `TypeError` since a `ReqImpossible` cannot be converted
4182        into an effect which would allow the transition to be taken.
4183        """
4184        raise TypeError(
4185            "Cannot convert ReqImpossible into an effect list:"
4186            " there are no powers or tokens which could be gained to"
4187            " satisfy this requirement."
4188        )
4189
4190    def unparse(self) -> str:
4191        return 'X'
4192
4193
4194Equivalences = Dict[
4195    Union[Capability, Tuple[MechanismID, MechanismState]],
4196    Set[Requirement]
4197]
4198"""
4199An `Equivalences` dictionary maps `Capability` names and/or
4200(`MechanismID`, `MechanismState`) pairs to `Requirement` objects,
4201indicating that that single capability or mechanism state should be
4202considered active if the specified requirement is met. Note that this
4203can lead to multiple states of the same mechanism being effectively
4204active at once if a state other than the current state is active via an
4205equivalence.
4206
4207When a circular dependency is created via equivalences, the capability or
4208mechanism state in question is considered inactive when the circular
4209dependency on it comes up, but the equivalence may still succeed (if it
4210uses a disjunction, for example).
4211"""
4212
4213
4214#----------------------#
4215# Tags and Annotations #
4216#----------------------#
4217
4218Tag: 'TypeAlias' = str
4219"""
4220A type alias: tags are strings.
4221
4222A tag is an arbitrary string key attached to a decision or transition,
4223with an associated value (default 1 to just mean "present").
4224
4225Meanings are left up to the map-maker, but some conventions include:
4226
4227TODO: Actually use these conventions, or abandon them
4228
4229- `'hard'` indicates that an edge is non-trivial to navigate. An
4230    annotation starting with `'fail:'` can be used to name another edge
4231    which would be traversed instead if the player fails to navigate the
4232    edge (e.g., a difficult series of platforms with a pit below that
4233    takes you to another decision). This is of course entirely
4234    subjective.
4235- `'false'` indicates that an edge doesn't actually exist, although it
4236    appears to. This tag is added in the same exploration step that
4237    requirements are updated (normally to `ReqImpossible`) to indicate
4238    that although the edge appeared to be traversable, it wasn't. This
4239    distinguishes that case from a case where edge requirements actually
4240    change.
4241- `'error'` indicates that an edge does not actually exist, and it's
4242    different than `'false'` because it indicates an error on the
4243    player's part rather than intentional deception by the game (another
4244    subjective distinction). It can also be used with a colon and another
4245    tag to indicate that that tag was applied in error (e.g., a ledge
4246    thought to be too high was not actually too high). This should be
4247    used sparingly, because in most cases capturing the player's
4248    perception of the world is what's desired. This is normally applied
4249    in the step before an edge is removed from the graph.
4250- `'hidden'` indicates that an edge is non-trivial to perceive. Again
4251    this is subjective. `'hinted'` can be used as well to indicate that
4252    despite being obfuscated, there are hints that suggest the edge's
4253    existence.
4254- `'created'` indicates that this transition is newly created and
4255    represents a change to the decision layout. Normally, when entering
4256    a decision point, all visible options will be listed. When
4257    revisiting a decision, several things can happen:
4258        1. You could notice a transition you hadn't noticed before.
4259        2. You could traverse part of the room that you couldn't before,
4260           observing new transitions that have always been there (this
4261           would be represented as an internal edge to another decision
4262           node).
4263        3. You could observe that the decision had changed due to some
4264           action or event, and discover a new transition that didn't
4265           exist previously.
4266    This tag distinguishes case 3 from case 1. The presence or absence
4267    of a `'hidden'` tag in case 1 represents whether the newly-observed
4268    (but not new) transition was overlooked because it was hidden or was
4269    just overlooked accidentally.
4270"""
4271
4272TagValueTypes: Tuple = (
4273    bool,
4274    int,
4275    float,
4276    str,
4277    list,
4278    dict,
4279    None,
4280    Requirement,
4281    Consequence
4282)
4283TagValue: 'TypeAlias' = Union[
4284    bool,
4285    int,
4286    float,
4287    str,
4288    list,
4289    dict,
4290    None,
4291    Requirement,
4292    Consequence
4293]
4294"""
4295A type alias: tag values are any kind of JSON-serializable data (so
4296booleans, ints, floats, strings, lists, dicts, or Nones, plus
4297`Requirement` and `Consequence` which have custom serialization defined
4298(see `parsing.CustomJSONEncoder`) The default value for tags is the
4299integer 1. Note that this is not enforced recursively in some places...
4300"""
4301
4302
4303class NoTagValue:
4304    """
4305    Class used to indicate no tag value for things that return tag values
4306    since `None` is a valid tag value.
4307    """
4308    pass
4309
4310
4311TagUpdateFunction: 'TypeAlias' = Callable[
4312    [Dict[Tag, TagValue], Tag, TagValue],
4313    TagValue
4314]
4315"""
4316A tag update function gets three arguments: the entire tags dictionary
4317for the thing being updated, the tag name of the tag being updated, and
4318the tag value for that tag. It must return a new tag value.
4319"""
4320
4321
4322Annotation: 'TypeAlias' = str
4323"A type alias: annotations are strings."
4324
4325
4326#-------#
4327# Zones #
4328#-------#
4329
4330class ZoneInfo(NamedTuple):
4331    """
4332    Zone info holds a level integer (starting from 0 as the level directly
4333    above decisions), a set of parent zones, a set of child decisions
4334    and/or zones, and zone tags and annotations. Zones at a particular
4335    level may only contain zones in lower levels, although zones at any
4336    level may also contain decisions directly.  The norm is for zones at
4337    level 0 to contain decisions, while zones at higher levels contain
4338    zones from the level directly below them.
4339
4340    Note that zones may have multiple parents, because one sub-zone may be
4341    contained within multiple super-zones.
4342    """
4343    level: int
4344    parents: Set[Zone]
4345    contents: Set[Union[DecisionID, Zone]]
4346    tags: Dict[Tag, TagValue]
4347    annotations: List[Annotation]
4348
4349
4350DefaultZone: Zone = ""
4351"""
4352An alias for the empty string to indicate a default zone.
4353"""
4354
4355
4356#----------------------------------#
4357# Exploration actions & Situations #
4358#----------------------------------#
4359
4360ExplorationActionType = Literal[
4361    'noAction',
4362    'start',
4363    'take',
4364    'explore',
4365    'warp',
4366    'focus',
4367    'swap',
4368    'focalize',
4369    'revertTo',
4370]
4371"""
4372The valid action types for exploration actions (see
4373`ExplorationAction`).
4374"""
4375
4376ExplorationAction: 'TypeAlias' = Union[
4377    Tuple[Literal['noAction']],
4378    Tuple[
4379        Literal['start'],
4380        Union[DecisionID, Dict[FocalPointName, DecisionID], Set[DecisionID]],
4381        Optional[DecisionID],
4382        Domain,
4383        Optional[CapabilitySet],
4384        Optional[Dict[MechanismID, MechanismState]],
4385        Optional[dict]
4386    ],
4387    Tuple[
4388        Literal['explore'],
4389        ContextSpecifier,
4390        DecisionID,
4391        TransitionWithOutcomes,
4392        Union[DecisionName, DecisionID, None],  # new name OR target
4393        Optional[Transition],  # new reciprocal name
4394        Union[Zone, None]  # new level-0 zone
4395    ],
4396    Tuple[
4397        Literal['explore'],
4398        FocalPointSpecifier,
4399        TransitionWithOutcomes,
4400        Union[DecisionName, DecisionID, None],  # new name OR target
4401        Optional[Transition],  # new reciprocal name
4402        Union[Zone, None]  # new level-0 zone
4403    ],
4404    Tuple[
4405        Literal['take'],
4406        ContextSpecifier,
4407        DecisionID,
4408        TransitionWithOutcomes
4409    ],
4410    Tuple[Literal['take'], FocalPointSpecifier, TransitionWithOutcomes],
4411    Tuple[Literal['warp'], ContextSpecifier, DecisionID],
4412    Tuple[Literal['warp'], FocalPointSpecifier, DecisionID],
4413    Tuple[Literal['focus'], ContextSpecifier, Set[Domain], Set[Domain]],
4414    Tuple[Literal['swap'], FocalContextName],
4415    Tuple[Literal['focalize'], FocalContextName],
4416    Tuple[Literal['revertTo'], SaveSlot, Set[str]],
4417]
4418"""
4419Represents an action taken at one step of a `DiscreteExploration`. It's a
4420always a tuple, and the first element is a string naming the action. It
4421has multiple possible configurations:
4422
4423- The string 'noAction' as a singlet means that no action has been
4424    taken, which can be used to represent waiting or an ending. In
4425    situations where the player is still deciding on an action, `None`
4426    (which is not a valid `ExplorationAction` should be used instead.
4427- The string 'start' followed by a `DecisionID` /
4428    (`FocalPointName`-to-`DecisionID` dictionary) / set-of-`DecisionID`s
4429    position(s) specifier, another `DecisionID` (or `None`), a `Domain`,
4430    and then optional `CapabilitySet`, mechanism state dictionary, and
4431    custom state dictionary objects (each of which could instead be
4432    `None` for default). This indicates setting up starting state in a
4433    new focal context. The first decision ID (or similar) specifies
4434    active decisions, the second specifies the primary decision (which
4435    ought to be one of the active ones). It always affects the active
4436    focal context, and a `BadStart` error will result if that context
4437    already has any active decisions in the specified domain. The
4438    specified domain must already exist and must have the appropriate
4439    focalization depending on the type of position(s) specifier given;
4440    use `DiscreteExploration.createDomain` to create a domain first if
4441    necessary. Likewise, any specified decisions to activate must
4442    already exist, use `DecisionGraph.addDecision` to create them before
4443    using a 'start' action.
4444
4445    When mechanism states and/or custom state is specified, these
4446    replace current mechanism/custom states for the entire current
4447    state, since these things aren't focal-context-specific. Similarly,
4448    if capabilities are provided, these replace existing capabilities
4449    for the active focal context, since those aren't domain-specific.
4450
4451- The string 'explore' followed by:
4452    * A `ContextSpecifier` indicating which context to use
4453    * A `DecisionID` indicating the starting decision
4454    * Alternatively, a `FocalPointSpecifier` can be used in place of the
4455        context specifier and decision to specify which focal point
4456        moves in a plural-focalized domain.
4457    * A `TransitionWithOutcomes` indicating the transition taken and
4458        outcomes observed (if any).
4459    * An optional `DecisionName` used to rename the destination.
4460    * An optional `Transition` used to rename the reciprocal transition.
4461    * An optional `Zone` used to place the destination into a
4462        (possibly-new) level-0 zone.
4463    This represents exploration of a previously-unexplored decision, in
4464    contrast to 'take' (see below) which represents moving across a
4465    previously-explored transition.
4466
4467- The string 'take' followed by a `ContextSpecifier`, `DecisionID`, and
4468    `TransitionWithOutcomes` represents taking that transition at that
4469    decision, updating the specified context (i.e., common vs. active;
4470    to update a non-active context first swap to it). Normal
4471    `DomainFocalization`-based rules for updating active decisions
4472    determine what happens besides transition consequences, but for a
4473    'singular'-focalized domain (as determined by the active
4474    `FocalContext` in the `DiscreteExploration`'s current `State`), the
4475    current active decision becomes inactive and the decision at the
4476    other end of the selected transition becomes active. A warning or
4477    error may be issued if the `DecisionID` used is an inactive
4478    decision.
4479    * For 'plural'-focalized domains, a `FocalPointSpecifier` is needed
4480        to know which of the plural focal points to move, this takes the
4481        place of the source `ContextSpecifier` and `DecisionID` since it
4482        provides that information. In this case the third item is still a
4483        `Transition`.
4484
4485- The string 'warp' followed by either a `DecisionID`, or a
4486    `FocalPointSpecifier` tuple followed by a `DecisionID`. This
4487    represents activating a new decision without following a
4488    transition in the decision graph, such as when a cutscene moves
4489    you. Things like teleporters can be represented by normal
4490    transitions; a warp should be used when there's a 1-time effect
4491    that has no reciprocal.
4492
4493- The string 'focus' followed by a `ContextSpecifier` and then two sets
4494    of `Domain`s. The first one lists domains that become inactive, and
4495    the second lists domains that become active. This can be used to
4496    represent opening up a menu, although if the menu can easily be
4497    closed and re-opened anywhere, it's usually not necessary to track
4498    the focus swaps (think a cutscene that forces you to make a choice
4499    before being able to continue normal exploration). A focus swap can
4500    also be the consequence of taking a transition, in which case the
4501    exploration action just identifies the transition using one of the
4502    formats above.
4503
4504- The string 'swap' is followed by a `FocalContextName` and represents
4505    a complete `FocalContext` swap. If this something the player can
4506    trigger at will (or under certain conditions) it's better to use a
4507    transition consequence and have the action be taking that transition.
4508
4509- The string 'focalize' is followed by an unused `FocalContextName`
4510    and represents the creation of a new empty focal context (which
4511    will also be swapped-to).
4512    # TODO: domain and context focus swaps as effects!
4513
4514- The  string 'revertTo' followed by a `SaveSlot` and then a set of
4515    reversion aspects (see `revertedState`). This will update the
4516    situation by restoring a previous state (or potentially only parts of
4517    it). An empty set of reversion aspects invokes the default revert
4518    behavior, which reverts all aspects of the state, except that changes
4519    to the `DecisionGraph` are preserved.
4520"""
4521
4522
4523def describeExplorationAction(
4524    situation: 'Situation',
4525    action: Optional[ExplorationAction]
4526) -> str:
4527    """
4528    Returns a string description of the action represented by an
4529    `ExplorationAction` object (or the string '(no action)' for the value
4530    `None`). Uses the provided situation to look up things like decision
4531    names, focal point positions, and destinations where relevant. Does
4532    not know details of which graph it is applied to or the outcomes of
4533    the action, so just describes what is being attempted.
4534    """
4535    if action is None:
4536        return '(no action)'
4537
4538    if (
4539        not isinstance(action, tuple)
4540     or len(action) == 0
4541    ):
4542        raise TypeError(f"Not an exploration action: {action!r}")
4543
4544    graph = situation.graph
4545
4546    if action[0] not in get_args(ExplorationActionType):
4547        raise ValueError(f"Invalid exploration action type: {action[0]!r}")
4548
4549    aType = action[0]
4550    if aType == 'noAction':
4551        return "wait"
4552
4553    elif aType == 'start':
4554        if len(action) != 7:
4555            raise ValueError(
4556                f"Wrong number of parts for 'start' action: {action!r}"
4557            )
4558        (
4559            _,
4560            startActive,
4561            primary,
4562            domain,
4563            capabilities,
4564            mechanisms,
4565            custom
4566        ) = action
4567        Union[DecisionID, Dict[FocalPointName, DecisionID], Set[DecisionID]]
4568        at: str
4569        if primary is None:
4570            if isinstance(startActive, DecisionID):
4571                at = f" at {graph.identityOf(startActive)}"
4572            elif isinstance(startActive, dict):
4573                at = f" with {len(startActive)} focal point(s)"
4574            elif isinstance(startActive, set):
4575                at = f" from {len(startActive)} decisions"
4576            else:
4577                raise TypeError(
4578                    f"Invalid type for starting location:"
4579                    f" {type(startActive)}"
4580                )
4581        else:
4582            at = f" at {graph.identityOf(primary)}"
4583            if isinstance(startActive, dict):
4584                at += f" (among {len(startActive)} focal point(s))"
4585            elif isinstance(startActive, set):
4586                at += f" (among {len(startActive)} decisions)"
4587
4588        return (
4589            f"start exploring domain {domain}{at}"
4590        )
4591
4592    elif aType == 'explore':
4593        if len(action) == 7:
4594            assert isinstance(action[2], DecisionID)
4595            fromID = action[2]
4596            assert isinstance(action[3], tuple)
4597            transitionName, specified = action[3]
4598            assert isinstance(action[3][0], Transition)
4599            assert isinstance(action[3][1], list)
4600            assert all(isinstance(x, bool) for x in action[3][1])
4601        elif len(action) == 6:
4602            assert isinstance(action[1], tuple)
4603            assert len(action[1]) == 3
4604            fpPos = resolvePosition(situation, action[1])
4605            if fpPos is None:
4606                raise ValueError(
4607                    f"Invalid focal point specifier: no position found"
4608                    f" for:\n{action[1]}"
4609                )
4610            else:
4611                fromID = fpPos
4612            transitionName, specified = action[2]
4613        else:
4614            raise ValueError(
4615                f"Wrong number of parts for 'explore' action: {action!r}"
4616            )
4617
4618        destID = graph.getDestination(fromID, transitionName)
4619
4620        frDesc = graph.identityOf(fromID)
4621        deDesc = graph.identityOf(destID)
4622
4623        newNameOrDest: Union[DecisionName, DecisionID, None] = action[-3]
4624        nowWord = "now "
4625        if newNameOrDest is None:
4626            if destID is None:
4627                nowWord = ""
4628                newName = "INVALID: an unspecified + unnamed decision"
4629            else:
4630                nowWord = ""
4631                newName = graph.nameFor(destID)
4632        elif isinstance(newNameOrDest, DecisionName):
4633            newName = newNameOrDest
4634        else:
4635            assert isinstance(newNameOrDest, DecisionID)
4636            destID = newNameOrDest
4637            nowWord = "now reaches "
4638            newName = graph.identityOf(destID)
4639
4640        newZone: Union[Zone, None] = action[-1]
4641        if newZone in (None, ""):
4642            deDesc = f"{destID} ({nowWord}{newName})"
4643        else:
4644            deDesc = f"{destID} ({nowWord}{newZone}::{newName})"
4645            # TODO: Don't hardcode '::' here?
4646
4647        oDesc = ""
4648        if len(specified) > 0:
4649            oDesc = " with outcomes: "
4650            first = True
4651            for o in specified:
4652                if first:
4653                    first = False
4654                else:
4655                    oDesc += ", "
4656                if o:
4657                    oDesc += "success"
4658                else:
4659                    oDesc += "failure"
4660
4661        return (
4662            f"explore {transitionName} from decision {frDesc} to"
4663            f" {deDesc}{oDesc}"
4664        )
4665
4666    elif aType == 'take':
4667        if len(action) == 4:
4668            assert action[1] in get_args(ContextSpecifier)
4669            assert isinstance(action[2], DecisionID)
4670            assert isinstance(action[3], tuple)
4671            assert len(action[3]) == 2
4672            assert isinstance(action[3][0], Transition)
4673            assert isinstance(action[3][1], list)
4674            context = action[1]
4675            fromID = action[2]
4676            transitionName, specified = action[3]
4677            destID = graph.getDestination(fromID, transitionName)
4678            oDesc = ""
4679            if len(specified) > 0:
4680                oDesc = " with outcomes: "
4681                first = True
4682                for o in specified:
4683                    if first:
4684                        first = False
4685                    else:
4686                        oDesc += ", "
4687                    if o:
4688                        oDesc += "success"
4689                    else:
4690                        oDesc += "failure"
4691            if fromID == destID:  # an action
4692                return f"do action {transitionName}"
4693            else:  # normal transition
4694                frDesc = graph.identityOf(fromID)
4695                deDesc = graph.identityOf(destID)
4696
4697                return (
4698                    f"take {transitionName} from decision {frDesc} to"
4699                    f" {deDesc}{oDesc}"
4700                )
4701        elif len(action) == 3:
4702            assert isinstance(action[1], tuple)
4703            assert len(action[1]) == 3
4704            assert isinstance(action[2], tuple)
4705            assert len(action[2]) == 2
4706            assert isinstance(action[2][0], Transition)
4707            assert isinstance(action[2][1], list)
4708            _, focalPoint, transition = action
4709            context, domain, name = focalPoint
4710            frID = resolvePosition(situation, focalPoint)
4711
4712            transitionName, specified = action[2]
4713            oDesc = ""
4714            if len(specified) > 0:
4715                oDesc = " with outcomes: "
4716                first = True
4717                for o in specified:
4718                    if first:
4719                        first = False
4720                    else:
4721                        oDesc += ", "
4722                    if o:
4723                        oDesc += "success"
4724                    else:
4725                        oDesc += "failure"
4726
4727            if frID is None:
4728                return (
4729                    f"invalid action (moves {focalPoint} which doesn't"
4730                    f" exist)"
4731                )
4732            else:
4733                destID = graph.getDestination(frID, transitionName)
4734
4735                if frID == destID:
4736                    return "do action {transition}{oDesc}"
4737                else:
4738                    frDesc = graph.identityOf(frID)
4739                    deDesc = graph.identityOf(destID)
4740                    return (
4741                        f"{name} takes {transition} from {frDesc} to"
4742                        f" {deDesc}{oDesc}"
4743                    )
4744        else:
4745            raise ValueError(
4746                f"Wrong number of parts for 'take' action: {action!r}"
4747            )
4748
4749    elif aType == 'warp':
4750        if len(action) != 3:
4751            raise ValueError(
4752                f"Wrong number of parts for 'warp' action: {action!r}"
4753            )
4754        if action[1] in get_args(ContextSpecifier):
4755            assert isinstance(action[1], str)
4756            assert isinstance(action[2], DecisionID)
4757            _, context, destination = action
4758            deDesc = graph.identityOf(destination)
4759            return f"warp to {deDesc!r}"
4760        elif isinstance(action[1], tuple) and len(action[1]) == 3:
4761            assert isinstance(action[2], DecisionID)
4762            _, focalPoint, destination = action
4763            context, domain, name = focalPoint
4764            deDesc = graph.identityOf(destination)
4765            frID = resolvePosition(situation, focalPoint)
4766            frDesc = graph.identityOf(frID)
4767            return f"{name} warps to {deDesc!r}"
4768        else:
4769            raise TypeError(
4770                f"Invalid second part for 'warp' action: {action!r}"
4771            )
4772
4773    elif aType == 'focus':
4774        if len(action) != 4:
4775            raise ValueError(
4776                "Wrong number of parts for 'focus' action: {action!r}"
4777            )
4778        _, context, deactivate, activate = action
4779        assert isinstance(deactivate, set)
4780        assert isinstance(activate, set)
4781        result = "change in active domains: "
4782        clauses = []
4783        if len(deactivate) > 0:
4784            clauses.append("deactivate domain(s) {', '.join(deactivate)}")
4785        if len(activate) > 0:
4786            clauses.append("activate domain(s) {', '.join(activate)}")
4787        result += '; '.join(clauses)
4788        return result
4789
4790    elif aType == 'swap':
4791        if len(action) != 2:
4792            raise ValueError(
4793                "Wrong number of parts for 'swap' action: {action!r}"
4794            )
4795        _, fcName = action
4796        return f"swap to focal context {fcName!r}"
4797
4798    elif aType == 'focalize':
4799        if len(action) != 2:
4800            raise ValueError(
4801                "Wrong number of parts for 'focalize' action: {action!r}"
4802            )
4803        _, fcName = action
4804        return f"create new focal context {fcName!r}"
4805
4806    else:
4807        raise RuntimeError(
4808            "Missing case for exploration action type: {action[0]!r}"
4809        )
4810
4811
4812DecisionType = Literal[
4813    "pending",
4814    "active",
4815    "unintended",
4816    "imposed",
4817    "consequence"
4818]
4819"""
4820The types for decisions are:
4821- 'pending': A decision that hasn't been made yet.
4822- 'active': A decision made actively and consciously (the default).
4823- 'unintended': A decision was made but the execution of that decision
4824    resulted in a different action than the one intended (note that we
4825    don't currently record the original intent). TODO: that?
4826- 'imposed': A course of action was changed/taken, but no conscious
4827    decision was made, meaning that the action was imposed by external
4828    circumstances.
4829- 'consequence': A different course of action resulted in a follow-up
4830    consequence that wasn't part of the original intent.
4831"""
4832
4833
4834class Situation(NamedTuple):
4835    """
4836    Holds all of the pieces of an exploration's state at a single
4837    exploration step, including:
4838
4839    - 'graph': The `DecisionGraph` for that step. Continuity between
4840        graphs can be established because they use the same `DecisionID`
4841        for unchanged nodes.
4842    - 'state': The game `State` for that step, including common and
4843        active `FocalContext`s which determine both what capabilities
4844        are active in the step and which decision point(s) the player
4845        may select an option at.
4846    - 'type': The `DecisionType` for the decision made at this
4847        situation.
4848    - 'taken': an `ExplorationAction` specifying what action was taken,
4849        or `None` for situations where an action has not yet been
4850        decided on (distinct from `(`noAction`,)` for waiting). The
4851        effects of that action are represented by the following
4852        `Situation` in the `DiscreteExploration`. Note that the final
4853        situation in an exploration will also use `('noAction',)` as the
4854        'taken' value to indicate that either no further choices are
4855        possible (e.g., at an ending), or it will use `None` to indicate
4856        that no choice has been made yet.
4857    - 'saves': A dictionary mapping save-slot names to (graph, state)
4858        pairs for saved states.
4859    - 'tags': A dictionary of tag-name: tag-value information for this
4860        step, allowing custom tags with custom values to be added.
4861    - 'annotations': A list of `Annotation` strings allowing custom
4862        annotations to be applied to a situation.
4863    """
4864    graph: 'DecisionGraph'
4865    state: State
4866    type: DecisionType
4867    action: Optional[ExplorationAction]
4868    saves: Dict[SaveSlot, Tuple['DecisionGraph', State]]
4869    tags: Dict[Tag, TagValue]
4870    annotations: List[Annotation]
4871
4872
4873#-----------------------------#
4874# Situation support functions #
4875#-----------------------------#
4876
4877def contextForTransition(
4878    situation: Situation,
4879    decision: AnyDecisionSpecifier,
4880    transition: Transition
4881) -> RequirementContext:
4882    """
4883    Given a `Situation` along with an `AnyDecisionSpecifier` and a
4884    `Transition` that together identify a particular transition of
4885    interest, returns the appropriate `RequirementContext` to use to
4886    evaluate requirements and resolve consequences for that transition,
4887    which involves the state & graph from the specified situation, along
4888    with the two ends of that transition as the search-from location.
4889    """
4890    return RequirementContext(
4891        graph=situation.graph,
4892        state=situation.state,
4893        searchFrom=situation.graph.bothEnds(decision, transition)
4894    )
4895
4896
4897def genericContextForSituation(
4898    situation: Situation,
4899    searchFrom: Optional[Set[DecisionID]] = None
4900) -> RequirementContext:
4901    """
4902    Turns a `Situation` into a `RequirementContext` without a specific
4903    transition as the origin (use `contextForTransition` if there's a
4904    relevant transition). By default, the `searchFrom` part of the
4905    requirement context will be the set of active decisions in the
4906    situation, but the search-from part can be overridden by supplying
4907    an explicit `searchFrom` set of decision IDs here.
4908    """
4909    if searchFrom is None:
4910        searchFrom = combinedDecisionSet(situation.state)
4911
4912    return RequirementContext(
4913        state=situation.state,
4914        graph=situation.graph,
4915        searchFrom=searchFrom
4916    )
4917
4918
4919def hasCapabilityOrEquivalent(
4920    capability: Capability,
4921    context: RequirementContext,
4922    dontRecurse: Optional[
4923        Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
4924    ] = None
4925):
4926    """
4927    Determines whether a capability should be considered obtained for the
4928    purposes of requirements, given an entire game state and an
4929    equivalences dictionary which maps capabilities and/or
4930    mechanism/state pairs  to sets of requirements that when fulfilled
4931    should count as activating that capability or mechanism state.
4932    """
4933    if dontRecurse is None:
4934        dontRecurse = set()
4935
4936    if (
4937        capability in context.state['common']['capabilities']['capabilities']
4938     or capability in (
4939            context.state['contexts']
4940                [context.state['activeContext']]
4941                ['capabilities']
4942                ['capabilities']
4943        )
4944    ):
4945        return True  # Capability is explicitly obtained
4946    elif capability in dontRecurse:
4947        return False  # Treat circular requirements as unsatisfied
4948    elif not context.graph.hasAnyEquivalents(capability):
4949        # No equivalences to check
4950        return False
4951    else:
4952        # Need to check for a satisfied equivalence
4953        subDont = set(dontRecurse)  # Where not to recurse
4954        subDont.add(capability)
4955        # equivalences for this capability
4956        options = context.graph.allEquivalents(capability)
4957        for req in options:
4958            if req.satisfied(context, subDont):
4959                return True
4960
4961        return False
4962
4963
4964def stateOfMechanism(
4965    ctx: RequirementContext,
4966    mechanism: AnyMechanismSpecifier
4967) -> MechanismState:
4968    """
4969    Returns the current state of the specified mechanism, returning
4970    `DEFAULT_MECHANISM_STATE` if that mechanism doesn't yet have an
4971    assigned state.
4972    """
4973    mID = ctx.graph.resolveMechanism(mechanism, ctx.searchFrom)
4974
4975    return ctx.state['mechanisms'].get(
4976        mID,
4977        DEFAULT_MECHANISM_STATE
4978    )
4979
4980
4981def mechanismInStateOrEquivalent(
4982    mechanism: AnyMechanismSpecifier,
4983    reqState: MechanismState,
4984    context: RequirementContext,
4985    dontRecurse: Optional[
4986        Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
4987    ] = None
4988):
4989    """
4990    Determines whether a mechanism should be considered as being in the
4991    given state for the purposes of requirements, given an entire game
4992    state and an equivalences dictionary which maps capabilities and/or
4993    mechanism/state pairs to sets of requirements that when fulfilled
4994    should count as activating that capability or mechanism state.
4995
4996    The `dontRecurse` set of capabilities and/or mechanisms indicates
4997    requirements which should not be considered for alternate
4998    fulfillment during recursion.
4999
5000    Mechanisms with unspecified state are considered to be in the
5001    `DEFAULT_MECHANISM_STATE`, but mechanisms which don't exist are not
5002    considered to be in any state (i.e., this will always return False).
5003    """
5004    if dontRecurse is None:
5005        dontRecurse = set()
5006
5007    mID = context.graph.resolveMechanism(mechanism, context.searchFrom)
5008
5009    currentState = stateOfMechanism(context, mID)
5010    if currentState == reqState:
5011        return True  # Mechanism is explicitly in the target state
5012    elif (mID, reqState) in dontRecurse:
5013        return False  # Treat circular requirements as unsatisfied
5014    elif not context.graph.hasAnyEquivalents((mID, reqState)):
5015        return False  # If there are no equivalences, nothing to check
5016    else:
5017        # Need to check for a satisfied equivalence
5018        subDont = set(dontRecurse)  # Where not to recurse
5019        subDont.add((mID, reqState))
5020        # equivalences for this capability
5021        options = context.graph.allEquivalents((mID, reqState))
5022        for req in options:
5023            if req.satisfied(context, subDont):
5024                return True
5025
5026        return False
5027
5028
5029def combinedTokenCount(state: State, tokenType: Token) -> TokenCount:
5030    """
5031    Returns the token count for a particular token type for a state,
5032    combining tokens from the common and active `FocalContext`s.
5033    """
5034    return (
5035        state['common']['capabilities']['tokens'].get(tokenType, 0)
5036      + state[
5037          'contexts'
5038        ][state['activeContext']]['capabilities']['tokens'].get(tokenType, 0)
5039    )
5040
5041
5042def explorationStatusOf(
5043    situation: Situation,
5044    decision: AnyDecisionSpecifier
5045) -> ExplorationStatus:
5046    """
5047    Returns the exploration status of the specified decision in the
5048    given situation, or the `DEFAULT_EXPLORATION_STATUS` if no status
5049    has been set for that decision.
5050    """
5051    dID = situation.graph.resolveDecision(decision)
5052    return situation.state['exploration'].get(
5053        dID,
5054        DEFAULT_EXPLORATION_STATUS
5055    )
5056
5057
5058def setExplorationStatus(
5059    situation: Situation,
5060    decision: AnyDecisionSpecifier,
5061    status: ExplorationStatus,
5062    upgradeOnly: bool = False
5063) -> None:
5064    """
5065    Sets the exploration status of the specified decision in the
5066    given situation. If `upgradeOnly` is set to True (default is False)
5067    then the exploration status will be changed only if the new status
5068    counts as more-explored than the old one (see `moreExplored`).
5069    """
5070    dID = situation.graph.resolveDecision(decision)
5071    eMap = situation.state['exploration']
5072    if upgradeOnly:
5073        status = moreExplored(
5074            status,
5075            eMap.get(dID, 'unknown')
5076        )
5077    eMap[dID] = status
5078
5079
5080def hasBeenVisited(
5081    situation: Situation,
5082    decision: AnyDecisionSpecifier
5083) -> bool:
5084    """
5085    Returns `True` if the specified decision has an exploration status
5086    which counts as having been visited (see `base.statusVisited`). Note
5087    that this works differently from `DecisionGraph.isConfirmed` which
5088    just checks for the 'unconfirmed' tag.
5089    """
5090    return statusVisited(explorationStatusOf(situation, decision))
5091
5092
5093class IndexTooFarError(IndexError):
5094    """
5095    An index error that also holds a number specifying how far beyond
5096    the end of the valid indices the given index was. If you are '0'
5097    beyond the end that means you're at the next element after the end.
5098    """
5099    def __init__(self, msg, beyond=0):
5100        """
5101        You need a message and can also include a 'beyond' value
5102        (default is 0).
5103        """
5104        self.msg = msg
5105        self.beyond = beyond
5106
5107    def __str__(self):
5108        return self.msg + f" ({self.beyond} beyond sequence)"
5109
5110    def __repr(self):
5111        return f"IndexTooFarError({repr(self.msg)}, {repr(self.beyond)})"
5112
5113
5114def countParts(
5115    consequence: Union[Consequence, Challenge, Condition, Effect]
5116) -> int:
5117    """
5118    Returns the number of parts the given consequence has for
5119    depth-first indexing purposes. The consequence itself counts as a
5120    part, plus each `Challenge`, `Condition`, and `Effect` within it,
5121    along with the part counts of any sub-`Consequence`s in challenges
5122    or conditions.
5123
5124    For example:
5125
5126    >>> countParts([])
5127    1
5128    >>> countParts([effect(gain='jump'), effect(lose='jump')])
5129    3
5130    >>> c = [  # 1
5131    ...     challenge(  # 2
5132    ...         skills=BestSkill('skill'),
5133    ...         level=4,
5134    ...         success=[],  # 3
5135    ...         failure=[effect(lose=('money', 10))],  # 4, 5
5136    ...         outcome=True
5137    ...    ),
5138    ...    condition(  # 6
5139    ...        ReqCapability('jump'),
5140    ...        [],  # 7
5141    ...        [effect(gain='jump')]  # 8, 9
5142    ...    ),
5143    ...    effect(set=('door', 'open'))  # 10
5144    ... ]
5145    >>> countParts(c)
5146    10
5147    >>> countParts(c[0])
5148    4
5149    >>> countParts(c[1])
5150    4
5151    >>> countParts(c[2])
5152    1
5153    >>> # (last part of the 10 is the outer list itself)
5154    >>> c = [  # index 0
5155    ...     effect(gain='happy'),  # index 1
5156    ...     challenge(  # index 2
5157    ...         skills=BestSkill('strength'),
5158    ...         success=[effect(gain='winner')]  # indices 3 & 4
5159    ...         # failure is implicit; gets index 5
5160    ...     )  # level defaults to 0
5161    ... ]
5162    >>> countParts(c)
5163    6
5164    >>> countParts(c[0])
5165    1
5166    >>> countParts(c[1])
5167    4
5168    >>> countParts(c[1]['success'])
5169    2
5170    >>> countParts(c[1]['failure'])
5171    1
5172    """
5173    total = 1
5174    if isinstance(consequence, list):
5175        for part in consequence:
5176            total += countParts(part)
5177    elif isinstance(consequence, dict):
5178        if 'skills' in consequence:  # it's a Challenge
5179            consequence = cast(Challenge, consequence)
5180            total += (
5181                countParts(consequence['success'])
5182              + countParts(consequence['failure'])
5183            )
5184        elif 'condition' in consequence:  # it's a Condition
5185            consequence = cast(Condition, consequence)
5186            total += (
5187                countParts(consequence['consequence'])
5188              + countParts(consequence['alternative'])
5189            )
5190        elif 'value' in consequence:  # it's an Effect
5191            pass  # counted already
5192        else:  # bad dict
5193            raise TypeError(
5194                f"Invalid consequence: items must be Effects,"
5195                f" Challenges, or Conditions (got a dictionary without"
5196                f" 'skills', 'value', or 'condition' keys)."
5197                f"\nGot consequence: {repr(consequence)}"
5198            )
5199    else:
5200        raise TypeError(
5201            f"Invalid consequence: must be an Effect, Challenge, or"
5202            f" Condition, or a list of those."
5203            f"\nGot part: {repr(consequence)}"
5204        )
5205
5206    return total
5207
5208
5209def walkParts(
5210    consequence: Union[Consequence, Challenge, Condition, Effect],
5211    startIndex: int = 0
5212) -> Generator[
5213    Tuple[int, Union[Consequence, Challenge, Condition, Effect]],
5214    None,
5215    None
5216]:
5217    """
5218    Yields tuples containing all indices and the associated
5219    `Consequence`s in the given consequence tree, in depth-first
5220    traversal order.
5221
5222    A `startIndex` other than 0 may be supplied and the indices yielded
5223    will start there.
5224
5225    For example:
5226
5227    >>> list(walkParts([]))
5228    [(0, [])]
5229    >>> e = []
5230    >>> list(walkParts(e))[0][1] is e
5231    True
5232    >>> c = [effect(gain='jump'), effect(lose='jump')]
5233    >>> list(walkParts(c)) == [
5234    ...     (0, c),
5235    ...     (1, c[0]),
5236    ...     (2, c[1]),
5237    ... ]
5238    True
5239    >>> c = [  # 1
5240    ...     challenge(  # 2
5241    ...         skills=BestSkill('skill'),
5242    ...         level=4,
5243    ...         success=[],  # 3
5244    ...         failure=[effect(lose=('money', 10))],  # 4, 5
5245    ...         outcome=True
5246    ...    ),
5247    ...    condition(  # 6
5248    ...        ReqCapability('jump'),
5249    ...        [],  # 7
5250    ...        [effect(gain='jump')]  # 8, 9
5251    ...    ),
5252    ...    effect(set=('door', 'open'))  # 10
5253    ... ]
5254    >>> list(walkParts(c)) == [
5255    ...     (0, c),
5256    ...     (1, c[0]),
5257    ...     (2, c[0]['success']),
5258    ...     (3, c[0]['failure']),
5259    ...     (4, c[0]['failure'][0]),
5260    ...     (5, c[1]),
5261    ...     (6, c[1]['consequence']),
5262    ...     (7, c[1]['alternative']),
5263    ...     (8, c[1]['alternative'][0]),
5264    ...     (9, c[2]),
5265    ... ]
5266    True
5267    """
5268    index = startIndex
5269    yield (index, consequence)
5270    index += 1
5271    if isinstance(consequence, list):
5272        for part in consequence:
5273            for (subIndex, subItem) in walkParts(part, index):
5274                yield (subIndex, subItem)
5275            index = subIndex + 1
5276    elif isinstance(consequence, dict) and 'skills' in consequence:
5277        # a Challenge
5278        challenge = cast(Challenge, consequence)
5279        for (subIndex, subItem) in walkParts(challenge['success'], index):
5280            yield (subIndex, subItem)
5281        index = subIndex + 1
5282        for (subIndex, subItem) in walkParts(challenge['failure'], index):
5283            yield (subIndex, subItem)
5284    elif isinstance(consequence, dict) and 'condition' in consequence:
5285        # a Condition
5286        condition = cast(Condition, consequence)
5287        for (subIndex, subItem) in walkParts(
5288            condition['consequence'],
5289            index
5290        ):
5291            yield (subIndex, subItem)
5292        index = subIndex + 1
5293        for (subIndex, subItem) in walkParts(
5294            condition['alternative'],
5295            index
5296        ):
5297            yield (subIndex, subItem)
5298    elif isinstance(consequence, dict) and 'value' in consequence:
5299        # an Effect; we already yielded it above
5300        pass
5301    else:
5302        raise TypeError(
5303            f"Invalid consequence: items must be lists, Effects,"
5304            f" Challenges, or Conditions.\nGot part:"
5305            f" {repr(consequence)}"
5306        )
5307
5308
5309def consequencePart(
5310    consequence: Consequence,
5311    index: int
5312) -> Union[Consequence, Challenge, Condition, Effect]:
5313    """
5314    Given a `Consequence`, returns the part at the specified index, in
5315    depth-first traversal order, including the consequence itself at
5316    index 0. Raises an `IndexTooFarError` if the index is beyond the end
5317    of the tree; the 'beyond' value of the error will indicate how many
5318    indices beyond the end it was, with 0 for an index that's just
5319    beyond the end.
5320
5321    For example:
5322
5323    >>> c = []
5324    >>> consequencePart(c, 0) is c
5325    True
5326    >>> try:
5327    ...     consequencePart(c, 1)
5328    ... except IndexTooFarError as e:
5329    ...     e.beyond
5330    0
5331    >>> try:
5332    ...     consequencePart(c, 2)
5333    ... except IndexTooFarError as e:
5334    ...     e.beyond
5335    1
5336    >>> c = [effect(gain='jump'), effect(lose='jump')]
5337    >>> consequencePart(c, 0) is c
5338    True
5339    >>> consequencePart(c, 1) is c[0]
5340    True
5341    >>> consequencePart(c, 2) is c[1]
5342    True
5343    >>> try:
5344    ...     consequencePart(c, 3)
5345    ... except IndexTooFarError as e:
5346    ...     e.beyond
5347    0
5348    >>> try:
5349    ...     consequencePart(c, 4)
5350    ... except IndexTooFarError as e:
5351    ...     e.beyond
5352    1
5353    >>> c = [
5354    ...     challenge(
5355    ...         skills=BestSkill('skill'),
5356    ...         level=4,
5357    ...         success=[],
5358    ...         failure=[effect(lose=('money', 10))],
5359    ...         outcome=True
5360    ...    ),
5361    ...    condition(ReqCapability('jump'), [], [effect(gain='jump')]),
5362    ...    effect(set=('door', 'open'))
5363    ... ]
5364    >>> consequencePart(c, 0) is c
5365    True
5366    >>> consequencePart(c, 1) is c[0]
5367    True
5368    >>> consequencePart(c, 2) is c[0]['success']
5369    True
5370    >>> consequencePart(c, 3) is c[0]['failure']
5371    True
5372    >>> consequencePart(c, 4) is c[0]['failure'][0]
5373    True
5374    >>> consequencePart(c, 5) is c[1]
5375    True
5376    >>> consequencePart(c, 6) is c[1]['consequence']
5377    True
5378    >>> consequencePart(c, 7) is c[1]['alternative']
5379    True
5380    >>> consequencePart(c, 8) is c[1]['alternative'][0]
5381    True
5382    >>> consequencePart(c, 9) is c[2]
5383    True
5384    >>> consequencePart(c, 10)
5385    Traceback (most recent call last):
5386    ...
5387    exploration.base.IndexTooFarError...
5388    >>> try:
5389    ...     consequencePart(c, 10)
5390    ... except IndexTooFarError as e:
5391    ...     e.beyond
5392    0
5393    >>> try:
5394    ...     consequencePart(c, 11)
5395    ... except IndexTooFarError as e:
5396    ...     e.beyond
5397    1
5398    >>> try:
5399    ...     consequencePart(c, 14)
5400    ... except IndexTooFarError as e:
5401    ...     e.beyond
5402    4
5403    """
5404    if index == 0:
5405        return consequence
5406    index -= 1
5407    for part in consequence:
5408        if index == 0:
5409            return part
5410        else:
5411            index -= 1
5412        if not isinstance(part, dict):
5413            raise TypeError(
5414                f"Invalid consequence: items in the list must be"
5415                f" Effects, Challenges, or Conditions."
5416                f"\nGot part: {repr(part)}"
5417            )
5418        elif 'skills' in part:  # it's a Challenge
5419            part = cast(Challenge, part)
5420            try:
5421                return consequencePart(part['success'], index)
5422            except IndexTooFarError as e:
5423                index = e.beyond
5424            try:
5425                return consequencePart(part['failure'], index)
5426            except IndexTooFarError as e:
5427                index = e.beyond
5428        elif 'condition' in part:  # it's a Condition
5429            part = cast(Condition, part)
5430            try:
5431                return consequencePart(part['consequence'], index)
5432            except IndexTooFarError as e:
5433                index = e.beyond
5434            try:
5435                return consequencePart(part['alternative'], index)
5436            except IndexTooFarError as e:
5437                index = e.beyond
5438        elif 'value' in part:  # it's an Effect
5439            pass  # if index was 0, we would have returned this part already
5440        else:  # bad dict
5441            raise TypeError(
5442                f"Invalid consequence: items in the list must be"
5443                f" Effects, Challenges, or Conditions (got a dictionary"
5444                f" without 'skills', 'value', or 'condition' keys)."
5445                f"\nGot part: {repr(part)}"
5446            )
5447
5448    raise IndexTooFarError(
5449        "Part index beyond end of consequence.",
5450        index
5451    )
5452
5453
5454def lookupEffect(
5455    situation: Situation,
5456    effect: EffectSpecifier
5457) -> Effect:
5458    """
5459    Looks up an effect within a situation.
5460    """
5461    graph = situation.graph
5462    root = graph.getConsequence(effect[0], effect[1])
5463    try:
5464        result = consequencePart(root, effect[2])
5465    except IndexTooFarError:
5466        raise IndexError(
5467            f"Invalid effect specifier (consequence has too few parts):"
5468            f" {effect}"
5469        )
5470
5471    if not isinstance(result, dict) or 'value' not in result:
5472        raise IndexError(
5473            f"Invalid effect specifier (part is not an Effect):"
5474            f" {effect}\nGot a/an {type(result)}:"
5475            f"\n  {result}"
5476        )
5477
5478    return cast(Effect, result)
5479
5480
5481def triggerCount(
5482    situation: Situation,
5483    effect: EffectSpecifier
5484) -> int:
5485    """
5486    Looks up the trigger count for the specified effect in the given
5487    situation. This includes times the effect has been triggered but
5488    didn't actually do anything because of its delay and/or charges
5489    values.
5490    """
5491    return situation.state['effectCounts'].get(effect, 0)
5492
5493
5494def incrementTriggerCount(
5495    situation: Situation,
5496    effect: EffectSpecifier,
5497    add: int = 1
5498) -> None:
5499    """
5500    Adds one (or the specified `add` value) to the trigger count for the
5501    specified effect in the given situation.
5502    """
5503    counts = situation.state['effectCounts']
5504    if effect in counts:
5505        counts[effect] += add
5506    else:
5507        counts[effect] = add
5508
5509
5510def doTriggerEffect(
5511    situation: Situation,
5512    effect: EffectSpecifier
5513) -> Tuple[Effect, Optional[int]]:
5514    """
5515    Looks up the trigger count for the given effect, adds one, and then
5516    returns a tuple with the effect, plus the effective trigger count or
5517    `None`, returning `None` if the effect's charges or delay values
5518    indicate that based on its new trigger count, it should not actually
5519    fire, and otherwise returning a modified trigger count that takes
5520    delay into account.
5521
5522    For example, if an effect has 2 delay and 3 charges and has been
5523    activated once, it will not actually trigger (since its delay value
5524    is still playing out). Once it hits the third attempted trigger, it
5525    will activate with an effective activation count of 1, since that's
5526    the first time it actually applies. Of course, on the 6th and
5527    subsequent activation attempts, it will once more cease to trigger
5528    because it will be out of charges.
5529    """
5530    counts = situation.state['effectCounts']
5531    thisCount = counts.get(effect, 0)
5532    counts[effect] = thisCount + 1  # increment the total count
5533
5534    # Get charges and delay values
5535    effectDetails = lookupEffect(situation, effect)
5536    delay = effectDetails['delay'] or 0
5537    charges = effectDetails['charges']
5538
5539    delayRemaining = delay - thisCount
5540    if delayRemaining > 0:
5541        return (effectDetails, None)
5542    else:
5543        thisCount -= delay
5544
5545    if charges is None:
5546        return (effectDetails, thisCount)
5547    else:
5548        chargesRemaining = charges - thisCount
5549        if chargesRemaining >= 0:
5550            return (effectDetails, thisCount)
5551        else:
5552            return (effectDetails, None)
5553
5554
5555#------------------#
5556# Position support #
5557#------------------#
5558
5559def resolvePosition(
5560    situation: Situation,
5561    posSpec: Union[Tuple[ContextSpecifier, Domain], FocalPointSpecifier]
5562) -> Optional[DecisionID]:
5563    """
5564    Given a tuple containing either a specific context plus a specific
5565    domain (which must be singular-focalized) or a full
5566    `FocalPointSpecifier`, this function returns the decision ID implied
5567    by the given specifier within the given situation, or `None` if the
5568    specifier is valid but the position for that specifier is `None`
5569    (including when the domain is not-yet-encountered). For
5570    singular-focalized domains, this is just the position value for that
5571    domain. For plural-focalized domains, you need to provide a
5572    `FocalPointSpecifier` and it's the position of that focal point.
5573    """
5574    fpName: Optional[FocalPointName] = None
5575    if len(posSpec) == 2:
5576        posSpec = cast(Tuple[ContextSpecifier, Domain], posSpec)
5577        whichContext, domain = posSpec
5578    elif len(posSpec) == 3:
5579        posSpec = cast(FocalPointSpecifier, posSpec)
5580        whichContext, domain, fpName = posSpec
5581    else:
5582        raise ValueError(
5583            f"Invalid position specifier {repr(posSpec)}. Must be a"
5584            f" length-2 or length-3 tuple."
5585        )
5586
5587    state = situation.state
5588    if whichContext == 'common':
5589        targetContext = state['common']
5590    else:
5591        targetContext = state['contexts'][state['activeContext']]
5592    focalization = getDomainFocalization(targetContext, domain)
5593
5594    if fpName is None:
5595        if focalization != 'singular':
5596            raise ValueError(
5597                f"Cannot resolve position {repr(posSpec)} because the"
5598                f" domain {repr(domain)} is not singular-focalized."
5599            )
5600        result = targetContext['activeDecisions'].get(domain)
5601        assert isinstance(result, DecisionID)
5602        return result
5603    else:
5604        if focalization != 'plural':
5605            raise ValueError(
5606                f"Cannot resolve position {repr(posSpec)} because a"
5607                f" focal point name was specified but the domain"
5608                f" {repr(domain)} is not plural-focalized."
5609            )
5610        fpMap = targetContext['activeDecisions'].get(domain, {})
5611        #  Double-check types for map itself and at least one entry
5612        assert isinstance(fpMap, dict)
5613        if len(fpMap) > 0:
5614            exKey = next(iter(fpMap))
5615            exVal = fpMap[exKey]
5616            assert isinstance(exKey, FocalPointName)
5617            assert exVal is None or isinstance(exVal, DecisionID)
5618        if fpName not in fpMap:
5619            raise ValueError(
5620                f"Cannot resolve position {repr(posSpec)} because no"
5621                f" focal point with name {repr(fpName)} exists in"
5622                f" domain {repr(domain)} for the {whichContext}"
5623                f" context."
5624            )
5625        return fpMap[fpName]
5626
5627
5628def updatePosition(
5629    situation: Situation,
5630    newPosition: DecisionID,
5631    inCommon: ContextSpecifier = "active",
5632    moveWhich: Optional[FocalPointName] = None
5633) -> None:
5634    """
5635    Given a Situation, updates the position information in that
5636    situation to represent updated player focalization. This can be as
5637    simple as a move from one virtual decision to an adjacent one, or as
5638    complicated as a cross-domain move where the previous decision point
5639    remains active and a specific focal point among a plural-focalized
5640    domain gets updated.
5641
5642    The exploration status of the destination will be set to 'exploring'
5643    if it had been an unexplored status, and the 'visiting' tag in the
5644    `DecisionGraph` will be added (set to 1).
5645
5646    TODO: Examples
5647    """
5648    graph = situation.graph
5649    state = situation.state
5650    destDomain = graph.domainFor(newPosition)
5651
5652    # Set the primary decision of the state
5653    state['primaryDecision'] = newPosition
5654
5655    if inCommon == 'common':
5656        targetContext = state['common']
5657    else:
5658        targetContext = state['contexts'][state['activeContext']]
5659
5660    # Figure out focalization type and active decision(s)
5661    fType = getDomainFocalization(targetContext, destDomain)
5662    domainActiveMap = targetContext['activeDecisions']
5663    if destDomain in domainActiveMap:
5664        active = domainActiveMap[destDomain]
5665    else:
5666        if fType == 'singular':
5667            active = domainActiveMap.setdefault(destDomain, None)
5668        elif fType == 'plural':
5669            active = domainActiveMap.setdefault(destDomain, {})
5670        else:
5671            assert fType == 'spreading'
5672            active = domainActiveMap.setdefault(destDomain, set())
5673
5674    if fType == 'plural':
5675        assert isinstance(active, dict)
5676        if len(active) > 0:
5677            exKey = next(iter(active))
5678            exVal = active[exKey]
5679            assert isinstance(exKey, FocalPointName)
5680            assert exVal is None or isinstance(exVal, DecisionID)
5681        if moveWhich is None and len(active) > 1:
5682            raise ValueError(
5683                f"Invalid position update: move is going to decision"
5684                f" {graph.identityOf(newPosition)} in domain"
5685                f" {repr(destDomain)}, but it did not specify which"
5686                f" focal point to move, and that domain has plural"
5687                f" focalization with more than one focal point."
5688            )
5689        elif moveWhich is None:
5690            moveWhich = list(active)[0]
5691
5692        # Actually move the specified focal point
5693        active[moveWhich] = newPosition
5694
5695    elif moveWhich is not None:
5696        raise ValueError(
5697            f"Invalid position update: move going to decision"
5698            f" {graph.identityOf(newPosition)} in domain"
5699            f" {repr(destDomain)}, specified that focal point"
5700            f" {repr(moveWhich)} should be moved, but that domain does"
5701            f" not have plural focalization, so it does not have"
5702            f" multiple focal points to move."
5703        )
5704
5705    elif fType == 'singular':
5706        # Update the single position:
5707        domainActiveMap[destDomain] = newPosition
5708
5709    elif fType == 'spreading':
5710        # Add the new position:
5711        assert isinstance(active, set)
5712        active.add(newPosition)
5713
5714    else:
5715        raise ValueError(f"Invalid focalization value: {repr(fType)}")
5716
5717    graph.untagDecision(newPosition, 'unconfirmed')
5718    if not hasBeenVisited(situation, newPosition):
5719        setExplorationStatus(
5720            situation,
5721            newPosition,
5722            'exploring',
5723            upgradeOnly=True
5724        )
5725
5726
5727#----------------#
5728# Layout support #
5729#----------------#
5730
5731LayoutPosition: 'TypeAlias' = Tuple[float, float]
5732"""
5733An (x, y) pair in unspecified coordinates.
5734"""
5735
5736
5737Layout: 'TypeAlias' = Dict[DecisionID, LayoutPosition]
5738"""
5739Maps one or more decision IDs to `LayoutPosition`s for those decisions.
5740"""
5741
5742#--------------------------------#
5743# Geographic exploration support #
5744#--------------------------------#
5745
5746PointID: 'TypeAlias' = int
5747
5748Coords: 'TypeAlias' = Sequence[float]
5749
5750AnyPoint: 'TypeAlias' = Union[PointID, Coords]
5751
5752Feature: 'TypeAlias' = str
5753"""
5754Each feature in a `FeatureGraph` gets a globally unique id, but also has
5755an explorer-assigned name. These names may repeat themselves (especially
5756in different regions) so a region-based address, possibly with a
5757creation-order numeral, can be used to specify a feature exactly even
5758without using its ID. Any string can be used, but for ease of parsing
5759and conversion between formats, sticking to alphanumerics plus
5760underscores is usually desirable.
5761"""
5762
5763FeatureID: 'TypeAlias' = int
5764"""
5765Features in a feature graph have unique integer identifiers that are
5766assigned automatically in order of creation.
5767"""
5768
5769Part: 'TypeAlias' = str
5770"""
5771Parts of a feature are identified using strings. Standard part names
5772include 'middle', compass directions, and top/bottom. To include both a
5773compass direction and a vertical position, put the vertical position
5774first and separate with a dash, like 'top-north'. Temporal positions
5775like start/end may also apply in some cases.
5776"""
5777
5778
5779class FeatureSpecifier(NamedTuple):
5780    """
5781    There are several ways to specify a feature within a `FeatureGraph`:
5782    Simplest is to just include the `FeatureID` directly (in that case
5783    the domain must be `None` and the 'within' sequence must be empty).
5784    A specific domain and/or a sequence of containing features (starting
5785    from most-external to most-internal) may also be specified when a
5786    string is used as the feature itself, to help disambiguate (when an
5787    ambiguous `FeatureSpecifier` is used,
5788    `AmbiguousFeatureSpecifierError` may arise in some cases). For any
5789    feature, a part may also be specified indicating which part of the
5790    feature is being referred to; this can be `None` when not referring
5791    to any specific sub-part.
5792    """
5793    domain: Optional[Domain]
5794    within: Sequence[Feature]
5795    feature: Union[Feature, FeatureID]
5796    part: Optional[Part]
5797
5798
5799def feature(
5800    name: Feature,
5801    part: Optional[Part] = None,
5802    domain: Optional[Domain] = None,
5803    within: Optional[Sequence[Feature]] = None
5804) -> FeatureSpecifier:
5805    """
5806    Builds a `FeatureSpecifier` with some defaults. The default domain
5807    is `None`, and by default the feature has an empty 'within' field and
5808    its part field is `None`.
5809    """
5810    if within is None:
5811        within = []
5812    return FeatureSpecifier(
5813        domain=domain,
5814        within=within,
5815        feature=name,
5816        part=part
5817    )
5818
5819
5820AnyFeatureSpecifier: 'TypeAlias' = Union[
5821    FeatureID,
5822    Feature,
5823    FeatureSpecifier
5824]
5825"""
5826A type for locations where a feature may be specified multiple different
5827ways: directly by ID, by full feature specifier, or by a string
5828identifying a feature name. You can use `normalizeFeatureSpecifier` to
5829convert one of these to a `FeatureSpecifier`.
5830"""
5831
5832
5833def normalizeFeatureSpecifier(spec: AnyFeatureSpecifier) -> FeatureSpecifier:
5834    """
5835    Turns an `AnyFeatureSpecifier` into a `FeatureSpecifier`. Note that
5836    it does not do parsing from a complex string. Use
5837    `parsing.ParseFormat.parseFeatureSpecifier` for that.
5838
5839    It will turn a feature specifier with an int-convertible feature name
5840    into a feature-ID-based specifier, discarding any domain and/or zone
5841    parts.
5842
5843    TODO: Issue a warning if parts are discarded?
5844    """
5845    if isinstance(spec, (FeatureID, Feature)):
5846        return FeatureSpecifier(
5847            domain=None,
5848            within=[],
5849            feature=spec,
5850            part=None
5851        )
5852    elif isinstance(spec, FeatureSpecifier):
5853        try:
5854            fID = int(spec.feature)
5855            return FeatureSpecifier(None, [], fID, spec.part)
5856        except ValueError:
5857            return spec
5858    else:
5859        raise TypeError(
5860            f"Invalid feature specifier type: {type(spec)}"
5861        )
5862
5863
5864class MetricSpace:
5865    """
5866    TODO
5867    Represents a variable-dimensional coordinate system within which
5868    locations can be identified by coordinates. May (or may not) include
5869    a reference to one or more images which are visual representation(s)
5870    of the space.
5871    """
5872    def __init__(self, name: str):
5873        self.name = name
5874
5875        self.points: Dict[PointID, Coords] = {}
5876        # Holds all IDs and their corresponding coordinates as key/value
5877        # pairs
5878
5879        self.nextID: PointID = 0
5880        # ID numbers should not be repeated or reused
5881
5882    def addPoint(self, coords: Coords) -> PointID:
5883        """
5884        Given a sequence (list/array/etc) of int coordinates, creates a
5885        point and adds it to the metric space object
5886
5887        >>> ms = MetricSpace("test")
5888        >>> ms.addPoint([2, 3])
5889        0
5890        >>> #expected result
5891        >>> ms.addPoint([2, 7, 0])
5892        1
5893        """
5894        thisID = self.nextID
5895
5896        self.nextID += 1
5897
5898        self.points[thisID] = coords  # creates key value pair
5899
5900        return thisID
5901
5902        # How do we "add" things to the metric space? What data structure
5903        # is it? dictionary
5904
5905    def removePoint(self, thisID: PointID) -> None:
5906        """
5907        Given the ID of a point/coord, checks the dictionary
5908        (points) for that key and removes the key/value pair from
5909        it.
5910
5911        >>> ms = MetricSpace("test")
5912        >>> ms.addPoint([2, 3])
5913        0
5914        >>> ms.removePoint(0)
5915        >>> ms.removePoint(0)
5916        Traceback (most recent call last):
5917        ...
5918        KeyError...
5919        >>> #expected result should be a caught KeyNotFound exception
5920        """
5921        self.points.pop(thisID)
5922
5923    def distance(self, origin: AnyPoint, dest: AnyPoint) -> float:
5924        """
5925        Given an orgin point and destination point, returns the
5926        distance between the two points as a float.
5927
5928        >>> ms = MetricSpace("test")
5929        >>> ms.addPoint([4, 0])
5930        0
5931        >>> ms.addPoint([1, 0])
5932        1
5933        >>> ms.distance(0, 1)
5934        3.0
5935        >>> p1 = ms.addPoint([4, 3])
5936        >>> p2 = ms.addPoint([4, 9])
5937        >>> ms.distance(p1, p2)
5938        6.0
5939        >>> ms.distance([8, 6], [4, 6])
5940        4.0
5941        >>> ms.distance([1, 1], [1, 1])
5942        0.0
5943        >>> ms.distance([-2, -3], [-5, -7])
5944        5.0
5945        >>> ms.distance([2.5, 3.7], [4.9, 6.1])
5946        3.394112549695428
5947        """
5948        if isinstance(origin, PointID):
5949            coord1 = self.points[origin]
5950        else:
5951            coord1 = origin
5952
5953        if isinstance(dest, PointID):
5954            coord2 = self.points[dest]
5955        else:
5956            coord2 = dest
5957
5958        inside = 0.0
5959
5960        for dim in range(max(len(coord1), len(coord2))):
5961            if dim < len(coord1):
5962                val1 = coord1[dim]
5963            else:
5964                val1 = 0
5965            if dim < len(coord2):
5966                val2 = coord2[dim]
5967            else:
5968                val2 = 0
5969
5970            inside += (val2 - val1)**2
5971
5972        result = math.sqrt(inside)
5973        return result
5974
5975    def NDCoords(
5976        self,
5977        point: AnyPoint,
5978        numDimension: int
5979    ) -> Coords:
5980        """
5981        Given a 2D set of coordinates (x, y), converts them to the desired
5982        dimension
5983
5984        >>> ms = MetricSpace("test")
5985        >>> ms.NDCoords([5, 9], 3)
5986        [5, 9, 0]
5987        >>> ms.NDCoords([3, 1], 1)
5988        [3]
5989        """
5990        if isinstance(point, PointID):
5991            coords = self.points[point]
5992        else:
5993            coords = point
5994
5995        seqLength = len(coords)
5996
5997        if seqLength != numDimension:
5998
5999            newCoords: Coords
6000
6001            if seqLength < numDimension:
6002
6003                newCoords = [item for item in coords]
6004
6005                for i in range(numDimension - seqLength):
6006                    newCoords.append(0)
6007
6008            else:
6009                newCoords = coords[:numDimension]
6010
6011        return newCoords
6012
6013    def lastID(self) -> PointID:
6014        """
6015        Returns the most updated ID of the metricSpace instance. The nextID
6016        field is always 1 more than the last assigned ID. Assumes that there
6017        has at least been one ID assigned to a point as a key value pair
6018        in the dictionary. Returns 0 if that is not the case. Does not
6019        consider possible counting errors if a point has been removed from
6020        the dictionary. The last ID does not neccessarily equal the number
6021        of points in the metricSpace (or in the dictionary).
6022
6023        >>> ms = MetricSpace("test")
6024        >>> ms.lastID()
6025        0
6026        >>> ms.addPoint([2, 3])
6027        0
6028        >>> ms.addPoint([2, 7, 0])
6029        1
6030        >>> ms.addPoint([2, 7])
6031        2
6032        >>> ms.lastID()
6033        2
6034        >>> ms.removePoint(2)
6035        >>> ms.lastID()
6036        2
6037        """
6038        if self.nextID < 1:
6039            return self.nextID
6040        return self.nextID - 1
6041
6042
6043def featurePart(spec: AnyFeatureSpecifier, part: Part) -> FeatureSpecifier:
6044    """
6045    Returns a new feature specifier (and/or normalizes to one) that
6046    contains the specified part in the 'part' slot. If the provided
6047    feature specifier already contains a 'part', that will be replaced.
6048
6049    For example:
6050
6051    >>> featurePart('town', 'north')
6052    FeatureSpecifier(domain=None, within=[], feature='town', part='north')
6053    >>> featurePart(5, 'top')
6054    FeatureSpecifier(domain=None, within=[], feature=5, part='top')
6055    >>> featurePart(
6056    ...     FeatureSpecifier('dom', ['one', 'two'], 'three', 'middle'),
6057    ...     'top'
6058    ... )
6059    FeatureSpecifier(domain='dom', within=['one', 'two'], feature='three',\
6060 part='top')
6061    >>> featurePart(FeatureSpecifier(None, ['region'], 'place', None), 'top')
6062    FeatureSpecifier(domain=None, within=['region'], feature='place',\
6063 part='top')
6064    """
6065    spec = normalizeFeatureSpecifier(spec)
6066    return FeatureSpecifier(spec.domain, spec.within, spec.feature, part)
6067
6068
6069FeatureType = Literal[
6070    'node',
6071    'path',
6072    'edge',
6073    'region',
6074    'landmark',
6075    'affordance',
6076    'entity'
6077]
6078"""
6079The different types of features that a `FeatureGraph` can have:
6080
60811. Nodes, representing destinations, and/or intersections. A node is
6082    something that one can be "at" and possibly "in."
60832. Paths, connecting nodes and/or other elements. Also used to represent
6084    access points (like doorways between regions) even when they don't
6085    have length.
60863. Edges, separating regions and/or impeding movement (but a door is also
6087    a kind of edge).
60884. Regions, enclosing other elements and/or regions. Besides via
6089    containment, region-region connections are mediated by nodes, paths,
6090    and/or edges.
60915. Landmarks, which are recognizable and possibly visible from afar.
60926. Affordances, which are exploration-relevant location-specific actions
6093    that can be taken, such as a lever that can be pulled. Affordances
6094    may require positioning within multiple domains, but should only be
6095    marked in the most-relevant domain, with cross-domain linkages for
6096    things like observability. Note that the other spatial object types
6097    have their own natural affordances; this is used to mark affordances
6098    beyond those. Each affordance can have a list of `Consequence`s to
6099    indicate what happens when it is activated.
61007. Entities, which can be interacted with, such as an NPC which can be
6101    talked to. Can also be used to represent the player's avatar in a
6102    particular domain. Can have adjacent (touching) affordances to
6103    represent specific interaction options, and may have nodes which
6104    represent options for deeper interaction, but has a generic
6105    'interact' affordance as well. In general, adjacent affordances
6106    should be used to represent options for interaction that are
6107    triggerable directly within the explorable space, such as the fact
6108    that an NPC can be pushed or picked up or the like. In contrast,
6109    interaction options accessible via opening an interaction menu
6110    should be represented by a 'hasOptions' link to a node (typically in
6111    a separate domain) which has some combination of affordances and/or
6112    further interior nodes representing sub-menus. Sub-menus gated on
6113    some kind of requirement can list those requirements for entry.
6114"""
6115
6116FeatureRelationshipType = Literal[
6117    'contains',
6118    'within',
6119    'touches',
6120    'observable',
6121    'positioned',
6122    'entranceFor',
6123    'enterTo',
6124    'optionsFor',
6125    'hasOptions',
6126    'interacting',
6127    'triggeredBy',
6128    'triggers',
6129]
6130"""
6131The possible relationships between features in a `FeatureGraph`:
6132
6133- 'contains', specifies that one element contains another. Regions can
6134    contain other elements, including other regions, and nodes can
6135    contain regions (but only indirectly other elements). A region
6136    contained by a node represents some kind of interior space for that
6137    node, and this can be used for fractal detail levels (e.g., a town is
6138    a node on the overworld but when you enter it it's a full region
6139    inside, to contrast with a town as a sub-region of the overworld with
6140    no special enter/exit mechanics). The opposite relation is 'within'.
6141- 'touches' specifies that two elements touch each other. Not used for
6142    regions directly (an edge, path, or node should intercede). The
6143    relationship is reciprocal, but not transitive.
6144- 'observable' specifies that the target element is observable (visible
6145    or otherwise perceivable) from the source element. Can be tagged to
6146    indicate things like partial observability and/or
6147    difficult-to-observe elements. By default, things that touch each
6148    other are considered mutually observable, even without an
6149    'observable' relation being added.
6150- 'positioned' to indicate a specific relative position of two objects,
6151    with tags on the edge used to indicate what the relationship is.
6152    E.g., "the table is 10 feet northwest of the chair" has multiple
6153    possible representations, one of which is a 'positioned' relation
6154    from the table to the chair, with the 'direction' tag set to
6155    'southeast' and the 'distance' tag set to '10 feet'. Note that a
6156    `MetricSpace` may also be used to track coordinate positions of
6157    things; annotating every possible position relationship is not
6158    expected.
6159- 'entranceFor' to indicate which feature contained inside of a node is
6160    enterable from outside the node (possibly from a specific part of
6161    the outside of the node). 'enterTo' is the reciprocal relationship.
6162    'entranceFor' applies from the interior region to the exterior node,
6163    while 'enterTo' goes the other way. Note that you cannot use two
6164    different part specifiers to mark the *same* region as enter-able
6165    from two parts of the same node: each pair of nodes can only have
6166    one 'enteranceFor'/'enterTo' connection between them.
6167- 'optionsFor' to indicate which node associated with an entity holds
6168    the set of options for interaction with that entity. Such nodes are
6169    typically placed within a separate domain from the main exploration
6170    space. The same node could be the options for multiple entities. The
6171    reciprocal is 'hasOptions'. In both cases, a part specifier may be
6172    used to indicate more specifically how the interaction is initiated,
6173    but note that a given pair of entities cannot have multiple
6174    'optionsFor'/'hasOption' links between them. You could have multiple
6175    separate nodes that are 'optionsFor' the same entity with different
6176    parts (or even with no part specified for either, although that
6177    would create ambiguity in terms of action outcomes).
6178- 'interacting' to indicate when one feature is taking action relative
6179    to another. This relationship will have an 'action' tag which will
6180    contain a `FeatureAction` dictionary that specifies the relationship
6181    target as its 'subject'. This does not have a reciprocal, and is
6182    normal ephemeral.
6183- 'triggeredBy' to indicate when some kind of action with a feature
6184    triggers an affordance. The reciprocal is 'triggers'. The link tag
6185    'triggerInfo' will specify:
6186    * 'action': the action whose use trips the trigger (one of the
6187        `FeatureAffordance`s)
6188    * 'directions' (optional): A set of directions, one of which must
6189        match the specified direction of a `FeatureAction` for the
6190        trigger to trip. When this key is not present, no direction
6191        filtering is applied.
6192    * 'parts' (optional): A set of part specifiers, one of which must
6193        match the specified action part for the trigger to trip. When
6194        this key is not present, no part filtering is applied.
6195    * 'entityTags' (optional): A set of entity tags, any of which must
6196        match a tag on an interacting entity for the trigger to trip.
6197        Items in the set may also be tuples of multiple tags, in which
6198        case all items in the tuple must match for the entity to
6199        qualify.
6200
6201Note that any of these relationships can be tagged as 'temporary' to
6202imply malleability. For example, a bus node could be temporarily 'at' a
6203bus stop node and 'within' a corresponding region, but then those
6204relationships could change when it moves on.
6205"""
6206
6207FREL_RECIPROCALS: Dict[
6208    FeatureRelationshipType,
6209    FeatureRelationshipType
6210] = {
6211    "contains": "within",
6212    "within": "contains",
6213    "touches": "touches",
6214    "entranceFor": "enterTo",
6215    "enterTo": "entranceFor",
6216    "optionsFor": "hasOptions",
6217    "hasOptions": "optionsFor",
6218    "triggeredBy": "triggers",
6219    "triggers": "triggeredBy",
6220}
6221"""
6222The reciprocal feature relation types for each `FeatureRelationshipType`
6223which has a required reciprocal.
6224"""
6225
6226
6227class FeatureDecision(TypedDict):
6228    """
6229    Represents a decision made during exploration, including the
6230    position(s) at which the explorer made the decision, which
6231    feature(s) were most relevant to the decision and what course of
6232    action was decided upon (see `FeatureAction`). Has the following
6233    slots:
6234
6235    - 'type': The type of decision (see `exploration.core.DecisionType`).
6236    - 'domains': A set of domains which are active during the decision,
6237        as opposed to domains which may be unfocused or otherwise
6238        inactive.
6239    - 'focus': An optional single `FeatureSpecifier` which represents the
6240        focal character or object for a decision. May be `None` e.g. in
6241        cases where a menu is in focus. Note that the 'positions' slot
6242        determines which positions are relevant to the decision,
6243        potentially separately from the focus but usually overlapping it.
6244    - 'positions': A dictionary mapping `core.Domain`s to sets of
6245        `FeatureSpecifier`s representing the player's position(s) in
6246        each domain. Some domains may function like tech trees, where
6247        the set of positions only expands over time. Others may function
6248        like a single avatar in a virtual world, where there is only one
6249        position. Still others might function like a group of virtual
6250        avatars, with multiple positions that can be updated
6251        independently.
6252    - 'intention': A `FeatureAction` indicating the action taken or
6253        attempted next as a result of the decision.
6254    """
6255    # TODO: HERE
6256    pass
6257
6258
6259FeatureAffordance = Literal[
6260    'approach',
6261    'recede',
6262    'follow',
6263    'cross',
6264    'enter',
6265    'exit',
6266    'explore',
6267    'scrutinize',
6268    'do',
6269    'interact',
6270    'focus',
6271]
6272"""
6273The list of verbs that can be used to express actions taken in relation
6274to features in a feature graph:
6275
6276- 'approach' and 'recede' apply to nodes, paths, edges, regions, and
6277    landmarks, and indicate movement towards or away from the feature.
6278- 'follow' applies to paths and edges, and indicates travelling along.
6279    May be bundled with a direction indicator, although this can
6280    sometimes be inferred (e.g., if you're starting at a node that's
6281    touching one end of a path). For edges, a side-indicator may also be
6282    included. A destination-indicator can be used to indicate where
6283    along the item you end up (according to which other feature touching
6284    it you arrive at).
6285- 'cross' applies to nodes, paths, edges, and regions, and may include a
6286    destination indicator when there are multiple possible destinations
6287    on the other side of the target from the current position.
6288- 'enter' and 'exit' apply to regions and nodes, and indicate going
6289    inside of or coming out of the feature. The 'entranceFor' and
6290    'enterTo' relations are used to indicate where you'll end up when
6291    entering a node, note that there can be multiple of these attached
6292    to different parts of the node. A destination indicator can also be
6293    specified on the action.
6294- 'explore' applies to regions, nodes, and paths, and edges, and
6295    indicates a general lower-fractal-level series of actions taken to
6296    gain more complete knowledge about the target.
6297- 'scrutinize' applies to any feature and indicates carefully probing
6298    the details of the feature to learn more about it (e.g., to look for
6299    a secret).
6300- 'do' applies to affordances, and indicates performing whatever special
6301    action they represent.
6302- 'interact' applies to entities, and indicates some kind of generic
6303    interaction with the entity. For more specific interactions, you can
6304    do one of two things:
6305    1. Place affordances touching or within the entity.
6306    2. Use an 'optionsFor' link to indicate which node (typically in a
6307        separate domain) represents the options made available by an
6308        interaction.
6309- 'focus' applies to any kind of node, but usually entities. It
6310    represents changing the focal object/character for the player.
6311    However, note that focus shifts often happen without this affordance
6312    being involved, such as when entering a menu.
6313"""
6314
6315FEATURE_TYPE_AFFORDANCES: Dict[FeatureAffordance, Set[FeatureType]] = {
6316    'approach': {'node', 'path', 'edge', 'region', 'landmark', 'entity'},
6317    'recede': {'node', 'path', 'edge', 'region', 'landmark', 'entity'},
6318    'follow': {'edge', 'path', 'entity'},
6319    'cross': {'node', 'path', 'edge', 'region'},
6320    'enter': {'node', 'region'},
6321    'exit': {'node', 'region'},
6322    'explore': {'node', 'path', 'edge', 'region'},
6323    'scrutinize': {
6324        'node', 'path', 'edge', 'region', 'landmark', 'affordance',
6325        'entity'
6326    },
6327    'do': {'affordance'},
6328    'interact': {'node', 'entity'},
6329}
6330"""
6331The mapping from affordances to the sets of feature types those
6332affordances apply to.
6333"""
6334
6335
6336class FeatureEffect(TypedDict):
6337    """
6338    Similar to `Effect` but with more options for how to manipulate the
6339    game state. This represents a single concrete change to either
6340    internal game state, or to the feature graph. Multiple changes
6341    (possibly with random factors involved) can be represented by a
6342    `Consequence`; a `FeatureEffect` is used as a leaf in a `Consequence`
6343    tree.
6344    """
6345    type: Literal[
6346        'gain',
6347        'lose',
6348        'toggle',
6349        'deactivate',
6350        'move',
6351        'focus',
6352        'initiate'
6353        'foreground',
6354        'background',
6355    ]
6356    value: Union[
6357        Capability,
6358        Tuple[Token, int],
6359        List[Capability],
6360        None
6361    ]
6362    charges: Optional[int]
6363    delay: Optional[int]
6364
6365
6366def featureEffect(
6367    #applyTo: ContextSpecifier = 'active',
6368    #gain: Optional[Union[
6369    #    Capability,
6370    #    Tuple[Token, TokenCount],
6371    #    Tuple[Literal['skill'], Skill, Level]
6372    #]] = None,
6373    #lose: Optional[Union[
6374    #    Capability,
6375    #    Tuple[Token, TokenCount],
6376    #    Tuple[Literal['skill'], Skill, Level]
6377    #]] = None,
6378    #set: Optional[Union[
6379    #    Tuple[Token, TokenCount],
6380    #    Tuple[AnyMechanismSpecifier, MechanismState],
6381    #    Tuple[Literal['skill'], Skill, Level]
6382    #]] = None,
6383    #toggle: Optional[Union[
6384    #    Tuple[AnyMechanismSpecifier, List[MechanismState]],
6385    #    List[Capability]
6386    #]] = None,
6387    #deactivate: Optional[bool] = None,
6388    #edit: Optional[List[List[commands.Command]]] = None,
6389    #goto: Optional[Union[
6390    #    AnyDecisionSpecifier,
6391    #    Tuple[AnyDecisionSpecifier, FocalPointName]
6392    #]] = None,
6393    #bounce: Optional[bool] = None,
6394    #delay: Optional[int] = None,
6395    #charges: Optional[int] = None,
6396    **kwargs
6397):
6398    # TODO: HERE
6399    return effect(**kwargs)
6400
6401# TODO: FeatureConsequences?
6402
6403
6404class FeatureAction(TypedDict):
6405    """
6406    Indicates an action decided on by a `FeatureDecision`. Has the
6407    following slots:
6408
6409    - 'subject': the main feature (an `AnyFeatureSpecifier`) that
6410        performs the action (usually an 'entity').
6411    - 'object': the main feature (an `AnyFeatureSpecifier`) with which
6412        the affordance is performed.
6413    - 'affordance': the specific `FeatureAffordance` indicating the type
6414        of action.
6415    - 'direction': The general direction of movement (especially when
6416        the affordance is `follow`). This can be either a direction in
6417        an associated `MetricSpace`, or it can be defined towards or
6418        away from the destination specified. If a destination but no
6419        direction is provided, the direction is assumed to be towards
6420        that destination.
6421    - 'part': The part within/along a feature for movement (e.g., which
6422        side of an edge are you on, or which part of a region are you
6423        traveling through).
6424    - 'destination': The destination of the action (when known ahead of
6425        time). For example, moving along a path towards a particular
6426        feature touching that path, or entering a node into a particular
6427        feature within that node. Note that entering of regions can be
6428        left implicit: if you enter a region to get to a landmark within
6429        it, noting that as approaching the landmark is more appropriate
6430        than noting that as entering the region with the landmark as the
6431        destination. The system can infer what regions you're in by
6432        which feature you're at.
6433    - 'outcome': A `Consequence` list/tree indicating one or more
6434        outcomes, possibly involving challenges. Note that the actual
6435        outcomes of an action may be altered by triggers; the outcomes
6436        listed here are the default outcomes if no triggers are tripped.
6437
6438    The 'direction', 'part', and/or 'destination' may each be None,
6439    depending on the type of affordance and/or amount of detail desired.
6440    """
6441    subject: AnyFeatureSpecifier
6442    object: AnyFeatureSpecifier
6443    affordance: FeatureAffordance
6444    direction: Optional[Part]
6445    part: Optional[Part]
6446    destination: Optional[AnyFeatureSpecifier]
6447    outcome: Consequence
DEFAULT_DOMAIN: str = 'main'

Default domain value for use when a domain is needed but not specified.

DEFAULT_FOCAL_CONTEXT_NAME: str = 'main'

Default focal context name for use when a focal context name is needed but not specified.

DEFAULT_MECHANISM_STATE: str = 'off'

Default state we assume in situations where a mechanism hasn't been assigned a state.

DEFAULT_EXPLORATION_STATUS: Literal['unknown', 'hypothesized', 'noticed', 'exploring', 'explored'] = 'noticed'

Default exploration status we assume when no exploration status has been set.

DEFAULT_SAVE_SLOT: str = 'slot0'

Default save slot to use when saving or reverting and the slot isn't specified.

Domain: TypeAlias = str

A type alias: Domains are identified by their names.

A domain represents a separable sphere of action in a game, such as movement in the in-game virtual space vs. movement in a menu (which is really just another kind of virtual space). Progress along a quest or tech tree can also be modeled as a separate domain.

Conceptually, domains may be either currently-active or currently-inactive (e.g., when a menu is open vs. closed, or when real-world movement is paused (or not) during menuing; see State). Also, the game state stores a set of 'active' decision points for each domain. At each particular game step, the set of options available to the player is the union of all outgoing transitions from active nodes in each active domain.

Each decision belongs to a single domain.

Zone: TypeAlias = str

A type alias: A zone as part of a DecisionGraph is identified using its name.

Zones contain decisions and/or other zones; one zone may be contained by multiple other zones, but a zone may not contain itself or otherwise form a containment loop.

Note that zone names must be globally unique within a DecisionGraph, and by extension, two zones with the same name at different steps of a DiscreteExploration are assumed to represent the same space.

The empty string is used to mean "default zone" in a few places, so it should not be used as a real zone name.

DecisionID: TypeAlias = int

A type alias: decision points are defined by arbitrarily assigned unique-per-Exploration ID numbers.

A decision represents a location within a decision graph where a decision can be made about where to go, or a dead-end reached by a previous decision. Typically, one room can have multiple decision points in it, even though many rooms have only one. Concepts like 'room' and 'area' that group multiple decisions together (at various scales) are handled by the idea of a Zone.

DecisionName: TypeAlias = str

A type alias: decisions have names which are strings.

Two decisions might share the same name, but they can be disambiguated because they may be in different Zones, and ultimately, they will have different DecisionIDs.

class DecisionSpecifier(typing.NamedTuple):
207class DecisionSpecifier(NamedTuple):
208    """
209    A decision specifier attempts to uniquely identify a decision by
210    name, rather than by ID. See `AnyDecisionSpecifier` for a type which
211    can also be an ID.
212
213    Ambiguity is possible if two decisions share the same name; the
214    decision specifier provides two means of disambiguation: a domain
215    may be specified, and a zone may be specified; if either is
216    specified only decisions within that domain and/or zone will match,
217    but of course there could still be multiple decisions that match
218    those criteria that still share names, in which case many operations
219    will end up raising an `AmbiguousDecisionSpecifierError`.
220    """
221    domain: Optional[Domain]
222    zone: Optional[Zone]
223    name: DecisionName

A decision specifier attempts to uniquely identify a decision by name, rather than by ID. See AnyDecisionSpecifier for a type which can also be an ID.

Ambiguity is possible if two decisions share the same name; the decision specifier provides two means of disambiguation: a domain may be specified, and a zone may be specified; if either is specified only decisions within that domain and/or zone will match, but of course there could still be multiple decisions that match those criteria that still share names, in which case many operations will end up raising an AmbiguousDecisionSpecifierError.

DecisionSpecifier(domain: Optional[str], zone: Optional[str], name: str)

Create new instance of DecisionSpecifier(domain, zone, name)

domain: Optional[str]

Alias for field number 0

zone: Optional[str]

Alias for field number 1

name: str

Alias for field number 2

Inherited Members
builtins.tuple
index
count
AnyDecisionSpecifier: TypeAlias = Union[int, DecisionSpecifier, str]

A type alias: Collects three different ways of specifying a decision: by ID, by DecisionSpecifier, or by a string which will be treated as either a DecisionName, or as a DecisionID if it can be converted to an integer.

class InvalidDecisionSpecifierError(builtins.ValueError):
235class InvalidDecisionSpecifierError(ValueError):
236    """
237    An error used when a decision specifier is in the wrong format.
238    """

An error used when a decision specifier is in the wrong format.

Inherited Members
builtins.ValueError
ValueError
builtins.BaseException
with_traceback
add_note
args
class InvalidMechanismSpecifierError(builtins.ValueError):
241class InvalidMechanismSpecifierError(ValueError):
242    """
243    An error used when a mechanism specifier is invalid.
244    """

An error used when a mechanism specifier is invalid.

Inherited Members
builtins.ValueError
ValueError
builtins.BaseException
with_traceback
add_note
args
Transition: TypeAlias = str

A type alias: transitions are defined by their names.

A transition represents a means of travel from one decision to another. Outgoing transition names have to be unique at each decision, but not globally.

TransitionWithOutcomes: TypeAlias = Tuple[str, List[bool]]

A type alias: a transition with an outcome attached is a tuple that has a Transition and then a sequence of booleans indicating success/failure of successive challenges attached to that transition. Challenges encountered during application of transition effects will each have their outcomes dictated by successive booleans in the sequence. If the sequence is shorter than the number of challenges encountered, additional challenges are resolved according to a ChallengePolicy specified when applying effects. TODO: Implement this, including parsing.

AnyTransition: TypeAlias = Union[str, Tuple[str, List[bool]]]
def nameAndOutcomes(transition: Union[str, Tuple[str, List[bool]]]) -> Tuple[str, List[bool]]:
277def nameAndOutcomes(transition: AnyTransition) -> TransitionWithOutcomes:
278    """
279    Returns a `TransitionWithOutcomes` when given either one of those
280    already or a base `Transition`. Outcomes will be an empty list when
281    given a transition alone. Checks that the type actually matches.
282    """
283    if isinstance(transition, Transition):
284        return (transition, [])
285    else:
286        if not isinstance(transition, tuple) or len(transition) != 2:
287            raise TypeError(
288                f"Transition with outcomes must be a length-2 tuple."
289                f" Got: {transition!r}"
290            )
291        name, outcomes = transition
292        if not isinstance(name, Transition):
293            raise TypeError(
294                f"Transition name must be a string."
295                f" Got: {name!r}"
296            )
297        if (
298            not isinstance(outcomes, list)
299         or not all(isinstance(x, bool) for x in outcomes)
300        ):
301            raise TypeError(
302                f"Transition outcomes must be a list of booleans."
303                f" Got: {outcomes!r}"
304            )
305        return transition

Returns a TransitionWithOutcomes when given either one of those already or a base Transition. Outcomes will be an empty list when given a transition alone. Checks that the type actually matches.

Capability: TypeAlias = str

A type alias: capabilities are defined by their names.

A capability represents the power to traverse certain transitions. These transitions should have a Requirement specified to indicate which capability/ies and/or token(s) can be used to traverse them. Capabilities are usually permanent, but may in some cases be temporary or be temporarily disabled. Capabilities might also combine (e.g., speed booster can't be used underwater until gravity suit is acquired but this is modeled through either Requirement expressions or equivalences (see DecisionGraph.addEquivalence).

By convention, a capability whose name starts with '?' indicates a capability that the player considers unknown, to be filled in later via equivalence. Similarly, by convention capabilities local to a particular zone and/or decision will be prefixed with the name of that zone/decision and '::' (subject to the restriction that capability names may NOT contain the symbols '&', '|', '!', '*', '(', and ')'). Note that in most cases zone-local capabilities can instead be Mechanisms, which are zone-local by default.

Token: TypeAlias = str

A type alias: tokens are defined by their type names.

A token represents an expendable item that can be used to traverse certain transitions a limited number of times (normally once after which the token is used up), or to permanently open certain transitions (perhaps when a certain amount have been acquired).

When a key permanently opens only one specific door, or is re-usable to open many doors, that should be represented as a Capability, not a token. Only when there is a choice of which door to unlock (and the key is then used up) should keys be represented as tokens.

Like capabilities, tokens can be unknown (names starting with '?') or may be zone- or decision-specific (prefixed with a zone/decision name and '::'). Also like capabilities, token names may not contain any of the symbols '&', '|', '!', '*', '(', or ')'.

TokenCount: TypeAlias = int

A token count is just an integer.

Skill: TypeAlias = str

Names a skill to be used for a challenge. The agent's skill level along with the challenge level determines the probability of success (see Challenge). When an agent doesn't list a skill at all, the level is assumed to be 0.

Level: TypeAlias = int

A challenge or skill level is just an integer.

MechanismID: TypeAlias = int

A type alias: mechanism IDs are integers. See MechanismName and MechanismState.

MechanismName: TypeAlias = str

A type alias: mechanism names are strings. See also MechanismState.

A mechanism represents something in the world that can be toggled or can otherwise change state, and which may alter the requirements for transitions and/or actions. For example, a switch that opens and closes one or more gates. Mechanisms can be used in Requirements by writing "mechanism:state", for example, "switch:on". Each mechanism can only be in one of its possible states at a time, so an effect that puts a mechanism in one state removes it from all other states. Mechanism states can be the subject of equivalences (see DecisionGraph.addEquivalence).

Mechanisms have MechanismIDs and are each associated with a specific decision (unless they are global), and when a mechanism name is mentioned, we look for the first mechanism with that name at the current decision, then in the lowest zone(s) containing that decision, etc. It's an error if we find two mechanisms with the same name at the same level of search. DecisionGraph.addMechanism will create a named mechanism and assign it an ID.

By convention, a capability whose name starts with '?' indicates a mechanism that the player considers unknown, to be filled in later via equivalence. Mechanism names are resolved by searching incrementally through higher and higher-level zones, then a global mechanism set and finally in all decisions. This means that the same mechanism name can potentially be re-used in different zones, especially when all transitions which depend on that mechanism's state are within the same zone. TODO: G: for global scope?

Mechanism states are not tracked as part of FocalContexts but are instead tracked as part of the DecisionGraph itself. If there are mechanism-like things which operate on a per-character basis or otherwise need to be tracked as part of focal contexts, use decision-local Capability names to track them instead.

class MechanismSpecifier(typing.NamedTuple):
415class MechanismSpecifier(NamedTuple):
416    """
417    Specifies a mechanism either just by name, or with domain and/or
418    zone and/or decision name hints.
419    """
420    domain: Optional[Domain]
421    zone: Optional[Zone]
422    decision: Optional[DecisionName]
423    name: MechanismName

Specifies a mechanism either just by name, or with domain and/or zone and/or decision name hints.

MechanismSpecifier( domain: Optional[str], zone: Optional[str], decision: Optional[str], name: str)

Create new instance of MechanismSpecifier(domain, zone, decision, name)

domain: Optional[str]

Alias for field number 0

zone: Optional[str]

Alias for field number 1

decision: Optional[str]

Alias for field number 2

name: str

Alias for field number 3

Inherited Members
builtins.tuple
index
count
def mechanismAt( name: str, domain: Optional[str] = None, zone: Optional[str] = None, decision: Optional[str] = None) -> MechanismSpecifier:
426def mechanismAt(
427    name: MechanismName,
428    domain: Optional[Domain] = None,
429    zone: Optional[Zone] = None,
430    decision: Optional[DecisionName] = None
431) -> MechanismSpecifier:
432    """
433    Builds a `MechanismSpecifier` using `None` default hints but
434    accepting `domain`, `zone`, and/or `decision` hints.
435    """
436    return MechanismSpecifier(domain, zone, decision, name)

Builds a MechanismSpecifier using None default hints but accepting domain, zone, and/or decision hints.

AnyMechanismSpecifier: TypeAlias = Union[int, str, MechanismSpecifier]

Can be a mechanism ID, mechanism name, or a mechanism specifier.

MechanismState: TypeAlias = str

A type alias: the state of a mechanism is a string. See Mechanism.

Each mechanism may have any number of states, but may only be in one of them at once. Mechanism states may NOT be strings which can be converted to integers using int because otherwise the 'set' effect would have trouble figuring out whether a mechanism or item count was being set.

EffectSpecifier: TypeAlias = Tuple[int, str, int]

Identifies a particular effect that's part of a consequence attached to a certain transition in a DecisionGraph. Identifies the effect based on the transition's source DecisionID and Transition name, plus an integer. The integer specifies the index of the effect in depth-first traversal order of the consequence of the specified transition.

TODO: Ensure these are updated when merging/deleting/renaming stuff.

class CapabilitySet(typing.TypedDict):
471class CapabilitySet(TypedDict):
472    """
473    Represents a set of capabilities, including boolean on/off
474    `Capability` names, countable `Token`s accumulated, and
475    integer-leveled skills. It has three slots:
476
477    - 'capabilities': A set representing which `Capability`s this
478        `CapabilitySet` includes.
479    - 'tokens': A dictionary mapping `Token` types to integers
480        representing how many of that token type this `CapabilitySet` has
481        accumulated.
482    - 'skills': A dictionary mapping `Skill` types to `Level` integers,
483        representing what skill levels this `CapabilitySet` has.
484    """
485    capabilities: Set[Capability]
486    tokens: Dict[Token, TokenCount]
487    skills: Dict[Skill, Level]

Represents a set of capabilities, including boolean on/off Capability names, countable Tokens accumulated, and integer-leveled skills. It has three slots:

  • 'capabilities': A set representing which Capabilitys this CapabilitySet includes.
  • 'tokens': A dictionary mapping Token types to integers representing how many of that token type this CapabilitySet has accumulated.
  • 'skills': A dictionary mapping Skill types to Level integers, representing what skill levels this CapabilitySet has.
capabilities: Set[str]
tokens: Dict[str, int]
skills: Dict[str, int]
DomainFocalization: TypeAlias = Literal['singular', 'plural', 'spreading']

How the player experiences decisions in a domain is controlled by focalization, which is specific to a FocalContext and a Domain:

  • Typically, focalization is 'singular' and there's a particular avatar (or co-located group of avatars) that the player follows around, at each point making a decision based on the position of that avatar (that avatar is effectively "at" one decision in the graph). Position in a singular domain is represented as a single DecisionID. When the player picks a transition, this decision ID is updated to the decision on the other side of that transition.
  • Less commonly, there can be multiple points of focalization which the player can freely switch between, meaning the player can at any given moment decide both which focal point to actually attend to, and what transition to take at that decision. This is called 'plural' focalization, and is common in tactics or strategy games where the player commands multiple units, although those games are often a poor match for decision mapping approaches. Position in a plural domain is represented by a dictionary mapping one or more focal-point name strings to single DecisionIDs. When the player makes a decision, they need to specify the name of the focal point for which the decision is made along with the transition name at that focal point, and that focal point is updated to the decision on the other side of the chosen transition.
  • Focalization can also be 'spreading' meaning that not only can the player pick options from one of multiple decisions, they also effectively expand the set of available decisions without having to give up access to old ones. This happens for example in a tech tree, where the player can invest some resource to unlock new nodes. Position in a spreading domain is represented by a set of DecisionIDs, and when a transition is chosen, the decision on the other side is added to the set if it wasn't already present.
FocalPointName: TypeAlias = str

The name of a particular focal point in 'plural' DomainFocalization.

ContextSpecifier: TypeAlias = Literal['common', 'active']

Used when it's necessary to specify whether the common or the active FocalContext is being referenced and/or updated.

FocalPointSpecifier: TypeAlias = Tuple[Literal['common', 'active'], str, str]

Specifies a particular focal point by picking out whether it's in the common or active context, which domain it's in, and the focal point name within that domain. Only needed for domains with 'plural' focalization (see DomainFocalization).

class FocalContext(typing.TypedDict):
556class FocalContext(TypedDict):
557    """
558    Focal contexts identify an avatar or similar situation where the player
559    has certain capabilities available (a `CapabilitySet`) and may also have
560    position information in one or more `Domain`s (see `State` and
561    `DomainFocalization`). Normally, only a single `FocalContext` is needed,
562    but situations where the player swaps between capability sets and/or
563    positions sometimes call for more.
564
565    At each decision step, only a single `FocalContext` is active, and the
566    capabilities of that context (plus capabilities of the 'common'
567    context) determine what transitions are traversable. At the same time,
568    the set of reachable transitions is determined by the focal context's
569    per-domain position information, including its per-domain
570    `DomainFocalization` type.
571
572    The slots are:
573
574    - 'capabilities': A `CapabilitySet` representing what capabilities,
575        tokens, and skills this context has. Note that capabilities from
576        the common `FocalContext` are added to these to determine what
577        transition requirements are met in a given step.
578    - 'focalization': A mapping from `Domain`s to `DomainFocalization`
579        specifying how this context is focalized in each domain.
580    - 'activeDomains': A set of `Domain`s indicating which `Domain`(s) are
581        active for this focal context right now.
582    - 'activeDecisions': A mapping from `Domain`s to either single
583        `DecisionID`s, dictionaries mapping `FocalPointName`s to
584        optional `DecisionID`s, or sets of `DecisionID`s. Which one is
585        used depends on the `DomainFocalization` of this context for
586        that domain. May also be `None` for domains in which no
587        decisions are active (and in 'plural'-focalization lists,
588       individual entries may be `None`). Active decisions from the
589        common `FocalContext` are also considered active at each step.
590    """
591    capabilities: CapabilitySet
592    focalization: Dict[Domain, DomainFocalization]
593    activeDomains: Set[Domain]
594    activeDecisions: Dict[
595        Domain,
596        Union[
597            None,
598            DecisionID,
599            Dict[FocalPointName, Optional[DecisionID]],
600            Set[DecisionID]
601        ]
602    ]

Focal contexts identify an avatar or similar situation where the player has certain capabilities available (a CapabilitySet) and may also have position information in one or more Domains (see State and DomainFocalization). Normally, only a single FocalContext is needed, but situations where the player swaps between capability sets and/or positions sometimes call for more.

At each decision step, only a single FocalContext is active, and the capabilities of that context (plus capabilities of the 'common' context) determine what transitions are traversable. At the same time, the set of reachable transitions is determined by the focal context's per-domain position information, including its per-domain DomainFocalization type.

The slots are:

  • 'capabilities': A CapabilitySet representing what capabilities, tokens, and skills this context has. Note that capabilities from the common FocalContext are added to these to determine what transition requirements are met in a given step.
  • 'focalization': A mapping from Domains to DomainFocalization specifying how this context is focalized in each domain.
  • 'activeDomains': A set of Domains indicating which Domain(s) are active for this focal context right now.
  • 'activeDecisions': A mapping from Domains to either single DecisionIDs, dictionaries mapping FocalPointNames to optional DecisionIDs, or sets of DecisionIDs. Which one is used depends on the DomainFocalization of this context for that domain. May also be None for domains in which no decisions are active (and in 'plural'-focalization lists, individual entries may be None). Active decisions from the common FocalContext are also considered active at each step.
capabilities: CapabilitySet
focalization: Dict[str, Literal['singular', 'plural', 'spreading']]
activeDomains: Set[str]
activeDecisions: Dict[str, Union[NoneType, int, Dict[str, Optional[int]], Set[int]]]
FocalContextName: TypeAlias = str

FocalContexts are assigned names are are indexed under those names within State objects (they don't contain their own name). Note that the 'common' focal context does not have a name.

def getDomainFocalization( context: FocalContext, domain: str, defaultFocalization: Literal['singular', 'plural', 'spreading'] = 'singular') -> Literal['singular', 'plural', 'spreading']:
613def getDomainFocalization(
614    context: FocalContext,
615    domain: Domain,
616    defaultFocalization: DomainFocalization = 'singular'
617) -> DomainFocalization:
618    """
619    Fetches the focalization value for the given domain in the given
620    focal context, setting it to the provided default first if that
621    focal context didn't have an entry for that domain yet.
622    """
623    return context['focalization'].setdefault(domain, defaultFocalization)

Fetches the focalization value for the given domain in the given focal context, setting it to the provided default first if that focal context didn't have an entry for that domain yet.

class State(typing.TypedDict):
626class State(TypedDict):
627    """
628    Represents a game state, including certain exploration-relevant
629    information, plus possibly extra custom information. Has the
630    following slots:
631
632    - 'common': A single `FocalContext` containing capability and position
633        information which is always active in addition to the current
634        `FocalContext`'s information.
635    - 'contexts': A dictionary mapping strings to `FocalContext`s, which
636        store capability and position information.
637    - 'activeContext': A string naming the currently-active
638        `FocalContext` (a key of the 'contexts' slot).
639    - 'primaryDecision': A `DecisionID` (or `None`) indicating the
640        primary decision that is being considered in this state. Whereas
641        the focalization structures can and often will indicate multiple
642        active decisions, whichever decision the player just arrived at
643        via the transition selected in a previous state will be the most
644        relevant, and we track that here. Of course, for some states
645        (like a pre-starting initial state) there is no primary
646        decision.
647    - 'mechanisms': A dictionary mapping `Mechanism` IDs to
648        `MechanismState` strings.
649    - 'exploration': A dictionary mapping decision IDs to exploration
650        statuses, which tracks how much knowledge the player has of
651        different decisions.
652    - 'effectCounts': A dictionary mapping `EffectSpecifier`s to
653        integers specifying how many times that effect has been
654        triggered since the beginning of the exploration (including
655        times that the actual effect was not applied due to delays
656        and/or charges. This is used to figure out when effects with
657        charges and/or delays should be applied.
658    - 'deactivated':  A set of (`DecisionID`, `Transition`) tuples
659        specifying which transitions have been deactivated. This is used
660        in addition to transition requirements to figure out which
661        transitions are traversable.
662    - 'custom': An arbitrary sub-dictionary representing any kind of
663        custom game state. In most cases, things can be reasonably
664        approximated via capabilities and tokens and custom game state is
665        not needed.
666    """
667    common: FocalContext
668    contexts: Dict[FocalContextName, FocalContext]
669    activeContext: FocalContextName
670    primaryDecision: Optional[DecisionID]
671    mechanisms: Dict[MechanismID, MechanismState]
672    exploration: Dict[DecisionID, 'ExplorationStatus']
673    effectCounts: Dict[EffectSpecifier, int]
674    deactivated: Set[Tuple[DecisionID, Transition]]
675    custom: dict

Represents a game state, including certain exploration-relevant information, plus possibly extra custom information. Has the following slots:

  • 'common': A single FocalContext containing capability and position information which is always active in addition to the current FocalContext's information.
  • 'contexts': A dictionary mapping strings to FocalContexts, which store capability and position information.
  • 'activeContext': A string naming the currently-active FocalContext (a key of the 'contexts' slot).
  • 'primaryDecision': A DecisionID (or None) indicating the primary decision that is being considered in this state. Whereas the focalization structures can and often will indicate multiple active decisions, whichever decision the player just arrived at via the transition selected in a previous state will be the most relevant, and we track that here. Of course, for some states (like a pre-starting initial state) there is no primary decision.
  • 'mechanisms': A dictionary mapping Mechanism IDs to MechanismState strings.
  • 'exploration': A dictionary mapping decision IDs to exploration statuses, which tracks how much knowledge the player has of different decisions.
  • 'effectCounts': A dictionary mapping EffectSpecifiers to integers specifying how many times that effect has been triggered since the beginning of the exploration (including times that the actual effect was not applied due to delays and/or charges. This is used to figure out when effects with charges and/or delays should be applied.
  • 'deactivated': A set of (DecisionID, Transition) tuples specifying which transitions have been deactivated. This is used in addition to transition requirements to figure out which transitions are traversable.
  • 'custom': An arbitrary sub-dictionary representing any kind of custom game state. In most cases, things can be reasonably approximated via capabilities and tokens and custom game state is not needed.
common: FocalContext
contexts: Dict[str, FocalContext]
activeContext: str
primaryDecision: Optional[int]
mechanisms: Dict[int, str]
exploration: Dict[int, Literal['unknown', 'hypothesized', 'noticed', 'exploring', 'explored']]
effectCounts: Dict[Tuple[int, str, int], int]
deactivated: Set[Tuple[int, str]]
custom: dict
def idOrDecisionSpecifier( ds: DecisionSpecifier) -> Union[DecisionSpecifier, int]:
682def idOrDecisionSpecifier(
683    ds: DecisionSpecifier
684) -> Union[DecisionSpecifier, int]:
685    """
686    Given a decision specifier which might use a name that's convertible
687    to an integer ID, returns the appropriate ID if so, and the original
688    decision specifier if not, raising an
689    `InvalidDecisionSpecifierError` if given a specifier with a
690    convertible name that also has other parts.
691    """
692    try:
693        dID = int(ds.name)
694    except ValueError:
695        return ds
696
697    if ds.domain is None and ds.zone is None:
698        return dID
699    else:
700        raise InvalidDecisionSpecifierError(
701            f"Specifier {ds} has an ID name but also includes"
702            f" domain and/or zone information."
703        )

Given a decision specifier which might use a name that's convertible to an integer ID, returns the appropriate ID if so, and the original decision specifier if not, raising an InvalidDecisionSpecifierError if given a specifier with a convertible name that also has other parts.

def spliceDecisionSpecifiers( base: DecisionSpecifier, default: DecisionSpecifier) -> DecisionSpecifier:
706def spliceDecisionSpecifiers(
707    base: DecisionSpecifier,
708    default: DecisionSpecifier
709) -> DecisionSpecifier:
710    """
711    Copies domain and/or zone info from the `default` specifier into the
712    `base` specifier, returning a new `DecisionSpecifier` without
713    modifying either argument. Info is only copied where the `base`
714    specifier has a missing value, although if the base specifier has a
715    domain but no zone and the domain is different from that of the
716    default specifier, no zone info is copied.
717
718    For example:
719
720    >>> d1 = DecisionSpecifier('main', 'zone', 'name')
721    >>> d2 = DecisionSpecifier('niam', 'enoz', 'eman')
722    >>> spliceDecisionSpecifiers(d1, d2)
723    DecisionSpecifier(domain='main', zone='zone', name='name')
724    >>> spliceDecisionSpecifiers(d2, d1)
725    DecisionSpecifier(domain='niam', zone='enoz', name='eman')
726    >>> d3 = DecisionSpecifier(None, None, 'three')
727    >>> spliceDecisionSpecifiers(d3, d1)
728    DecisionSpecifier(domain='main', zone='zone', name='three')
729    >>> spliceDecisionSpecifiers(d3, d2)
730    DecisionSpecifier(domain='niam', zone='enoz', name='three')
731    >>> d4 = DecisionSpecifier('niam', None, 'four')
732    >>> spliceDecisionSpecifiers(d4, d1)  # diff domain -> no zone
733    DecisionSpecifier(domain='niam', zone=None, name='four')
734    >>> spliceDecisionSpecifiers(d4, d2)  # same domian -> copy zone
735    DecisionSpecifier(domain='niam', zone='enoz', name='four')
736    >>> d5 = DecisionSpecifier(None, 'cone', 'five')
737    >>> spliceDecisionSpecifiers(d4, d5)  # None domain -> copy zone
738    DecisionSpecifier(domain='niam', zone='cone', name='four')
739    """
740    newDomain = base.domain
741    if newDomain is None:
742        newDomain = default.domain
743    newZone = base.zone
744    if (
745        newZone is None
746    and (newDomain == default.domain or default.domain is None)
747    ):
748        newZone = default.zone
749
750    return DecisionSpecifier(domain=newDomain, zone=newZone, name=base.name)

Copies domain and/or zone info from the default specifier into the base specifier, returning a new DecisionSpecifier without modifying either argument. Info is only copied where the base specifier has a missing value, although if the base specifier has a domain but no zone and the domain is different from that of the default specifier, no zone info is copied.

For example:

>>> d1 = DecisionSpecifier('main', 'zone', 'name')
>>> d2 = DecisionSpecifier('niam', 'enoz', 'eman')
>>> spliceDecisionSpecifiers(d1, d2)
DecisionSpecifier(domain='main', zone='zone', name='name')
>>> spliceDecisionSpecifiers(d2, d1)
DecisionSpecifier(domain='niam', zone='enoz', name='eman')
>>> d3 = DecisionSpecifier(None, None, 'three')
>>> spliceDecisionSpecifiers(d3, d1)
DecisionSpecifier(domain='main', zone='zone', name='three')
>>> spliceDecisionSpecifiers(d3, d2)
DecisionSpecifier(domain='niam', zone='enoz', name='three')
>>> d4 = DecisionSpecifier('niam', None, 'four')
>>> spliceDecisionSpecifiers(d4, d1)  # diff domain -> no zone
DecisionSpecifier(domain='niam', zone=None, name='four')
>>> spliceDecisionSpecifiers(d4, d2)  # same domian -> copy zone
DecisionSpecifier(domain='niam', zone='enoz', name='four')
>>> d5 = DecisionSpecifier(None, 'cone', 'five')
>>> spliceDecisionSpecifiers(d4, d5)  # None domain -> copy zone
DecisionSpecifier(domain='niam', zone='cone', name='four')
def mergeCapabilitySets( A: CapabilitySet, B: CapabilitySet) -> CapabilitySet:
753def mergeCapabilitySets(A: CapabilitySet, B: CapabilitySet) -> CapabilitySet:
754    """
755    Merges two capability sets into a new one, where all capabilities in
756    either original set are active, and token counts and skill levels are
757    summed.
758
759    Example:
760
761    >>> cs1 = {
762    ...    'capabilities': {'fly', 'whistle'},
763    ...    'tokens': {'subway': 3},
764    ...    'skills': {'agility': 1, 'puzzling': 3},
765    ... }
766    >>> cs2 = {
767    ...    'capabilities': {'dig', 'fly'},
768    ...    'tokens': {'subway': 1, 'goat': 2},
769    ...    'skills': {'agility': -1},
770    ... }
771    >>> ms = mergeCapabilitySets(cs1, cs2)
772    >>> ms['capabilities'] == {'fly', 'whistle', 'dig'}
773    True
774    >>> ms['tokens'] == {'subway': 4, 'goat': 2}
775    True
776    >>> ms['skills'] == {'agility': 0, 'puzzling': 3}
777    True
778    """
779    # Set up our result
780    result: CapabilitySet = {
781        'capabilities': set(),
782        'tokens': {},
783        'skills': {}
784    }
785
786    # Merge capabilities
787    result['capabilities'].update(A['capabilities'])
788    result['capabilities'].update(B['capabilities'])
789
790    # Merge tokens
791    tokensA = A['tokens']
792    tokensB = B['tokens']
793    resultTokens = result['tokens']
794    for tType, val in tokensA.items():
795        if tType not in resultTokens:
796            resultTokens[tType] = val
797        else:
798            resultTokens[tType] += val
799    for tType, val in tokensB.items():
800        if tType not in resultTokens:
801            resultTokens[tType] = val
802        else:
803            resultTokens[tType] += val
804
805    # Merge skills
806    skillsA = A['skills']
807    skillsB = B['skills']
808    resultSkills = result['skills']
809    for skill, level in skillsA.items():
810        if skill not in resultSkills:
811            resultSkills[skill] = level
812        else:
813            resultSkills[skill] += level
814    for skill, level in skillsB.items():
815        if skill not in resultSkills:
816            resultSkills[skill] = level
817        else:
818            resultSkills[skill] += level
819
820    return result

Merges two capability sets into a new one, where all capabilities in either original set are active, and token counts and skill levels are summed.

Example:

>>> cs1 = {
...    'capabilities': {'fly', 'whistle'},
...    'tokens': {'subway': 3},
...    'skills': {'agility': 1, 'puzzling': 3},
... }
>>> cs2 = {
...    'capabilities': {'dig', 'fly'},
...    'tokens': {'subway': 1, 'goat': 2},
...    'skills': {'agility': -1},
... }
>>> ms = mergeCapabilitySets(cs1, cs2)
>>> ms['capabilities'] == {'fly', 'whistle', 'dig'}
True
>>> ms['tokens'] == {'subway': 4, 'goat': 2}
True
>>> ms['skills'] == {'agility': 0, 'puzzling': 3}
True
def emptyFocalContext() -> FocalContext:
823def emptyFocalContext() -> FocalContext:
824    """
825    Returns a completely empty focal context, which has no capabilities
826    and which has no associated domains.
827    """
828    return {
829        'capabilities': {
830            'capabilities': set(),
831            'tokens': {},
832            'skills': {}
833        },
834        'focalization': {},
835        'activeDomains': set(),
836        'activeDecisions': {}
837    }

Returns a completely empty focal context, which has no capabilities and which has no associated domains.

def basicFocalContext( domain: Optional[str] = None, focalization: Literal['singular', 'plural', 'spreading'] = 'singular'):
840def basicFocalContext(
841    domain: Optional[Domain] = None,
842    focalization: DomainFocalization = 'singular'
843):
844    """
845    Returns a basic focal context, which has no capabilities and which
846    uses the given focalization (default 'singular') for a single
847    domain with the given name (default `DEFAULT_DOMAIN`) which is
848    active but which has no position specified.
849    """
850    if domain is None:
851        domain = DEFAULT_DOMAIN
852    return {
853        'capabilities': {
854            'capabilities': set(),
855            'tokens': {},
856            'skills': {}
857        },
858        'focalization': {domain: focalization},
859        'activeDomains': {domain},
860        'activeDecisions': {domain: None}
861    }

Returns a basic focal context, which has no capabilities and which uses the given focalization (default 'singular') for a single domain with the given name (default DEFAULT_DOMAIN) which is active but which has no position specified.

def emptyState() -> State:
864def emptyState() -> State:
865    """
866    Returns an empty `State` dictionary. The empty dictionary uses
867    `DEFAULT_FOCAL_CONTEXT_NAME` as the name of the active
868    `FocalContext`.
869    """
870    return {
871        'common': emptyFocalContext(),
872        'contexts': {DEFAULT_FOCAL_CONTEXT_NAME: basicFocalContext()},
873        'activeContext': DEFAULT_FOCAL_CONTEXT_NAME,
874        'primaryDecision': None,
875        'mechanisms': {},
876        'exploration': {},
877        'effectCounts': {},
878        'deactivated': set(),
879        'custom': {}
880    }

Returns an empty State dictionary. The empty dictionary uses DEFAULT_FOCAL_CONTEXT_NAME as the name of the active FocalContext.

def basicState( context: Optional[str] = None, domain: Optional[str] = None, focalization: Literal['singular', 'plural', 'spreading'] = 'singular') -> State:
883def basicState(
884    context: Optional[FocalContextName] = None,
885    domain: Optional[Domain] = None,
886    focalization: DomainFocalization = 'singular'
887) -> State:
888    """
889    Returns a `State` dictionary with a newly created single active
890    focal context that uses the given name (default
891    `DEFAULT_FOCAL_CONTEXT_NAME`). This context is created using
892    `basicFocalContext` with the given domain and focalization as
893    arguments (defaults `DEFAULT_DOMAIN` and 'singular').
894    """
895    if context is None:
896        context = DEFAULT_FOCAL_CONTEXT_NAME
897    return {
898        'common': emptyFocalContext(),
899        'contexts': {context: basicFocalContext(domain, focalization)},
900        'activeContext': context,
901        'primaryDecision': None,
902        'mechanisms': {},
903        'exploration': {},
904        'effectCounts': {},
905        'deactivated': set(),
906        'custom': {}
907    }

Returns a State dictionary with a newly created single active focal context that uses the given name (default DEFAULT_FOCAL_CONTEXT_NAME). This context is created using basicFocalContext with the given domain and focalization as arguments (defaults DEFAULT_DOMAIN and 'singular').

def effectiveCapabilitySet(state: State) -> CapabilitySet:
910def effectiveCapabilitySet(state: State) -> CapabilitySet:
911    """
912    Given a `baseTypes.State` object, computes the effective capability
913    set for that state, which merges capabilities and tokens from the
914    common `baseTypes.FocalContext` with those of the active one.
915
916    Returns a `CapabilitySet`.
917    """
918    # Grab relevant contexts
919    commonContext = state['common']
920    activeContext = state['contexts'][state['activeContext']]
921
922    # Extract capability dictionaries
923    commonCapabilities = commonContext['capabilities']
924    activeCapabilities = activeContext['capabilities']
925
926    return mergeCapabilitySets(
927        commonCapabilities,
928        activeCapabilities
929    )

Given a baseTypes.State object, computes the effective capability set for that state, which merges capabilities and tokens from the common baseTypes.FocalContext with those of the active one.

Returns a CapabilitySet.

def combinedDecisionSet(state: State) -> Set[int]:
932def combinedDecisionSet(state: State) -> Set[DecisionID]:
933    """
934    Given a `State` object, computes the active decision set for that
935    state, which is the set of decisions at which the player can make an
936    immediate decision. This depends on the 'common' `FocalContext` as
937    well as the active focal context, and of course each `FocalContext`
938    may specify separate active decisions for different domains, separate
939    sets of active domains, etc. See `FocalContext` and
940    `DomainFocalization` for more details, as well as `activeDecisionSet`.
941
942    Returns a set of `DecisionID`s.
943    """
944    commonContext = state['common']
945    activeContext = state['contexts'][state['activeContext']]
946    result = set()
947    for ctx in (commonContext, activeContext):
948        result |= activeDecisionSet(ctx)
949
950    return result

Given a State object, computes the active decision set for that state, which is the set of decisions at which the player can make an immediate decision. This depends on the 'common' FocalContext as well as the active focal context, and of course each FocalContext may specify separate active decisions for different domains, separate sets of active domains, etc. See FocalContext and DomainFocalization for more details, as well as activeDecisionSet.

Returns a set of DecisionIDs.

def activeDecisionSet(context: FocalContext) -> Set[int]:
 953def activeDecisionSet(context: FocalContext) -> Set[DecisionID]:
 954    """
 955    Given a `FocalContext`, returns the set of all `DecisionID`s which
 956    are active in that focal context. This includes only decisions which
 957    are in active domains.
 958
 959    For example:
 960
 961    >>> fc = emptyFocalContext()
 962    >>> activeDecisionSet(fc)
 963    set()
 964    >>> fc['focalization'] = {
 965    ...     'Si': 'singular',
 966    ...     'Pl': 'plural',
 967    ...     'Sp': 'spreading'
 968    ... }
 969    >>> fc['activeDomains'] = {'Si'}
 970    >>> fc['activeDecisions'] = {
 971    ...     'Si': 0,
 972    ...     'Pl': {'one': 1, 'two': 2},
 973    ...     'Sp': {3, 4}
 974    ... }
 975    >>> activeDecisionSet(fc)
 976    {0}
 977    >>> fc['activeDomains'] = {'Si', 'Pl'}
 978    >>> sorted(activeDecisionSet(fc))
 979    [0, 1, 2]
 980    >>> fc['activeDomains'] = {'Pl'}
 981    >>> sorted(activeDecisionSet(fc))
 982    [1, 2]
 983    >>> fc['activeDomains'] = {'Sp'}
 984    >>> sorted(activeDecisionSet(fc))
 985    [3, 4]
 986    >>> fc['activeDomains'] = {'Si', 'Pl', 'Sp'}
 987    >>> sorted(activeDecisionSet(fc))
 988    [0, 1, 2, 3, 4]
 989    """
 990    result = set()
 991    decisionsMap = context['activeDecisions']
 992    for domain in context['activeDomains']:
 993        activeGroup = decisionsMap[domain]
 994        if activeGroup is None:
 995            pass
 996        elif isinstance(activeGroup, DecisionID):
 997            result.add(activeGroup)
 998        elif isinstance(activeGroup, dict):
 999            for x in activeGroup.values():
1000                if x is not None:
1001                    result.add(x)
1002        elif isinstance(activeGroup, set):
1003            result.update(activeGroup)
1004        else:
1005            raise TypeError(
1006                f"The FocalContext {repr(context)} has an invalid"
1007                f" active group for domain {repr(domain)}."
1008                f"\nGroup is: {repr(activeGroup)}"
1009            )
1010
1011    return result

Given a FocalContext, returns the set of all DecisionIDs which are active in that focal context. This includes only decisions which are in active domains.

For example:

>>> fc = emptyFocalContext()
>>> activeDecisionSet(fc)
set()
>>> fc['focalization'] = {
...     'Si': 'singular',
...     'Pl': 'plural',
...     'Sp': 'spreading'
... }
>>> fc['activeDomains'] = {'Si'}
>>> fc['activeDecisions'] = {
...     'Si': 0,
...     'Pl': {'one': 1, 'two': 2},
...     'Sp': {3, 4}
... }
>>> activeDecisionSet(fc)
{0}
>>> fc['activeDomains'] = {'Si', 'Pl'}
>>> sorted(activeDecisionSet(fc))
[0, 1, 2]
>>> fc['activeDomains'] = {'Pl'}
>>> sorted(activeDecisionSet(fc))
[1, 2]
>>> fc['activeDomains'] = {'Sp'}
>>> sorted(activeDecisionSet(fc))
[3, 4]
>>> fc['activeDomains'] = {'Si', 'Pl', 'Sp'}
>>> sorted(activeDecisionSet(fc))
[0, 1, 2, 3, 4]
ExplorationStatus: TypeAlias = Literal['unknown', 'hypothesized', 'noticed', 'exploring', 'explored']

Exploration statuses track what kind of knowledge the player has about a decision. Note that this is independent of whether or not they've visited it. They are one of the following strings:

- 'unknown': Indicates a decision that the player has absolutely no
    knowledge of, not even by implication. Normally such decisions
    are not part of a decision map, since the player can only write
    down what they've at least seen implied. But in cases where you
    want to track exploration of a pre-specified decision map,
    decisions that are pre-specified but which the player hasn't had
    any hint of yet would have this status.
- 'hypothesized': Indicates a decision that the player can
    reasonably expect might be there, but which they haven't yet
    confirmed. This comes up when, for example, there's a flashback
    during which the player explores an older version of an area,
    which they then return to in the "present day." In this case,
    the player can hypothesize that the area layout will be the
    same, although in the end, it could in fact be different. The
    entire flashback area can be cloned and the cloned decisions
    marked as hypothesized to represent this. Note that this does
    NOT apply to decisions which are definitely implied, such as the
    decision on the other side of something the player recognizes as
    a door. Those kind of decisions should be marked as 'noticed'.
- 'noticed': Indicates a decision that the player assumes will
    exist, and/or which the player has been able to observe some
    aspects of indirectly, such as in a cutscene. A decision on the
    other side of a closed door is in this category, since even
    though the player hasn't seen anything about it, they can pretty
    reliably assume there will be some decision there.
- 'exploring': Indicates that a player has started to gain some
    knowledge of the transitions available at a decision (beyond the
    obvious reciprocals for connections to a 'noticed' decision,
    usually but not always by having now visited that decision. Even
    the most cursory visit should elevate a decision's exploration
    level to 'exploring', except perhaps if the visit is in a
    cutscene (although that can also count in some cases).
- 'explored': Indicates that the player believes they have
    discovered all of the relevant transitions at this decision, and
    there is no need for them to explore it further. This notation
    should be based on the player's immediate belief, so even if
    it's known that the player will later discover another hidden
    option at this transition (or even if the options will later
    change), unless the player is cognizant of that, it should be
    marked as 'explored' as soon as the player believes they've
    exhausted observation of transitions. The player does not have
    to have explored all of those transitions yet, including
    actions, as long as they're satisfied for now that they've found
    all of the options available.
def moreExplored( a: Literal['unknown', 'hypothesized', 'noticed', 'exploring', 'explored'], b: Literal['unknown', 'hypothesized', 'noticed', 'exploring', 'explored']) -> Literal['unknown', 'hypothesized', 'noticed', 'exploring', 'explored']:
1073def moreExplored(
1074    a: ExplorationStatus,
1075    b: ExplorationStatus
1076) -> ExplorationStatus:
1077    """
1078    Returns whichever of the two exploration statuses counts as 'more
1079    explored'.
1080    """
1081    eArgs = get_args(ExplorationStatus)
1082    try:
1083        aIndex = eArgs.index(a)
1084    except ValueError:
1085        raise ValueError(
1086            f"Status {a!r} is not a valid exploration status. Must be"
1087            f" one of: {eArgs!r}"
1088        )
1089    try:
1090        bIndex = eArgs.index(b)
1091    except ValueError:
1092        raise ValueError(
1093            f"Status {b!r} is not a valid exploration status. Must be"
1094            f" one of: {eArgs!r}"
1095        )
1096    if aIndex > bIndex:
1097        return a
1098    else:
1099        return b

Returns whichever of the two exploration statuses counts as 'more explored'.

def statusVisited( status: Literal['unknown', 'hypothesized', 'noticed', 'exploring', 'explored']) -> bool:
1102def statusVisited(status: ExplorationStatus) -> bool:
1103    """
1104    Returns true or false depending on whether the provided status
1105    indicates a decision has been visited or not. The 'exploring' and
1106    'explored' statuses imply a decision has been visisted, but other
1107    statuses do not.
1108    """
1109    return status in ('exploring', 'explored')

Returns true or false depending on whether the provided status indicates a decision has been visited or not. The 'exploring' and 'explored' statuses imply a decision has been visisted, but other statuses do not.

RestoreFCPart: TypeAlias = Literal['capabilities', 'tokens', 'skills', 'positions']

Parts of a FocalContext that can be restored. Used in revertedState.

RestoreCapabilityPart = typing.Literal['capabilities', 'tokens', 'skills']

Parts of a focal context CapabilitySet that can be restored. Used in revertedState.

RestoreFCKey = typing.Literal['focalization', 'activeDomains', 'activeDecisions']

Parts of a FocalContext besides the capability set that we can restore.

RestoreStatePart = typing.Literal['mechanisms', 'exploration', 'custom']

Parts of a State that we can restore besides the FocalContext stuff. Doesn't include the stuff covered by the 'effects' restore aspect. See revertedState for more.

def revertedState( currentStuff: Tuple[exploration.core.DecisionGraph, State], savedStuff: Tuple[exploration.core.DecisionGraph, State], revisionAspects: Set[str]) -> Tuple[exploration.core.DecisionGraph, State]:
1141def revertedState(
1142    currentStuff: Tuple['DecisionGraph', State],
1143    savedStuff: Tuple['DecisionGraph', State],
1144    revisionAspects: Set[str]
1145) -> Tuple['DecisionGraph', State]:
1146    """
1147    Given two (graph, state) pairs, as well as a set of reversion aspect
1148    strings, returns a (graph, state) pair representing the reverted
1149    graph and state. The provided graphs and states will not be
1150    modified, and the return value will not include references to them,
1151    so modifying the returned state will not modify the original or
1152    saved states or graphs.
1153
1154    If the `revisionAspects` set is empty, then all aspects except
1155    skills, exploration statuses, and the graph will be reverted.
1156
1157    Note that the reversion process can lead to impossible states if the
1158    wrong combination of reversion aspects is used (e.g., reverting the
1159    graph but not focal context position information might lead to
1160    positions that refer to decisions which do not exist).
1161
1162    Valid reversion aspect strings are:
1163    - "common-capabilities", "common-tokens", "common-skills,"
1164        "common-positions" or just "common" for all four. These
1165        control the parts of the common context's `CapabilitySet`
1166        that get reverted, as well as whether the focalization,
1167        active domains, and active decisions get reverted (those
1168        three as "positions").
1169    - "c-*NAME*-capabilities" as well as -tokens, -skills,
1170        -positions, and without a suffix, where *NAME* is the name of
1171        a specific focal context.
1172    - "all-capabilities" as well as -tokens, -skills, -positions,
1173        and -contexts, reverting the relevant part of all focal
1174        contexts except the common one, with "all-contexts" reverting
1175        every part of all non-common focal contexts.
1176    - "current-capabilities" as well as -tokens, -skills, -positions,
1177        and without a suffix, for the currently-active focal context.
1178    - "primary" which reverts the primary decision (some positions should
1179        also be reverted in this case).
1180    - "mechanisms" which reverts mechanism states.
1181    - "exploration" which reverts the exploration state of decisions
1182        (note that the `DecisionGraph` also stores "unconfirmed" tags
1183        which are NOT affected by a revert unless "graph" is specified).
1184    - "effects" which reverts the record of how many times transition
1185        effects have been triggered, plus whether transitions have
1186        been disabled or not.
1187    - "custom" which reverts custom state.
1188    - "graph" reverts the graph itself (but this is usually not
1189        desired). This will still preserve the next-ID value for
1190        assigning new nodes, so that nodes created in a reverted graph
1191        will not re-use IDs from nodes created before the reversion.
1192    - "-*NAME*" where *NAME* is a custom reversion specification
1193        defined using `core.DecisionGraph.reversionType` and available
1194        in the "current" decision graph (note the dash is required
1195        before the custom name). This allows complex reversion systems
1196        to be set up once and referenced repeatedly. Any strings
1197        specified along with a custom reversion type will revert the
1198        specified state in addition to what the custom reversion type
1199        specifies.
1200
1201    For example:
1202
1203    >>> from . import core
1204    >>> g = core.DecisionGraph.example("simple")  # A - B - C triangle
1205    >>> g.setTransitionRequirement('B', 'next', ReqCapability('helmet'))
1206    >>> g.addAction(
1207    ...     'A',
1208    ...     'getHelmet',
1209    ...     consequence=[effect(gain='helmet'), effect(deactivate=True)]
1210    ... )
1211    >>> s0 = basicState()
1212    >>> fc0 = s0['contexts']['main']
1213    >>> fc0['activeDecisions']['main'] = 0  # A
1214    >>> s1 = basicState()
1215    >>> fc1 = s1['contexts']['main']
1216    >>> fc1['capabilities']['capabilities'].add('helmet')
1217    >>> fc1['activeDecisions']['main'] = 1  # B
1218    >>> s1['exploration'] = {0: 'explored', 1: 'exploring', 2: 'unknown'}
1219    >>> s1['effectCounts'] = {(0, 'getHelmet', 1): 1}
1220    >>> s1['deactivated'] = {(0, "getHelmet")}
1221    >>> # Basic reversion of everything except graph & exploration
1222    >>> rg, rs = revertedState((g, s1), (g, s0), set())
1223    >>> rg == g
1224    True
1225    >>> rg is g
1226    False
1227    >>> rs == s0
1228    False
1229    >>> rs is s0
1230    False
1231    >>> rs['contexts'] == s0['contexts']
1232    True
1233    >>> rs['exploration'] == s1['exploration']
1234    True
1235    >>> rs['effectCounts'] = s0['effectCounts']
1236    >>> rs['deactivated'] = s0['deactivated']
1237    >>> # Reverting capabilities but not position, exploration, or effects
1238    >>> rg, rs = revertedState((g, s1), (g, s0), {"current-capabilities"})
1239    >>> rg == g
1240    True
1241    >>> rs == s0 or rs == s1
1242    False
1243    >>> s1['contexts']['main']['capabilities']['capabilities']
1244    {'helmet'}
1245    >>> s0['contexts']['main']['capabilities']['capabilities']
1246    set()
1247    >>> rs['contexts']['main']['capabilities']['capabilities']
1248    set()
1249    >>> s1['contexts']['main']['activeDecisions']['main']
1250    1
1251    >>> s0['contexts']['main']['activeDecisions']['main']
1252    0
1253    >>> rs['contexts']['main']['activeDecisions']['main']
1254    1
1255    >>> # Restore position and effects; that's all that wasn't reverted
1256    >>> rs['contexts']['main']['activeDecisions']['main'] = 0
1257    >>> rs['exploration'] = {}
1258    >>> rs['effectCounts'] = {}
1259    >>> rs['deactivated'] = set()
1260    >>> rs == s0
1261    True
1262    >>> # Reverting position but not state
1263    >>> rg, rs = revertedState((g, s1), (g, s0), {"current-positions"})
1264    >>> rg == g
1265    True
1266    >>> s1['contexts']['main']['capabilities']['capabilities']
1267    {'helmet'}
1268    >>> s0['contexts']['main']['capabilities']['capabilities']
1269    set()
1270    >>> rs['contexts']['main']['capabilities']['capabilities']
1271    {'helmet'}
1272    >>> s1['contexts']['main']['activeDecisions']['main']
1273    1
1274    >>> s0['contexts']['main']['activeDecisions']['main']
1275    0
1276    >>> rs['contexts']['main']['activeDecisions']['main']
1277    0
1278    >>> # Reverting based on specific focal context name
1279    >>> rg2, rs2 = revertedState((g, s1), (g, s0), {"c-main-positions"})
1280    >>> rg2 == rg
1281    True
1282    >>> rs2 == rs
1283    True
1284    >>> # Test of graph reversion
1285    >>> import copy
1286    >>> g2 = copy.deepcopy(g)
1287    >>> g2.addDecision('D')
1288    3
1289    >>> g2.addTransition(2, 'alt', 'D', 'return')
1290    >>> rg, rs = revertedState((g2, s1), (g, s0), {'graph'})
1291    >>> rg == g
1292    True
1293    >>> rg is g
1294    False
1295
1296    TODO: More tests for various other reversion aspects
1297    TODO: Implement per-token-type / per-capability / per-mechanism /
1298    per-skill reversion.
1299    """
1300    # Expand custom references
1301    expandedAspects = set()
1302    queue = list(revisionAspects)
1303    if len(queue) == 0:
1304        queue = [  # don't revert skills, exploration, and graph
1305            "common-capabilities",
1306            "common-tokens",
1307            "common-positions",
1308            "all-capabilities",
1309            "all-tokens",
1310            "all-positions",
1311            "mechanisms",
1312            "primary",
1313            "effects",
1314            "custom"
1315        ]  # we do not include "graph" or "exploration" here...
1316    customLookup = currentStuff[0].reversionTypes
1317    while len(queue) > 0:
1318        aspect = queue.pop(0)
1319        if aspect.startswith('-'):
1320            customName = aspect[1:]
1321            if customName not in customLookup:
1322                raise ValueError(
1323                    f"Custom reversion type {aspect[1:]!r} is invalid"
1324                    f" because that reversion type has not been"
1325                    f" defined. Defined types are:"
1326                    f"\n{list(customLookup.keys())}"
1327                )
1328            queue.extend(customLookup[customName])
1329        else:
1330            expandedAspects.add(aspect)
1331
1332    # Further expand focal-context-part collectives
1333    if "common" in expandedAspects:
1334        expandedAspects.add("common-capabilities")
1335        expandedAspects.add("common-tokens")
1336        expandedAspects.add("common-skills")
1337        expandedAspects.add("common-positions")
1338
1339    if "all-contexts" in expandedAspects:
1340        expandedAspects.add("all-capabilities")
1341        expandedAspects.add("all-tokens")
1342        expandedAspects.add("all-skills")
1343        expandedAspects.add("all-positions")
1344
1345    if "current" in expandedAspects:
1346        expandedAspects.add("current-capabilities")
1347        expandedAspects.add("current-tokens")
1348        expandedAspects.add("current-skills")
1349        expandedAspects.add("current-positions")
1350
1351    # Figure out things to revert that are specific to named focal
1352    # contexts
1353    perFC: Dict[FocalContextName, Set[RestoreFCPart]] = {}
1354    currentFCName = currentStuff[1]['activeContext']
1355    for aspect in expandedAspects:
1356        # For current- stuff, look up current context name
1357        if aspect.startswith("current"):
1358            found = False
1359            part: RestoreFCPart
1360            for part in get_args(RestoreFCPart):
1361                if aspect == f"current-{part}":
1362                    perFC.setdefault(currentFCName, set()).add(part)
1363                    found = True
1364            if not found and aspect != "current":
1365                raise RuntimeError(f"Invalid reversion aspect: {aspect!r}")
1366        elif aspect.startswith("c-"):
1367            if aspect.endswith("-capabilities"):
1368                fcName = aspect[2:-13]
1369                perFC.setdefault(fcName, set()).add("capabilities")
1370            elif aspect.endswith("-tokens"):
1371                fcName = aspect[2:-7]
1372                perFC.setdefault(fcName, set()).add("tokens")
1373            elif aspect.endswith("-skills"):
1374                fcName = aspect[2:-7]
1375                perFC.setdefault(fcName, set()).add("skills")
1376            elif aspect.endswith("-positions"):
1377                fcName = aspect[2:-10]
1378                perFC.setdefault(fcName, set()).add("positions")
1379            else:
1380                fcName = aspect[2:]
1381                forThis = perFC.setdefault(fcName, set())
1382                forThis.add("capabilities")
1383                forThis.add("tokens")
1384                forThis.add("skills")
1385                forThis.add("positions")
1386
1387    currentState = currentStuff[1]
1388    savedState = savedStuff[1]
1389
1390    # Expand all-FC reversions to per-FC entries for each FC in both
1391    # current and prior states
1392    allFCs = set(currentState['contexts']) | set(savedState['contexts'])
1393    for part in get_args(RestoreFCPart):
1394        if f"all-{part}" in expandedAspects:
1395            for fcName in allFCs:
1396                perFC.setdefault(fcName, set()).add(part)
1397
1398    # Revert graph or not
1399    if "graph" in expandedAspects:
1400        resultGraph = copy.deepcopy(savedStuff[0])
1401        # Patch nextID to avoid spurious ID matches
1402        resultGraph.nextID = currentStuff[0].nextID
1403    else:
1404        resultGraph = copy.deepcopy(currentStuff[0])
1405
1406    # Start from non-reverted state copy
1407    resultState = copy.deepcopy(currentState)
1408
1409    # Revert primary decision or not
1410    if "primary" in expandedAspects:
1411        resultState['primaryDecision'] = savedState['primaryDecision']
1412
1413    # Revert specified aspects of the common focal context
1414    savedCommon = savedState['common']
1415    capKey: RestoreCapabilityPart
1416    for capKey in get_args(RestoreCapabilityPart):
1417        if f"common-{part}" in expandedAspects:
1418            resultState['common']['capabilities'][capKey] = copy.deepcopy(
1419                savedCommon['capabilities'][capKey]
1420            )
1421    if "common-positions" in expandedAspects:
1422        fcKey: RestoreFCKey
1423        for fcKey in get_args(RestoreFCKey):
1424            resultState['common'][fcKey] = copy.deepcopy(savedCommon[fcKey])
1425
1426    # Update focal context parts for named focal contexts:
1427    savedContextMap = savedState['contexts']
1428    for fcName, restore in perFC.items():
1429        thisFC = resultState['contexts'].setdefault(
1430            fcName,
1431            emptyFocalContext()
1432        )  # Create FC by name if it didn't exist already
1433        thatFC = savedContextMap.get(fcName)
1434        if thatFC is None:  # what if it's a new one?
1435            if restore == set(get_args(RestoreFCPart)):
1436                # If we're restoring everything and the context didn't
1437                # exist in the prior state, delete it in the restored
1438                # state
1439                del resultState['contexts'][fcName]
1440            else:
1441                # Otherwise update parts of it to be blank since prior
1442                # state didn't have any info
1443                for part in restore:
1444                    if part == "positions":
1445                        thisFC['focalization'] = {}
1446                        thisFC['activeDomains'] = set()
1447                        thisFC['activeDecisions'] = {}
1448                    elif part == "capabilities":
1449                        thisFC['capabilities'][part] = set()
1450                    else:
1451                        thisFC['capabilities'][part] = {}
1452        else:  # same context existed in saved data; update parts
1453            for part in restore:
1454                if part == "positions":
1455                    for fcKey in get_args(RestoreFCKey):  # typed above
1456                        thisFC[fcKey] = copy.deepcopy(thatFC[fcKey])
1457                else:
1458                    thisFC['capabilities'][part] = copy.deepcopy(
1459                        thatFC['capabilities'][part]
1460                    )
1461
1462    # Revert mechanisms, exploration, and/or custom state if specified
1463    statePart: RestoreStatePart
1464    for statePart in get_args(RestoreStatePart):
1465        if statePart in expandedAspects:
1466            resultState[statePart] = copy.deepcopy(savedState[statePart])
1467
1468    # Revert effect tracking if specified
1469    if "effects" in expandedAspects:
1470        resultState['effectCounts'] = copy.deepcopy(
1471            savedState['effectCounts']
1472        )
1473        resultState['deactivated'] = copy.deepcopy(savedState['deactivated'])
1474
1475    return (resultGraph, resultState)

Given two (graph, state) pairs, as well as a set of reversion aspect strings, returns a (graph, state) pair representing the reverted graph and state. The provided graphs and states will not be modified, and the return value will not include references to them, so modifying the returned state will not modify the original or saved states or graphs.

If the revisionAspects set is empty, then all aspects except skills, exploration statuses, and the graph will be reverted.

Note that the reversion process can lead to impossible states if the wrong combination of reversion aspects is used (e.g., reverting the graph but not focal context position information might lead to positions that refer to decisions which do not exist).

Valid reversion aspect strings are:

  • "common-capabilities", "common-tokens", "common-skills," "common-positions" or just "common" for all four. These control the parts of the common context's CapabilitySet that get reverted, as well as whether the focalization, active domains, and active decisions get reverted (those three as "positions").
  • "c-NAME-capabilities" as well as -tokens, -skills, -positions, and without a suffix, where NAME is the name of a specific focal context.
  • "all-capabilities" as well as -tokens, -skills, -positions, and -contexts, reverting the relevant part of all focal contexts except the common one, with "all-contexts" reverting every part of all non-common focal contexts.
  • "current-capabilities" as well as -tokens, -skills, -positions, and without a suffix, for the currently-active focal context.
  • "primary" which reverts the primary decision (some positions should also be reverted in this case).
  • "mechanisms" which reverts mechanism states.
  • "exploration" which reverts the exploration state of decisions (note that the DecisionGraph also stores "unconfirmed" tags which are NOT affected by a revert unless "graph" is specified).
  • "effects" which reverts the record of how many times transition effects have been triggered, plus whether transitions have been disabled or not.
  • "custom" which reverts custom state.
  • "graph" reverts the graph itself (but this is usually not desired). This will still preserve the next-ID value for assigning new nodes, so that nodes created in a reverted graph will not re-use IDs from nodes created before the reversion.
  • "-NAME" where NAME is a custom reversion specification defined using core.DecisionGraph.reversionType and available in the "current" decision graph (note the dash is required before the custom name). This allows complex reversion systems to be set up once and referenced repeatedly. Any strings specified along with a custom reversion type will revert the specified state in addition to what the custom reversion type specifies.

For example:

>>> from . import core
>>> g = core.DecisionGraph.example("simple")  # A - B - C triangle
>>> g.setTransitionRequirement('B', 'next', ReqCapability('helmet'))
>>> g.addAction(
...     'A',
...     'getHelmet',
...     consequence=[effect(gain='helmet'), effect(deactivate=True)]
... )
>>> s0 = basicState()
>>> fc0 = s0['contexts']['main']
>>> fc0['activeDecisions']['main'] = 0  # A
>>> s1 = basicState()
>>> fc1 = s1['contexts']['main']
>>> fc1['capabilities']['capabilities'].add('helmet')
>>> fc1['activeDecisions']['main'] = 1  # B
>>> s1['exploration'] = {0: 'explored', 1: 'exploring', 2: 'unknown'}
>>> s1['effectCounts'] = {(0, 'getHelmet', 1): 1}
>>> s1['deactivated'] = {(0, "getHelmet")}
>>> # Basic reversion of everything except graph & exploration
>>> rg, rs = revertedState((g, s1), (g, s0), set())
>>> rg == g
True
>>> rg is g
False
>>> rs == s0
False
>>> rs is s0
False
>>> rs['contexts'] == s0['contexts']
True
>>> rs['exploration'] == s1['exploration']
True
>>> rs['effectCounts'] = s0['effectCounts']
>>> rs['deactivated'] = s0['deactivated']
>>> # Reverting capabilities but not position, exploration, or effects
>>> rg, rs = revertedState((g, s1), (g, s0), {"current-capabilities"})
>>> rg == g
True
>>> rs == s0 or rs == s1
False
>>> s1['contexts']['main']['capabilities']['capabilities']
{'helmet'}
>>> s0['contexts']['main']['capabilities']['capabilities']
set()
>>> rs['contexts']['main']['capabilities']['capabilities']
set()
>>> s1['contexts']['main']['activeDecisions']['main']
1
>>> s0['contexts']['main']['activeDecisions']['main']
0
>>> rs['contexts']['main']['activeDecisions']['main']
1
>>> # Restore position and effects; that's all that wasn't reverted
>>> rs['contexts']['main']['activeDecisions']['main'] = 0
>>> rs['exploration'] = {}
>>> rs['effectCounts'] = {}
>>> rs['deactivated'] = set()
>>> rs == s0
True
>>> # Reverting position but not state
>>> rg, rs = revertedState((g, s1), (g, s0), {"current-positions"})
>>> rg == g
True
>>> s1['contexts']['main']['capabilities']['capabilities']
{'helmet'}
>>> s0['contexts']['main']['capabilities']['capabilities']
set()
>>> rs['contexts']['main']['capabilities']['capabilities']
{'helmet'}
>>> s1['contexts']['main']['activeDecisions']['main']
1
>>> s0['contexts']['main']['activeDecisions']['main']
0
>>> rs['contexts']['main']['activeDecisions']['main']
0
>>> # Reverting based on specific focal context name
>>> rg2, rs2 = revertedState((g, s1), (g, s0), {"c-main-positions"})
>>> rg2 == rg
True
>>> rs2 == rs
True
>>> # Test of graph reversion
>>> import copy
>>> g2 = copy.deepcopy(g)
>>> g2.addDecision('D')
3
>>> g2.addTransition(2, 'alt', 'D', 'return')
>>> rg, rs = revertedState((g2, s1), (g, s0), {'graph'})
>>> rg == g
True
>>> rg is g
False

TODO: More tests for various other reversion aspects TODO: Implement per-token-type / per-capability / per-mechanism / per-skill reversion.

class RequirementContext(typing.NamedTuple):
1482class RequirementContext(NamedTuple):
1483    """
1484    The context necessary to check whether a requirement is satisfied or
1485    not. Also used for computing effective skill levels for
1486    `SkillCombination`s. Includes a `State` that specifies `Capability`
1487    and `Token` states, a `DecisionGraph` (which includes equivalences),
1488    and a set of `DecisionID`s to use as the starting place for finding
1489    mechanisms by name.
1490    """
1491    state: State
1492    graph: 'DecisionGraph'
1493    searchFrom: Set[DecisionID]

The context necessary to check whether a requirement is satisfied or not. Also used for computing effective skill levels for SkillCombinations. Includes a State that specifies Capability and Token states, a DecisionGraph (which includes equivalences), and a set of DecisionIDs to use as the starting place for finding mechanisms by name.

RequirementContext( state: State, graph: ForwardRef('DecisionGraph'), searchFrom: Set[int])

Create new instance of RequirementContext(state, graph, searchFrom)

state: State

Alias for field number 0

Alias for field number 1

searchFrom: Set[int]

Alias for field number 2

Inherited Members
builtins.tuple
index
count
def getSkillLevel(state: State, skill: str) -> int:
1496def getSkillLevel(state: State, skill: Skill) -> Level:
1497    """
1498    Given a `State` and a `Skill`, looks up that skill in both the
1499    common and active `FocalContext`s of the state, and adds those
1500    numbers together to get an effective skill level for that skill.
1501    Note that `SkillCombination`s can be used to set up more complex
1502    logic for skill combinations across different skills; if adding
1503    levels isn't desired between `FocalContext`s, use different skill
1504    names.
1505
1506    If the skill isn't mentioned, the level will count as 0.
1507    """
1508    commonContext = state['common']
1509    activeContext = state['contexts'][state['activeContext']]
1510    return (
1511        commonContext['capabilities']['skills'].get(skill, 0)
1512      + activeContext['capabilities']['skills'].get(skill, 0)
1513    )

Given a State and a Skill, looks up that skill in both the common and active FocalContexts of the state, and adds those numbers together to get an effective skill level for that skill. Note that SkillCombinations can be used to set up more complex logic for skill combinations across different skills; if adding levels isn't desired between FocalContexts, use different skill names.

If the skill isn't mentioned, the level will count as 0.

SaveSlot: TypeAlias = str
EffectType = typing.Literal['gain', 'lose', 'set', 'toggle', 'deactivate', 'edit', 'goto', 'bounce', 'follow', 'save']

The types that effects can use. See Effect for details.

A union of all possible effect types.

class Effect(typing.TypedDict):
1554class Effect(TypedDict):
1555    """
1556    Represents one effect of a transition on the decision graph and/or
1557    game state. The `type` slot is an `EffectType` that indicates what
1558    type of effect it is, and determines what the `value` slot will hold.
1559    The `charges` slot is normally `None`, but when set to an integer,
1560    the effect will only trigger that many times, subtracting one charge
1561    each time until it reaches 0, after which the effect will remain but
1562    be ignored. The `delay` slot is also normally `None`, but when set to
1563    an integer, the effect won't trigger but will instead subtract one
1564    from the delay until it reaches zero, at which point it will start to
1565    trigger (and use up charges if there are any). The 'applyTo' slot
1566    should be either 'common' or 'active' (a `ContextSpecifier`) and
1567    determines which focal context the effect applies to.
1568
1569    The `value` values for each `type` are:
1570
1571    - `'gain'`: A `Capability`, (`Token`, `TokenCount`) pair, or
1572        ('skill', `Skill`, `Level`) triple indicating a capability
1573        gained, some tokens acquired, or skill levels gained.
1574    - `'lose'`: A `Capability`, (`Token`, `TokenCount`) pair, or
1575        ('skill', `Skill`, `Level`) triple indicating a capability lost,
1576        some tokens spent, or skill levels lost. Note that the literal
1577        string 'skill' is added to disambiguate skills from tokens.
1578    - `'set'`: A (`Token`, `TokenCount`) pair, a (`MechanismSpecifier`,
1579        `MechanismState`) pair, or a ('skill', `Skill`, `Level`) triple
1580        indicating the new number of tokens, new mechanism state, or new
1581        skill level to establish. Ignores the old count/level, unlike
1582        'gain' and 'lose.'
1583    - `'toggle'`: A list of capabilities which will be toggled on one
1584        after the other, toggling the rest off, OR, a tuple containing a
1585        mechanism name followed by a list of states to be set one after
1586        the other. Does not work for tokens or skills. If a `Capability`
1587        list only has one item, it will be toggled on or off depending
1588        on whether the player currently has that capability or not,
1589        otherwise, whichever capability in the toggle list is currently
1590        active will determine which one gets activated next (the
1591        subsequent one in the list, wrapping from the end to the start).
1592        Note that equivalences are NOT considered when determining which
1593        capability to turn on, and ALL capabilities in the toggle list
1594        except the next one to turn on are turned off. Also, if none of
1595        the capabilities in the list is currently activated, the first
1596        one will be. For mechanisms, `DEFAULT_MECHANISM_STATE` will be
1597        used as the default state if only one state is provided, since
1598        mechanisms can't be "not in a state." `Mechanism` toggles
1599        function based on the current mechanism state; if it's not in
1600        the list they set the first given state.
1601    - `'deactivate'`: `None`. When the effect is activated, the
1602        transition it applies on will be added to the deactivated set in
1603        the current state. This effect type ignores the 'applyTo' value
1604        since it does not make changes to a `FocalContext`.
1605    - `'edit'`: A list of lists of `Command`s, with each list to be
1606        applied in succession on every subsequent activation of the
1607        transition (like toggle). These can use extra variables '$@' to
1608        refer to the source decision of the transition the edit effect is
1609        attached to, '$@d' to refer to the destination decision, '$@t' to
1610        refer to the transition, and '$@r' to refer to its reciprocal.
1611        Commands are powerful and might edit more than just the
1612        specified focal context.
1613        TODO: How to handle list-of-lists format?
1614    - `'goto'`: Either an `AnyDecisionSpecifier` specifying where the
1615        player should end up, or an (`AnyDecisionSpecifier`,
1616        `FocalPointName`) specifying both where they should end up and
1617        which focal point in the relevant domain should be moved. If
1618        multiple 'goto' values are present on different effects of a
1619        transition, they each trigger in turn (and e.g., might activate
1620        multiple decision points in a spreading-focalized domain). Every
1621        transition has a destination, so 'goto' is not necessary: use it
1622        only when an attempt to take a transition is diverted (and
1623        normally, in conjunction with 'charges', 'delay', and/or as an
1624        effect that's behind a `Challenge` or `Conditional`). If a goto
1625        specifies a destination in a plural-focalized domain, but does
1626        not include a focal point name, then the focal point which was
1627        taking the transition will be the one to move. If that
1628        information is not available, the first focal point created in
1629        that domain will be moved by default. Note that when using
1630        something other than a destination ID as the
1631        `AnyDecisionSpecifier`, it's up to you to ensure that the
1632        specifier is not ambiguous, otherwise taking the transition will
1633        crash the program.
1634    - `'bounce'`: Value will be `None`. Prevents the normal position
1635        update associated with a transition that this effect applies to.
1636        Normally, a transition should be marked with an appropriate
1637        requirement to prevent access, even in cases where access seems
1638        possible until tested (just add the requirement on a step after
1639        the transition is observed where relevant). However, 'bounce' can
1640        be used in cases where there's a challenge to fail, for example.
1641        `bounce` is redundant with `goto`: if a `goto` effect applies on
1642        a certain transition, the presence or absence of `bounce` on the
1643        same transition is ignored, since the new position will be
1644        specified by the `goto` value anyways.
1645    - `'follow'`: Value will be a `Transition` name. A transition with
1646        that name must exist at the destination of the action, and when
1647        the follow effect triggers, the player will immediately take
1648        that transition (triggering any consequences it has) after
1649        arriving at their normal destination (so the exploration status
1650        of the normal destination will also be updated). This can result
1651        in an infinite loop if two 'follow' effects imply transitions
1652        which trigger each other, so don't do that.
1653    - `'save'`: Value will be a string indicating a save-slot name.
1654        Indicates a save point, which can be returned to using a
1655        'revertTo' `ExplorationAction`. The entire game state and current
1656        graph is recorded, including effects of the current consequence
1657        before, but not after, the 'save' effect. However, the graph
1658        configuration is not restored by default (see 'revert'). A revert
1659        effect may specify only parts of the state to revert.
1660
1661    TODO:
1662        'focus',
1663        'foreground',
1664        'background',
1665    """
1666    type: EffectType
1667    applyTo: ContextSpecifier
1668    value: AnyEffectValue
1669    charges: Optional[int]
1670    delay: Optional[int]
1671    hidden: bool

Represents one effect of a transition on the decision graph and/or game state. The type slot is an EffectType that indicates what type of effect it is, and determines what the value slot will hold. The charges slot is normally None, but when set to an integer, the effect will only trigger that many times, subtracting one charge each time until it reaches 0, after which the effect will remain but be ignored. The delay slot is also normally None, but when set to an integer, the effect won't trigger but will instead subtract one from the delay until it reaches zero, at which point it will start to trigger (and use up charges if there are any). The 'applyTo' slot should be either 'common' or 'active' (a ContextSpecifier) and determines which focal context the effect applies to.

The value values for each type are:

  • 'gain': A Capability, (Token, TokenCount) pair, or ('skill', Skill, Level) triple indicating a capability gained, some tokens acquired, or skill levels gained.
  • 'lose': A Capability, (Token, TokenCount) pair, or ('skill', Skill, Level) triple indicating a capability lost, some tokens spent, or skill levels lost. Note that the literal string 'skill' is added to disambiguate skills from tokens.
  • 'set': A (Token, TokenCount) pair, a (MechanismSpecifier, MechanismState) pair, or a ('skill', Skill, Level) triple indicating the new number of tokens, new mechanism state, or new skill level to establish. Ignores the old count/level, unlike 'gain' and 'lose.'
  • 'toggle': A list of capabilities which will be toggled on one after the other, toggling the rest off, OR, a tuple containing a mechanism name followed by a list of states to be set one after the other. Does not work for tokens or skills. If a Capability list only has one item, it will be toggled on or off depending on whether the player currently has that capability or not, otherwise, whichever capability in the toggle list is currently active will determine which one gets activated next (the subsequent one in the list, wrapping from the end to the start). Note that equivalences are NOT considered when determining which capability to turn on, and ALL capabilities in the toggle list except the next one to turn on are turned off. Also, if none of the capabilities in the list is currently activated, the first one will be. For mechanisms, DEFAULT_MECHANISM_STATE will be used as the default state if only one state is provided, since mechanisms can't be "not in a state." Mechanism toggles function based on the current mechanism state; if it's not in the list they set the first given state.
  • 'deactivate': None. When the effect is activated, the transition it applies on will be added to the deactivated set in the current state. This effect type ignores the 'applyTo' value since it does not make changes to a FocalContext.
  • 'edit': A list of lists of Commands, with each list to be applied in succession on every subsequent activation of the transition (like toggle). These can use extra variables '$@' to refer to the source decision of the transition the edit effect is attached to, '$@d' to refer to the destination decision, '$@t' to refer to the transition, and '$@r' to refer to its reciprocal. Commands are powerful and might edit more than just the specified focal context. TODO: How to handle list-of-lists format?
  • 'goto': Either an AnyDecisionSpecifier specifying where the player should end up, or an (AnyDecisionSpecifier, FocalPointName) specifying both where they should end up and which focal point in the relevant domain should be moved. If multiple 'goto' values are present on different effects of a transition, they each trigger in turn (and e.g., might activate multiple decision points in a spreading-focalized domain). Every transition has a destination, so 'goto' is not necessary: use it only when an attempt to take a transition is diverted (and normally, in conjunction with 'charges', 'delay', and/or as an effect that's behind a Challenge or Conditional). If a goto specifies a destination in a plural-focalized domain, but does not include a focal point name, then the focal point which was taking the transition will be the one to move. If that information is not available, the first focal point created in that domain will be moved by default. Note that when using something other than a destination ID as the AnyDecisionSpecifier, it's up to you to ensure that the specifier is not ambiguous, otherwise taking the transition will crash the program.
  • 'bounce': Value will be None. Prevents the normal position update associated with a transition that this effect applies to. Normally, a transition should be marked with an appropriate requirement to prevent access, even in cases where access seems possible until tested (just add the requirement on a step after the transition is observed where relevant). However, 'bounce' can be used in cases where there's a challenge to fail, for example. bounce is redundant with goto: if a goto effect applies on a certain transition, the presence or absence of bounce on the same transition is ignored, since the new position will be specified by the goto value anyways.
  • 'follow': Value will be a Transition name. A transition with that name must exist at the destination of the action, and when the follow effect triggers, the player will immediately take that transition (triggering any consequences it has) after arriving at their normal destination (so the exploration status of the normal destination will also be updated). This can result in an infinite loop if two 'follow' effects imply transitions which trigger each other, so don't do that.
  • 'save': Value will be a string indicating a save-slot name. Indicates a save point, which can be returned to using a 'revertTo' ExplorationAction. The entire game state and current graph is recorded, including effects of the current consequence before, but not after, the 'save' effect. However, the graph configuration is not restored by default (see 'revert'). A revert effect may specify only parts of the state to revert.

TODO: 'focus', 'foreground', 'background',

type: Literal['gain', 'lose', 'set', 'toggle', 'deactivate', 'edit', 'goto', 'bounce', 'follow', 'save']
applyTo: Literal['common', 'active']
charges: Optional[int]
delay: Optional[int]
hidden: bool
def effect( *, applyTo: Literal['common', 'active'] = 'active', gain: Union[str, Tuple[str, int], Tuple[Literal['skill'], str, int], NoneType] = None, lose: Union[str, Tuple[str, int], Tuple[Literal['skill'], str, int], NoneType] = None, set: Union[Tuple[str, int], Tuple[Union[int, str, MechanismSpecifier], str], Tuple[Literal['skill'], str, int], NoneType] = None, toggle: Union[Tuple[Union[int, str, MechanismSpecifier], List[str]], List[str], NoneType] = None, deactivate: Optional[bool] = None, edit: Optional[List[List[Union[exploration.commands.LiteralValue, exploration.commands.EstablishCollection, exploration.commands.AppendValue, exploration.commands.SetValue, exploration.commands.PopValue, exploration.commands.GetValue, exploration.commands.RemoveValue, exploration.commands.ApplyOperator, exploration.commands.ApplyUnary, exploration.commands.VariableAssignment, exploration.commands.VariableDeletion, exploration.commands.LoadVariable, exploration.commands.FunctionCall, exploration.commands.SkipCommands, exploration.commands.Label]]]] = None, goto: Union[int, DecisionSpecifier, str, Tuple[Union[int, DecisionSpecifier, str], str], NoneType] = None, bounce: Optional[bool] = None, follow: Optional[str] = None, save: Optional[str] = None, delay: Optional[int] = None, charges: Optional[int] = None, hidden: bool = False) -> Effect:
1674def effect(
1675    *,
1676    applyTo: ContextSpecifier = 'active',
1677    gain: Optional[Union[
1678        Capability,
1679        Tuple[Token, TokenCount],
1680        Tuple[Literal['skill'], Skill, Level]
1681    ]] = None,
1682    lose: Optional[Union[
1683        Capability,
1684        Tuple[Token, TokenCount],
1685        Tuple[Literal['skill'], Skill, Level]
1686    ]] = None,
1687    set: Optional[Union[
1688        Tuple[Token, TokenCount],
1689        Tuple[AnyMechanismSpecifier, MechanismState],
1690        Tuple[Literal['skill'], Skill, Level]
1691    ]] = None,
1692    toggle: Optional[Union[
1693        Tuple[AnyMechanismSpecifier, List[MechanismState]],
1694        List[Capability]
1695    ]] = None,
1696    deactivate: Optional[bool] = None,
1697    edit: Optional[List[List[commands.Command]]] = None,
1698    goto: Optional[Union[
1699        AnyDecisionSpecifier,
1700        Tuple[AnyDecisionSpecifier, FocalPointName]
1701    ]] = None,
1702    bounce: Optional[bool] = None,
1703    follow: Optional[Transition] = None,
1704    save: Optional[SaveSlot] = None,
1705    delay: Optional[int] = None,
1706    charges: Optional[int] = None,
1707    hidden: bool = False
1708) -> Effect:
1709    """
1710    Factory for a transition effect which includes default values so you
1711    can just specify effect types that are relevant to a particular
1712    situation. You may not supply values for more than one of
1713    gain/lose/set/toggle/deactivate/edit/goto/bounce, since which one
1714    you use determines the effect type.
1715    """
1716    tCount = len([
1717        x
1718        for x in (
1719            gain,
1720            lose,
1721            set,
1722            toggle,
1723            deactivate,
1724            edit,
1725            goto,
1726            bounce,
1727            follow,
1728            save
1729        )
1730        if x is not None
1731    ])
1732    if tCount == 0:
1733        raise ValueError(
1734            "You must specify one of gain, lose, set, toggle, deactivate,"
1735            " edit, goto, bounce, follow, or save."
1736        )
1737    elif tCount > 1:
1738        raise ValueError(
1739            f"You may only specify one of gain, lose, set, toggle,"
1740            f" deactivate, edit, goto, bounce, follow, or save"
1741            f" (you provided values for {tCount} of those)."
1742        )
1743
1744    result: Effect = {
1745        'type': 'edit',
1746        'applyTo': applyTo,
1747        'value': [],
1748        'delay': delay,
1749        'charges': charges,
1750        'hidden': hidden
1751    }
1752
1753    if gain is not None:
1754        result['type'] = 'gain'
1755        result['value'] = gain
1756    elif lose is not None:
1757        result['type'] = 'lose'
1758        result['value'] = lose
1759    elif set is not None:
1760        result['type'] = 'set'
1761        if (
1762            len(set) == 2
1763        and isinstance(set[0], MechanismName)
1764        and isinstance(set[1], MechanismState)
1765        ):
1766            result['value'] = (
1767                MechanismSpecifier(None, None, None, set[0]),
1768                set[1]
1769            )
1770        else:
1771            result['value'] = set
1772    elif toggle is not None:
1773        result['type'] = 'toggle'
1774        result['value'] = toggle
1775    elif deactivate is not None:
1776        result['type'] = 'deactivate'
1777        result['value'] = None
1778    elif edit is not None:
1779        result['type'] = 'edit'
1780        result['value'] = edit
1781    elif goto is not None:
1782        result['type'] = 'goto'
1783        result['value'] = goto
1784    elif bounce is not None:
1785        result['type'] = 'bounce'
1786        result['value'] = None
1787    elif follow is not None:
1788        result['type'] = 'follow'
1789        result['value'] = follow
1790    elif save is not None:
1791        result['type'] = 'save'
1792        result['value'] = save
1793    else:
1794        raise RuntimeError(
1795            "No effect specified in effect function & check failed."
1796        )
1797
1798    return result

Factory for a transition effect which includes default values so you can just specify effect types that are relevant to a particular situation. You may not supply values for more than one of gain/lose/set/toggle/deactivate/edit/goto/bounce, since which one you use determines the effect type.

class SkillCombination:
1801class SkillCombination:
1802    """
1803    Represents which skill(s) are used for a `Challenge`, including under
1804    what circumstances different skills might apply using
1805    `Requirement`s. This is an abstract class, use the subclasses
1806    `BestSkill`, `WorstSkill`, `CombinedSkill`, `InverseSkill`, and/or
1807    `ConditionalSkill` to represent a specific situation. To represent a
1808    single required skill, use a `BestSkill` or `CombinedSkill` with
1809    that skill as the only skill.
1810
1811    Use `SkillCombination.effectiveLevel` to figure out the effective
1812    level of the entire requirement in a given situation. Note that
1813    levels from the common and active `FocalContext`s are added together
1814    whenever a specific skill level is referenced.
1815
1816    Some examples:
1817
1818    >>> from . import core
1819    >>> ctx = RequirementContext(emptyState(), core.DecisionGraph(), set())
1820    >>> ctx.state['common']['capabilities']['skills']['brawn'] = 1
1821    >>> ctx.state['common']['capabilities']['skills']['brains'] = 3
1822    >>> ctx.state['common']['capabilities']['skills']['luck'] = -1
1823
1824    1. To represent using just the 'brains' skill, you would use:
1825
1826        `BestSkill('brains')`
1827
1828        >>> sr = BestSkill('brains')
1829        >>> sr.effectiveLevel(ctx)
1830        3
1831
1832        If a skill isn't listed, its level counts as 0:
1833
1834        >>> sr = BestSkill('agility')
1835        >>> sr.effectiveLevel(ctx)
1836        0
1837
1838        To represent using the higher of 'brains' or 'brawn' you'd use:
1839
1840        `BestSkill('brains', 'brawn')`
1841
1842        >>> sr = BestSkill('brains', 'brawn')
1843        >>> sr.effectiveLevel(ctx)
1844        3
1845
1846        The zero default only applies if an unknown skill is in the mix:
1847
1848        >>> sr = BestSkill('luck')
1849        >>> sr.effectiveLevel(ctx)
1850        -1
1851        >>> sr = BestSkill('luck', 'agility')
1852        >>> sr.effectiveLevel(ctx)
1853        0
1854
1855    2. To represent using the lower of 'brains' or 'brawn' you'd use:
1856
1857        `WorstSkill('brains', 'brawn')`
1858
1859        >>> sr = WorstSkill('brains', 'brawn')
1860        >>> sr.effectiveLevel(ctx)
1861        1
1862
1863    3. To represent using 'brawn' if the focal context has the 'brawny'
1864        capability, but brains if not, use:
1865
1866        ```
1867        ConditionalSkill(
1868            ReqCapability('brawny'),
1869            'brawn',
1870            'brains'
1871        )
1872        ```
1873
1874        >>> sr = ConditionalSkill(
1875        ...     ReqCapability('brawny'),
1876        ...     'brawn',
1877        ...     'brains'
1878        ... )
1879        >>> sr.effectiveLevel(ctx)
1880        3
1881        >>> brawny = copy.deepcopy(ctx)
1882        >>> brawny.state['common']['capabilities']['capabilities'].add(
1883        ...     'brawny'
1884        ... )
1885        >>> sr.effectiveLevel(brawny)
1886        1
1887
1888        If the player can still choose to use 'brains' even when they
1889        have the 'brawny' capability, you would do:
1890
1891        >>> sr = ConditionalSkill(
1892        ...     ReqCapability('brawny'),
1893        ...     BestSkill('brawn', 'brains'),
1894        ...     'brains'
1895        ... )
1896        >>> sr.effectiveLevel(ctx)
1897        3
1898        >>> sr.effectiveLevel(brawny)  # can still use brains if better
1899        3
1900
1901    4. To represent using the combined level of the 'brains' and
1902        'brawn' skills, you would use:
1903
1904        `CombinedSkill('brains', 'brawn')`
1905
1906        >>> sr = CombinedSkill('brains', 'brawn')
1907        >>> sr.effectiveLevel(ctx)
1908        4
1909
1910    5. Skill names can be replaced by entire sub-`SkillCombination`s in
1911        any position, so more complex forms are possible:
1912
1913        >>> sr = BestSkill(CombinedSkill('brains', 'luck'), 'brawn')
1914        >>> sr.effectiveLevel(ctx)
1915        2
1916        >>> sr = BestSkill(
1917        ...     ConditionalSkill(
1918        ...         ReqCapability('brawny'),
1919        ...         'brawn',
1920        ...         'brains',
1921        ...     ),
1922        ...     CombinedSkill('brains', 'luck')
1923        ... )
1924        >>> sr.effectiveLevel(ctx)
1925        3
1926        >>> sr.effectiveLevel(brawny)
1927        2
1928    """
1929    def effectiveLevel(self, context: 'RequirementContext') -> Level:
1930        """
1931        Returns the effective `Level` of the skill combination, given
1932        the situation specified by the provided `RequirementContext`.
1933        """
1934        raise NotImplementedError(
1935            "SkillCombination is an abstract class. Use one of its"
1936            " subclsases instead."
1937        )
1938
1939    def __eq__(self, other: Any) -> bool:
1940        raise NotImplementedError(
1941            "SkillCombination is an abstract class and cannot be compared."
1942        )
1943
1944    def __hash__(self) -> int:
1945        raise NotImplementedError(
1946            "SkillCombination is an abstract class and cannot be hashed."
1947        )
1948
1949    def walk(self) -> Generator[
1950        Union['SkillCombination', Skill, Level],
1951        None,
1952        None
1953    ]:
1954        """
1955        Yields this combination and each sub-part in depth-first
1956        traversal order.
1957        """
1958        raise NotImplementedError(
1959            "SkillCombination is an abstract class and cannot be walked."
1960        )
1961
1962    def unparse(self) -> str:
1963        """
1964        Returns a string that `SkillCombination.parse` would turn back
1965        into a `SkillCombination` equivalent to this one. For example:
1966
1967        >>> BestSkill('brains').unparse()
1968        'best(brains)'
1969        >>> WorstSkill('brains', 'brawn').unparse()
1970        'worst(brains, brawn)'
1971        >>> CombinedSkill(
1972        ...     ConditionalSkill(ReqTokens('orb', 3), 'brains', 0),
1973        ...     InverseSkill('luck')
1974        ... ).unparse()
1975        'sum(if(orb*3, brains, 0), ~luck)'
1976        """
1977        raise NotImplementedError(
1978            "SkillCombination is an abstract class and cannot be"
1979            " unparsed."
1980        )

Represents which skill(s) are used for a Challenge, including under what circumstances different skills might apply using Requirements. This is an abstract class, use the subclasses BestSkill, WorstSkill, CombinedSkill, InverseSkill, and/or ConditionalSkill to represent a specific situation. To represent a single required skill, use a BestSkill or CombinedSkill with that skill as the only skill.

Use SkillCombination.effectiveLevel to figure out the effective level of the entire requirement in a given situation. Note that levels from the common and active FocalContexts are added together whenever a specific skill level is referenced.

Some examples:

>>> from . import core
>>> ctx = RequirementContext(emptyState(), core.DecisionGraph(), set())
>>> ctx.state['common']['capabilities']['skills']['brawn'] = 1
>>> ctx.state['common']['capabilities']['skills']['brains'] = 3
>>> ctx.state['common']['capabilities']['skills']['luck'] = -1
  1. To represent using just the 'brains' skill, you would use:

    BestSkill('brains')

    >>> sr = BestSkill('brains')
    >>> sr.effectiveLevel(ctx)
    3
    

    If a skill isn't listed, its level counts as 0:

    >>> sr = BestSkill('agility')
    >>> sr.effectiveLevel(ctx)
    0
    

    To represent using the higher of 'brains' or 'brawn' you'd use:

    BestSkill('brains', 'brawn')

    >>> sr = BestSkill('brains', 'brawn')
    >>> sr.effectiveLevel(ctx)
    3
    

    The zero default only applies if an unknown skill is in the mix:

    >>> sr = BestSkill('luck')
    >>> sr.effectiveLevel(ctx)
    -1
    >>> sr = BestSkill('luck', 'agility')
    >>> sr.effectiveLevel(ctx)
    0
    
  2. To represent using the lower of 'brains' or 'brawn' you'd use:

    WorstSkill('brains', 'brawn')

    >>> sr = WorstSkill('brains', 'brawn')
    >>> sr.effectiveLevel(ctx)
    1
    
  3. To represent using 'brawn' if the focal context has the 'brawny' capability, but brains if not, use:

    ConditionalSkill(
        ReqCapability('brawny'),
        'brawn',
        'brains'
    )
    
    >>> sr = ConditionalSkill(
    ...     ReqCapability('brawny'),
    ...     'brawn',
    ...     'brains'
    ... )
    >>> sr.effectiveLevel(ctx)
    3
    >>> brawny = copy.deepcopy(ctx)
    >>> brawny.state['common']['capabilities']['capabilities'].add(
    ...     'brawny'
    ... )
    >>> sr.effectiveLevel(brawny)
    1
    

    If the player can still choose to use 'brains' even when they have the 'brawny' capability, you would do:

    >>> sr = ConditionalSkill(
    ...     ReqCapability('brawny'),
    ...     BestSkill('brawn', 'brains'),
    ...     'brains'
    ... )
    >>> sr.effectiveLevel(ctx)
    3
    >>> sr.effectiveLevel(brawny)  # can still use brains if better
    3
    
  4. To represent using the combined level of the 'brains' and 'brawn' skills, you would use:

    CombinedSkill('brains', 'brawn')

    >>> sr = CombinedSkill('brains', 'brawn')
    >>> sr.effectiveLevel(ctx)
    4
    
  5. Skill names can be replaced by entire sub-SkillCombinations in any position, so more complex forms are possible:

    >>> sr = BestSkill(CombinedSkill('brains', 'luck'), 'brawn')
    >>> sr.effectiveLevel(ctx)
    2
    >>> sr = BestSkill(
    ...     ConditionalSkill(
    ...         ReqCapability('brawny'),
    ...         'brawn',
    ...         'brains',
    ...     ),
    ...     CombinedSkill('brains', 'luck')
    ... )
    >>> sr.effectiveLevel(ctx)
    3
    >>> sr.effectiveLevel(brawny)
    2
    
def effectiveLevel(self, context: RequirementContext) -> int:
1929    def effectiveLevel(self, context: 'RequirementContext') -> Level:
1930        """
1931        Returns the effective `Level` of the skill combination, given
1932        the situation specified by the provided `RequirementContext`.
1933        """
1934        raise NotImplementedError(
1935            "SkillCombination is an abstract class. Use one of its"
1936            " subclsases instead."
1937        )

Returns the effective Level of the skill combination, given the situation specified by the provided RequirementContext.

def walk( self) -> Generator[Union[SkillCombination, str, int], NoneType, NoneType]:
1949    def walk(self) -> Generator[
1950        Union['SkillCombination', Skill, Level],
1951        None,
1952        None
1953    ]:
1954        """
1955        Yields this combination and each sub-part in depth-first
1956        traversal order.
1957        """
1958        raise NotImplementedError(
1959            "SkillCombination is an abstract class and cannot be walked."
1960        )

Yields this combination and each sub-part in depth-first traversal order.

def unparse(self) -> str:
1962    def unparse(self) -> str:
1963        """
1964        Returns a string that `SkillCombination.parse` would turn back
1965        into a `SkillCombination` equivalent to this one. For example:
1966
1967        >>> BestSkill('brains').unparse()
1968        'best(brains)'
1969        >>> WorstSkill('brains', 'brawn').unparse()
1970        'worst(brains, brawn)'
1971        >>> CombinedSkill(
1972        ...     ConditionalSkill(ReqTokens('orb', 3), 'brains', 0),
1973        ...     InverseSkill('luck')
1974        ... ).unparse()
1975        'sum(if(orb*3, brains, 0), ~luck)'
1976        """
1977        raise NotImplementedError(
1978            "SkillCombination is an abstract class and cannot be"
1979            " unparsed."
1980        )

Returns a string that SkillCombination.parse would turn back into a SkillCombination equivalent to this one. For example:

>>> BestSkill('brains').unparse()
'best(brains)'
>>> WorstSkill('brains', 'brawn').unparse()
'worst(brains, brawn)'
>>> CombinedSkill(
...     ConditionalSkill(ReqTokens('orb', 3), 'brains', 0),
...     InverseSkill('luck')
... ).unparse()
'sum(if(orb*3, brains, 0), ~luck)'
class BestSkill(SkillCombination):
1983class BestSkill(SkillCombination):
1984    def __init__(
1985        self,
1986        *skills: Union[SkillCombination, Skill, Level]
1987    ):
1988        """
1989        Given one or more `SkillCombination` sub-items and/or skill
1990        names or levels, represents a situation where the highest
1991        effective level among the sub-items is used. Skill names
1992        translate to the player's level for that skill (with 0 as a
1993        default) while level numbers translate to that number.
1994        """
1995        if len(skills) == 0:
1996            raise ValueError(
1997                "Cannot create a `BestSkill` with 0 sub-skills."
1998            )
1999        self.skills = skills
2000
2001    def __eq__(self, other: Any) -> bool:
2002        return isinstance(other, BestSkill) and other.skills == self.skills
2003
2004    def __hash__(self) -> int:
2005        result = 1829
2006        for sk in self.skills:
2007            result += hash(sk)
2008        return result
2009
2010    def __repr__(self) -> str:
2011        subs = ', '.join(repr(sk) for sk in self.skills)
2012        return "BestSkill(" + subs + ")"
2013
2014    def walk(self) -> Generator[
2015        Union[SkillCombination, Skill, Level],
2016        None,
2017        None
2018    ]:
2019        yield self
2020        for sub in self.skills:
2021            if isinstance(sub, (Skill, Level)):
2022                yield sub
2023            else:
2024                yield from sub.walk()
2025
2026    def effectiveLevel(self, ctx: 'RequirementContext') -> Level:
2027        """
2028        Determines the effective level of each sub-skill-combo and
2029        returns the highest of those.
2030        """
2031        result = None
2032        level: Level
2033        if len(self.skills) == 0:
2034            raise RuntimeError("Invalid BestSkill: has zero sub-skills.")
2035        for sk in self.skills:
2036            if isinstance(sk, Level):
2037                level = sk
2038            elif isinstance(sk, Skill):
2039                level = getSkillLevel(ctx.state, sk)
2040            elif isinstance(sk, SkillCombination):
2041                level = sk.effectiveLevel(ctx)
2042            else:
2043                raise RuntimeError(
2044                    f"Invalid BestSkill: found sub-skill '{repr(sk)}'"
2045                    f" which is not a skill name string, level integer,"
2046                    f" or SkillCombination."
2047                )
2048            if result is None or result < level:
2049                result = level
2050
2051        assert result is not None
2052        return result
2053
2054    def unparse(self):
2055        result = "best("
2056        for sk in self.skills:
2057            if isinstance(sk, SkillCombination):
2058                result += sk.unparse()
2059            else:
2060                result += str(sk)
2061            result += ', '
2062        return result[:-2] + ')'

Represents which skill(s) are used for a Challenge, including under what circumstances different skills might apply using Requirements. This is an abstract class, use the subclasses BestSkill, WorstSkill, CombinedSkill, InverseSkill, and/or ConditionalSkill to represent a specific situation. To represent a single required skill, use a BestSkill or CombinedSkill with that skill as the only skill.

Use SkillCombination.effectiveLevel to figure out the effective level of the entire requirement in a given situation. Note that levels from the common and active FocalContexts are added together whenever a specific skill level is referenced.

Some examples:

>>> from . import core
>>> ctx = RequirementContext(emptyState(), core.DecisionGraph(), set())
>>> ctx.state['common']['capabilities']['skills']['brawn'] = 1
>>> ctx.state['common']['capabilities']['skills']['brains'] = 3
>>> ctx.state['common']['capabilities']['skills']['luck'] = -1
  1. To represent using just the 'brains' skill, you would use:

    BestSkill('brains')

    >>> sr = BestSkill('brains')
    >>> sr.effectiveLevel(ctx)
    3
    

    If a skill isn't listed, its level counts as 0:

    >>> sr = BestSkill('agility')
    >>> sr.effectiveLevel(ctx)
    0
    

    To represent using the higher of 'brains' or 'brawn' you'd use:

    BestSkill('brains', 'brawn')

    >>> sr = BestSkill('brains', 'brawn')
    >>> sr.effectiveLevel(ctx)
    3
    

    The zero default only applies if an unknown skill is in the mix:

    >>> sr = BestSkill('luck')
    >>> sr.effectiveLevel(ctx)
    -1
    >>> sr = BestSkill('luck', 'agility')
    >>> sr.effectiveLevel(ctx)
    0
    
  2. To represent using the lower of 'brains' or 'brawn' you'd use:

    WorstSkill('brains', 'brawn')

    >>> sr = WorstSkill('brains', 'brawn')
    >>> sr.effectiveLevel(ctx)
    1
    
  3. To represent using 'brawn' if the focal context has the 'brawny' capability, but brains if not, use:

    ConditionalSkill(
        ReqCapability('brawny'),
        'brawn',
        'brains'
    )
    
    >>> sr = ConditionalSkill(
    ...     ReqCapability('brawny'),
    ...     'brawn',
    ...     'brains'
    ... )
    >>> sr.effectiveLevel(ctx)
    3
    >>> brawny = copy.deepcopy(ctx)
    >>> brawny.state['common']['capabilities']['capabilities'].add(
    ...     'brawny'
    ... )
    >>> sr.effectiveLevel(brawny)
    1
    

    If the player can still choose to use 'brains' even when they have the 'brawny' capability, you would do:

    >>> sr = ConditionalSkill(
    ...     ReqCapability('brawny'),
    ...     BestSkill('brawn', 'brains'),
    ...     'brains'
    ... )
    >>> sr.effectiveLevel(ctx)
    3
    >>> sr.effectiveLevel(brawny)  # can still use brains if better
    3
    
  4. To represent using the combined level of the 'brains' and 'brawn' skills, you would use:

    CombinedSkill('brains', 'brawn')

    >>> sr = CombinedSkill('brains', 'brawn')
    >>> sr.effectiveLevel(ctx)
    4
    
  5. Skill names can be replaced by entire sub-SkillCombinations in any position, so more complex forms are possible:

    >>> sr = BestSkill(CombinedSkill('brains', 'luck'), 'brawn')
    >>> sr.effectiveLevel(ctx)
    2
    >>> sr = BestSkill(
    ...     ConditionalSkill(
    ...         ReqCapability('brawny'),
    ...         'brawn',
    ...         'brains',
    ...     ),
    ...     CombinedSkill('brains', 'luck')
    ... )
    >>> sr.effectiveLevel(ctx)
    3
    >>> sr.effectiveLevel(brawny)
    2
    
BestSkill(*skills: Union[SkillCombination, str, int])
1984    def __init__(
1985        self,
1986        *skills: Union[SkillCombination, Skill, Level]
1987    ):
1988        """
1989        Given one or more `SkillCombination` sub-items and/or skill
1990        names or levels, represents a situation where the highest
1991        effective level among the sub-items is used. Skill names
1992        translate to the player's level for that skill (with 0 as a
1993        default) while level numbers translate to that number.
1994        """
1995        if len(skills) == 0:
1996            raise ValueError(
1997                "Cannot create a `BestSkill` with 0 sub-skills."
1998            )
1999        self.skills = skills

Given one or more SkillCombination sub-items and/or skill names or levels, represents a situation where the highest effective level among the sub-items is used. Skill names translate to the player's level for that skill (with 0 as a default) while level numbers translate to that number.

skills
def walk( self) -> Generator[Union[SkillCombination, str, int], NoneType, NoneType]:
2014    def walk(self) -> Generator[
2015        Union[SkillCombination, Skill, Level],
2016        None,
2017        None
2018    ]:
2019        yield self
2020        for sub in self.skills:
2021            if isinstance(sub, (Skill, Level)):
2022                yield sub
2023            else:
2024                yield from sub.walk()

Yields this combination and each sub-part in depth-first traversal order.

def effectiveLevel(self, ctx: RequirementContext) -> int:
2026    def effectiveLevel(self, ctx: 'RequirementContext') -> Level:
2027        """
2028        Determines the effective level of each sub-skill-combo and
2029        returns the highest of those.
2030        """
2031        result = None
2032        level: Level
2033        if len(self.skills) == 0:
2034            raise RuntimeError("Invalid BestSkill: has zero sub-skills.")
2035        for sk in self.skills:
2036            if isinstance(sk, Level):
2037                level = sk
2038            elif isinstance(sk, Skill):
2039                level = getSkillLevel(ctx.state, sk)
2040            elif isinstance(sk, SkillCombination):
2041                level = sk.effectiveLevel(ctx)
2042            else:
2043                raise RuntimeError(
2044                    f"Invalid BestSkill: found sub-skill '{repr(sk)}'"
2045                    f" which is not a skill name string, level integer,"
2046                    f" or SkillCombination."
2047                )
2048            if result is None or result < level:
2049                result = level
2050
2051        assert result is not None
2052        return result

Determines the effective level of each sub-skill-combo and returns the highest of those.

def unparse(self):
2054    def unparse(self):
2055        result = "best("
2056        for sk in self.skills:
2057            if isinstance(sk, SkillCombination):
2058                result += sk.unparse()
2059            else:
2060                result += str(sk)
2061            result += ', '
2062        return result[:-2] + ')'

Returns a string that SkillCombination.parse would turn back into a SkillCombination equivalent to this one. For example:

>>> BestSkill('brains').unparse()
'best(brains)'
>>> WorstSkill('brains', 'brawn').unparse()
'worst(brains, brawn)'
>>> CombinedSkill(
...     ConditionalSkill(ReqTokens('orb', 3), 'brains', 0),
...     InverseSkill('luck')
... ).unparse()
'sum(if(orb*3, brains, 0), ~luck)'
class WorstSkill(SkillCombination):
2065class WorstSkill(SkillCombination):
2066    def __init__(
2067        self,
2068        *skills: Union[SkillCombination, Skill, Level]
2069    ):
2070        """
2071        Given one or more `SkillCombination` sub-items and/or skill
2072        names or levels, represents a situation where the lowest
2073        effective level among the sub-items is used. Skill names
2074        translate to the player's level for that skill (with 0 as a
2075        default) while level numbers translate to that number.
2076        """
2077        if len(skills) == 0:
2078            raise ValueError(
2079                "Cannot create a `WorstSkill` with 0 sub-skills."
2080            )
2081        self.skills = skills
2082
2083    def __eq__(self, other: Any) -> bool:
2084        return isinstance(other, WorstSkill) and other.skills == self.skills
2085
2086    def __hash__(self) -> int:
2087        result = 7182
2088        for sk in self.skills:
2089            result += hash(sk)
2090        return result
2091
2092    def __repr__(self) -> str:
2093        subs = ', '.join(repr(sk) for sk in self.skills)
2094        return "WorstSkill(" + subs + ")"
2095
2096    def walk(self) -> Generator[
2097        Union[SkillCombination, Skill, Level],
2098        None,
2099        None
2100    ]:
2101        yield self
2102        for sub in self.skills:
2103            if isinstance(sub, (Skill, Level)):
2104                yield sub
2105            else:
2106                yield from sub.walk()
2107
2108    def effectiveLevel(self, ctx: 'RequirementContext') -> Level:
2109        """
2110        Determines the effective level of each sub-skill-combo and
2111        returns the lowest of those.
2112        """
2113        result = None
2114        level: Level
2115        if len(self.skills) == 0:
2116            raise RuntimeError("Invalid WorstSkill: has zero sub-skills.")
2117        for sk in self.skills:
2118            if isinstance(sk, Level):
2119                level = sk
2120            elif isinstance(sk, Skill):
2121                level = getSkillLevel(ctx.state, sk)
2122            elif isinstance(sk, SkillCombination):
2123                level = sk.effectiveLevel(ctx)
2124            else:
2125                raise RuntimeError(
2126                    f"Invalid WorstSkill: found sub-skill '{repr(sk)}'"
2127                    f" which is not a skill name string, level integer,"
2128                    f" or SkillCombination."
2129                )
2130            if result is None or result > level:
2131                result = level
2132
2133        assert result is not None
2134        return result
2135
2136    def unparse(self):
2137        result = "worst("
2138        for sk in self.skills:
2139            if isinstance(sk, SkillCombination):
2140                result += sk.unparse()
2141            else:
2142                result += str(sk)
2143            result += ', '
2144        return result[:-2] + ')'

Represents which skill(s) are used for a Challenge, including under what circumstances different skills might apply using Requirements. This is an abstract class, use the subclasses BestSkill, WorstSkill, CombinedSkill, InverseSkill, and/or ConditionalSkill to represent a specific situation. To represent a single required skill, use a BestSkill or CombinedSkill with that skill as the only skill.

Use SkillCombination.effectiveLevel to figure out the effective level of the entire requirement in a given situation. Note that levels from the common and active FocalContexts are added together whenever a specific skill level is referenced.

Some examples:

>>> from . import core
>>> ctx = RequirementContext(emptyState(), core.DecisionGraph(), set())
>>> ctx.state['common']['capabilities']['skills']['brawn'] = 1
>>> ctx.state['common']['capabilities']['skills']['brains'] = 3
>>> ctx.state['common']['capabilities']['skills']['luck'] = -1
  1. To represent using just the 'brains' skill, you would use:

    BestSkill('brains')

    >>> sr = BestSkill('brains')
    >>> sr.effectiveLevel(ctx)
    3
    

    If a skill isn't listed, its level counts as 0:

    >>> sr = BestSkill('agility')
    >>> sr.effectiveLevel(ctx)
    0
    

    To represent using the higher of 'brains' or 'brawn' you'd use:

    BestSkill('brains', 'brawn')

    >>> sr = BestSkill('brains', 'brawn')
    >>> sr.effectiveLevel(ctx)
    3
    

    The zero default only applies if an unknown skill is in the mix:

    >>> sr = BestSkill('luck')
    >>> sr.effectiveLevel(ctx)
    -1
    >>> sr = BestSkill('luck', 'agility')
    >>> sr.effectiveLevel(ctx)
    0
    
  2. To represent using the lower of 'brains' or 'brawn' you'd use:

    WorstSkill('brains', 'brawn')

    >>> sr = WorstSkill('brains', 'brawn')
    >>> sr.effectiveLevel(ctx)
    1
    
  3. To represent using 'brawn' if the focal context has the 'brawny' capability, but brains if not, use:

    ConditionalSkill(
        ReqCapability('brawny'),
        'brawn',
        'brains'
    )
    
    >>> sr = ConditionalSkill(
    ...     ReqCapability('brawny'),
    ...     'brawn',
    ...     'brains'
    ... )
    >>> sr.effectiveLevel(ctx)
    3
    >>> brawny = copy.deepcopy(ctx)
    >>> brawny.state['common']['capabilities']['capabilities'].add(
    ...     'brawny'
    ... )
    >>> sr.effectiveLevel(brawny)
    1
    

    If the player can still choose to use 'brains' even when they have the 'brawny' capability, you would do:

    >>> sr = ConditionalSkill(
    ...     ReqCapability('brawny'),
    ...     BestSkill('brawn', 'brains'),
    ...     'brains'
    ... )
    >>> sr.effectiveLevel(ctx)
    3
    >>> sr.effectiveLevel(brawny)  # can still use brains if better
    3
    
  4. To represent using the combined level of the 'brains' and 'brawn' skills, you would use:

    CombinedSkill('brains', 'brawn')

    >>> sr = CombinedSkill('brains', 'brawn')
    >>> sr.effectiveLevel(ctx)
    4
    
  5. Skill names can be replaced by entire sub-SkillCombinations in any position, so more complex forms are possible:

    >>> sr = BestSkill(CombinedSkill('brains', 'luck'), 'brawn')
    >>> sr.effectiveLevel(ctx)
    2
    >>> sr = BestSkill(
    ...     ConditionalSkill(
    ...         ReqCapability('brawny'),
    ...         'brawn',
    ...         'brains',
    ...     ),
    ...     CombinedSkill('brains', 'luck')
    ... )
    >>> sr.effectiveLevel(ctx)
    3
    >>> sr.effectiveLevel(brawny)
    2
    
WorstSkill(*skills: Union[SkillCombination, str, int])
2066    def __init__(
2067        self,
2068        *skills: Union[SkillCombination, Skill, Level]
2069    ):
2070        """
2071        Given one or more `SkillCombination` sub-items and/or skill
2072        names or levels, represents a situation where the lowest
2073        effective level among the sub-items is used. Skill names
2074        translate to the player's level for that skill (with 0 as a
2075        default) while level numbers translate to that number.
2076        """
2077        if len(skills) == 0:
2078            raise ValueError(
2079                "Cannot create a `WorstSkill` with 0 sub-skills."
2080            )
2081        self.skills = skills

Given one or more SkillCombination sub-items and/or skill names or levels, represents a situation where the lowest effective level among the sub-items is used. Skill names translate to the player's level for that skill (with 0 as a default) while level numbers translate to that number.

skills
def walk( self) -> Generator[Union[SkillCombination, str, int], NoneType, NoneType]:
2096    def walk(self) -> Generator[
2097        Union[SkillCombination, Skill, Level],
2098        None,
2099        None
2100    ]:
2101        yield self
2102        for sub in self.skills:
2103            if isinstance(sub, (Skill, Level)):
2104                yield sub
2105            else:
2106                yield from sub.walk()

Yields this combination and each sub-part in depth-first traversal order.

def effectiveLevel(self, ctx: RequirementContext) -> int:
2108    def effectiveLevel(self, ctx: 'RequirementContext') -> Level:
2109        """
2110        Determines the effective level of each sub-skill-combo and
2111        returns the lowest of those.
2112        """
2113        result = None
2114        level: Level
2115        if len(self.skills) == 0:
2116            raise RuntimeError("Invalid WorstSkill: has zero sub-skills.")
2117        for sk in self.skills:
2118            if isinstance(sk, Level):
2119                level = sk
2120            elif isinstance(sk, Skill):
2121                level = getSkillLevel(ctx.state, sk)
2122            elif isinstance(sk, SkillCombination):
2123                level = sk.effectiveLevel(ctx)
2124            else:
2125                raise RuntimeError(
2126                    f"Invalid WorstSkill: found sub-skill '{repr(sk)}'"
2127                    f" which is not a skill name string, level integer,"
2128                    f" or SkillCombination."
2129                )
2130            if result is None or result > level:
2131                result = level
2132
2133        assert result is not None
2134        return result

Determines the effective level of each sub-skill-combo and returns the lowest of those.

def unparse(self):
2136    def unparse(self):
2137        result = "worst("
2138        for sk in self.skills:
2139            if isinstance(sk, SkillCombination):
2140                result += sk.unparse()
2141            else:
2142                result += str(sk)
2143            result += ', '
2144        return result[:-2] + ')'

Returns a string that SkillCombination.parse would turn back into a SkillCombination equivalent to this one. For example:

>>> BestSkill('brains').unparse()
'best(brains)'
>>> WorstSkill('brains', 'brawn').unparse()
'worst(brains, brawn)'
>>> CombinedSkill(
...     ConditionalSkill(ReqTokens('orb', 3), 'brains', 0),
...     InverseSkill('luck')
... ).unparse()
'sum(if(orb*3, brains, 0), ~luck)'
class CombinedSkill(SkillCombination):
2147class CombinedSkill(SkillCombination):
2148    def __init__(
2149        self,
2150        *skills: Union[SkillCombination, Skill, Level]
2151    ):
2152        """
2153        Given one or more `SkillCombination` sub-items and/or skill
2154        names or levels, represents a situation where the sum of the
2155        effective levels of each sub-item is used. Skill names
2156        translate to the player's level for that skill (with 0 as a
2157        default) while level numbers translate to that number.
2158        """
2159        if len(skills) == 0:
2160            raise ValueError(
2161                "Cannot create a `CombinedSkill` with 0 sub-skills."
2162            )
2163        self.skills = skills
2164
2165    def __eq__(self, other: Any) -> bool:
2166        return (
2167            isinstance(other, CombinedSkill)
2168        and other.skills == self.skills
2169        )
2170
2171    def __hash__(self) -> int:
2172        result = 2871
2173        for sk in self.skills:
2174            result += hash(sk)
2175        return result
2176
2177    def __repr__(self) -> str:
2178        subs = ', '.join(repr(sk) for sk in self.skills)
2179        return "CombinedSkill(" + subs + ")"
2180
2181    def walk(self) -> Generator[
2182        Union[SkillCombination, Skill, Level],
2183        None,
2184        None
2185    ]:
2186        yield self
2187        for sub in self.skills:
2188            if isinstance(sub, (Skill, Level)):
2189                yield sub
2190            else:
2191                yield from sub.walk()
2192
2193    def effectiveLevel(self, ctx: 'RequirementContext') -> Level:
2194        """
2195        Determines the effective level of each sub-skill-combo and
2196        returns the sum of those, with 0 as a default.
2197        """
2198        result = 0
2199        level: Level
2200        if len(self.skills) == 0:
2201            raise RuntimeError(
2202                "Invalid CombinedSkill: has zero sub-skills."
2203            )
2204        for sk in self.skills:
2205            if isinstance(sk, Level):
2206                level = sk
2207            elif isinstance(sk, Skill):
2208                level = getSkillLevel(ctx.state, sk)
2209            elif isinstance(sk, SkillCombination):
2210                level = sk.effectiveLevel(ctx)
2211            else:
2212                raise RuntimeError(
2213                    f"Invalid CombinedSkill: found sub-skill '{repr(sk)}'"
2214                    f" which is not a skill name string, level integer,"
2215                    f" or SkillCombination."
2216                )
2217            result += level
2218
2219        assert result is not None
2220        return result
2221
2222    def unparse(self):
2223        result = "sum("
2224        for sk in self.skills:
2225            if isinstance(sk, SkillCombination):
2226                result += sk.unparse()
2227            else:
2228                result += str(sk)
2229            result += ', '
2230        return result[:-2] + ')'

Represents which skill(s) are used for a Challenge, including under what circumstances different skills might apply using Requirements. This is an abstract class, use the subclasses BestSkill, WorstSkill, CombinedSkill, InverseSkill, and/or ConditionalSkill to represent a specific situation. To represent a single required skill, use a BestSkill or CombinedSkill with that skill as the only skill.

Use SkillCombination.effectiveLevel to figure out the effective level of the entire requirement in a given situation. Note that levels from the common and active FocalContexts are added together whenever a specific skill level is referenced.

Some examples:

>>> from . import core
>>> ctx = RequirementContext(emptyState(), core.DecisionGraph(), set())
>>> ctx.state['common']['capabilities']['skills']['brawn'] = 1
>>> ctx.state['common']['capabilities']['skills']['brains'] = 3
>>> ctx.state['common']['capabilities']['skills']['luck'] = -1
  1. To represent using just the 'brains' skill, you would use:

    BestSkill('brains')

    >>> sr = BestSkill('brains')
    >>> sr.effectiveLevel(ctx)
    3
    

    If a skill isn't listed, its level counts as 0:

    >>> sr = BestSkill('agility')
    >>> sr.effectiveLevel(ctx)
    0
    

    To represent using the higher of 'brains' or 'brawn' you'd use:

    BestSkill('brains', 'brawn')

    >>> sr = BestSkill('brains', 'brawn')
    >>> sr.effectiveLevel(ctx)
    3
    

    The zero default only applies if an unknown skill is in the mix:

    >>> sr = BestSkill('luck')
    >>> sr.effectiveLevel(ctx)
    -1
    >>> sr = BestSkill('luck', 'agility')
    >>> sr.effectiveLevel(ctx)
    0
    
  2. To represent using the lower of 'brains' or 'brawn' you'd use:

    WorstSkill('brains', 'brawn')

    >>> sr = WorstSkill('brains', 'brawn')
    >>> sr.effectiveLevel(ctx)
    1
    
  3. To represent using 'brawn' if the focal context has the 'brawny' capability, but brains if not, use:

    ConditionalSkill(
        ReqCapability('brawny'),
        'brawn',
        'brains'
    )
    
    >>> sr = ConditionalSkill(
    ...     ReqCapability('brawny'),
    ...     'brawn',
    ...     'brains'
    ... )
    >>> sr.effectiveLevel(ctx)
    3
    >>> brawny = copy.deepcopy(ctx)
    >>> brawny.state['common']['capabilities']['capabilities'].add(
    ...     'brawny'
    ... )
    >>> sr.effectiveLevel(brawny)
    1
    

    If the player can still choose to use 'brains' even when they have the 'brawny' capability, you would do:

    >>> sr = ConditionalSkill(
    ...     ReqCapability('brawny'),
    ...     BestSkill('brawn', 'brains'),
    ...     'brains'
    ... )
    >>> sr.effectiveLevel(ctx)
    3
    >>> sr.effectiveLevel(brawny)  # can still use brains if better
    3
    
  4. To represent using the combined level of the 'brains' and 'brawn' skills, you would use:

    CombinedSkill('brains', 'brawn')

    >>> sr = CombinedSkill('brains', 'brawn')
    >>> sr.effectiveLevel(ctx)
    4
    
  5. Skill names can be replaced by entire sub-SkillCombinations in any position, so more complex forms are possible:

    >>> sr = BestSkill(CombinedSkill('brains', 'luck'), 'brawn')
    >>> sr.effectiveLevel(ctx)
    2
    >>> sr = BestSkill(
    ...     ConditionalSkill(
    ...         ReqCapability('brawny'),
    ...         'brawn',
    ...         'brains',
    ...     ),
    ...     CombinedSkill('brains', 'luck')
    ... )
    >>> sr.effectiveLevel(ctx)
    3
    >>> sr.effectiveLevel(brawny)
    2
    
CombinedSkill(*skills: Union[SkillCombination, str, int])
2148    def __init__(
2149        self,
2150        *skills: Union[SkillCombination, Skill, Level]
2151    ):
2152        """
2153        Given one or more `SkillCombination` sub-items and/or skill
2154        names or levels, represents a situation where the sum of the
2155        effective levels of each sub-item is used. Skill names
2156        translate to the player's level for that skill (with 0 as a
2157        default) while level numbers translate to that number.
2158        """
2159        if len(skills) == 0:
2160            raise ValueError(
2161                "Cannot create a `CombinedSkill` with 0 sub-skills."
2162            )
2163        self.skills = skills

Given one or more SkillCombination sub-items and/or skill names or levels, represents a situation where the sum of the effective levels of each sub-item is used. Skill names translate to the player's level for that skill (with 0 as a default) while level numbers translate to that number.

skills
def walk( self) -> Generator[Union[SkillCombination, str, int], NoneType, NoneType]:
2181    def walk(self) -> Generator[
2182        Union[SkillCombination, Skill, Level],
2183        None,
2184        None
2185    ]:
2186        yield self
2187        for sub in self.skills:
2188            if isinstance(sub, (Skill, Level)):
2189                yield sub
2190            else:
2191                yield from sub.walk()

Yields this combination and each sub-part in depth-first traversal order.

def effectiveLevel(self, ctx: RequirementContext) -> int:
2193    def effectiveLevel(self, ctx: 'RequirementContext') -> Level:
2194        """
2195        Determines the effective level of each sub-skill-combo and
2196        returns the sum of those, with 0 as a default.
2197        """
2198        result = 0
2199        level: Level
2200        if len(self.skills) == 0:
2201            raise RuntimeError(
2202                "Invalid CombinedSkill: has zero sub-skills."
2203            )
2204        for sk in self.skills:
2205            if isinstance(sk, Level):
2206                level = sk
2207            elif isinstance(sk, Skill):
2208                level = getSkillLevel(ctx.state, sk)
2209            elif isinstance(sk, SkillCombination):
2210                level = sk.effectiveLevel(ctx)
2211            else:
2212                raise RuntimeError(
2213                    f"Invalid CombinedSkill: found sub-skill '{repr(sk)}'"
2214                    f" which is not a skill name string, level integer,"
2215                    f" or SkillCombination."
2216                )
2217            result += level
2218
2219        assert result is not None
2220        return result

Determines the effective level of each sub-skill-combo and returns the sum of those, with 0 as a default.

def unparse(self):
2222    def unparse(self):
2223        result = "sum("
2224        for sk in self.skills:
2225            if isinstance(sk, SkillCombination):
2226                result += sk.unparse()
2227            else:
2228                result += str(sk)
2229            result += ', '
2230        return result[:-2] + ')'

Returns a string that SkillCombination.parse would turn back into a SkillCombination equivalent to this one. For example:

>>> BestSkill('brains').unparse()
'best(brains)'
>>> WorstSkill('brains', 'brawn').unparse()
'worst(brains, brawn)'
>>> CombinedSkill(
...     ConditionalSkill(ReqTokens('orb', 3), 'brains', 0),
...     InverseSkill('luck')
... ).unparse()
'sum(if(orb*3, brains, 0), ~luck)'
class InverseSkill(SkillCombination):
2233class InverseSkill(SkillCombination):
2234    def __init__(
2235        self,
2236        invert: Union[SkillCombination, Skill, Level]
2237    ):
2238        """
2239        Represents the effective level of the given `SkillCombination`,
2240        the level of the given `Skill`, or just the provided specific
2241        `Level`, except inverted (multiplied by -1).
2242        """
2243        self.invert = invert
2244
2245    def __eq__(self, other: Any) -> bool:
2246        return (
2247            isinstance(other, InverseSkill)
2248        and other.invert == self.invert
2249        )
2250
2251    def __hash__(self) -> int:
2252        return 3193 + hash(self.invert)
2253
2254    def __repr__(self) -> str:
2255        return "InverseSkill(" + repr(self.invert) + ")"
2256
2257    def walk(self) -> Generator[
2258        Union[SkillCombination, Skill, Level],
2259        None,
2260        None
2261    ]:
2262        yield self
2263        if isinstance(self.invert, SkillCombination):
2264            yield from self.invert.walk()
2265        else:
2266            yield self.invert
2267
2268    def effectiveLevel(self, ctx: 'RequirementContext') -> Level:
2269        """
2270        Determines whether the requirement is satisfied or not and then
2271        returns the effective level of either the `ifSatisfied` or
2272        `ifNot` skill combination, as appropriate.
2273        """
2274        if isinstance(self.invert, Level):
2275            return -self.invert
2276        elif isinstance(self.invert, Skill):
2277            return -getSkillLevel(ctx.state, self.invert)
2278        elif isinstance(self.invert, SkillCombination):
2279            return -self.invert.effectiveLevel(ctx)
2280        else:
2281            raise RuntimeError(
2282                f"Invalid InverseSkill: invert value {repr(self.invert)}"
2283                f" The invert value must be a Level (int), a Skill"
2284                f" (str), or a SkillCombination."
2285            )
2286
2287    def unparse(self):
2288        # TODO: Move these to `parsing` to avoid hard-coded tokens here?
2289        if isinstance(self.invert, SkillCombination):
2290            return '~' + self.invert.unparse()
2291        else:
2292            return '~' + str(self.invert)

Represents which skill(s) are used for a Challenge, including under what circumstances different skills might apply using Requirements. This is an abstract class, use the subclasses BestSkill, WorstSkill, CombinedSkill, InverseSkill, and/or ConditionalSkill to represent a specific situation. To represent a single required skill, use a BestSkill or CombinedSkill with that skill as the only skill.

Use SkillCombination.effectiveLevel to figure out the effective level of the entire requirement in a given situation. Note that levels from the common and active FocalContexts are added together whenever a specific skill level is referenced.

Some examples:

>>> from . import core
>>> ctx = RequirementContext(emptyState(), core.DecisionGraph(), set())
>>> ctx.state['common']['capabilities']['skills']['brawn'] = 1
>>> ctx.state['common']['capabilities']['skills']['brains'] = 3
>>> ctx.state['common']['capabilities']['skills']['luck'] = -1
  1. To represent using just the 'brains' skill, you would use:

    BestSkill('brains')

    >>> sr = BestSkill('brains')
    >>> sr.effectiveLevel(ctx)
    3
    

    If a skill isn't listed, its level counts as 0:

    >>> sr = BestSkill('agility')
    >>> sr.effectiveLevel(ctx)
    0
    

    To represent using the higher of 'brains' or 'brawn' you'd use:

    BestSkill('brains', 'brawn')

    >>> sr = BestSkill('brains', 'brawn')
    >>> sr.effectiveLevel(ctx)
    3
    

    The zero default only applies if an unknown skill is in the mix:

    >>> sr = BestSkill('luck')
    >>> sr.effectiveLevel(ctx)
    -1
    >>> sr = BestSkill('luck', 'agility')
    >>> sr.effectiveLevel(ctx)
    0
    
  2. To represent using the lower of 'brains' or 'brawn' you'd use:

    WorstSkill('brains', 'brawn')

    >>> sr = WorstSkill('brains', 'brawn')
    >>> sr.effectiveLevel(ctx)
    1
    
  3. To represent using 'brawn' if the focal context has the 'brawny' capability, but brains if not, use:

    ConditionalSkill(
        ReqCapability('brawny'),
        'brawn',
        'brains'
    )
    
    >>> sr = ConditionalSkill(
    ...     ReqCapability('brawny'),
    ...     'brawn',
    ...     'brains'
    ... )
    >>> sr.effectiveLevel(ctx)
    3
    >>> brawny = copy.deepcopy(ctx)
    >>> brawny.state['common']['capabilities']['capabilities'].add(
    ...     'brawny'
    ... )
    >>> sr.effectiveLevel(brawny)
    1
    

    If the player can still choose to use 'brains' even when they have the 'brawny' capability, you would do:

    >>> sr = ConditionalSkill(
    ...     ReqCapability('brawny'),
    ...     BestSkill('brawn', 'brains'),
    ...     'brains'
    ... )
    >>> sr.effectiveLevel(ctx)
    3
    >>> sr.effectiveLevel(brawny)  # can still use brains if better
    3
    
  4. To represent using the combined level of the 'brains' and 'brawn' skills, you would use:

    CombinedSkill('brains', 'brawn')

    >>> sr = CombinedSkill('brains', 'brawn')
    >>> sr.effectiveLevel(ctx)
    4
    
  5. Skill names can be replaced by entire sub-SkillCombinations in any position, so more complex forms are possible:

    >>> sr = BestSkill(CombinedSkill('brains', 'luck'), 'brawn')
    >>> sr.effectiveLevel(ctx)
    2
    >>> sr = BestSkill(
    ...     ConditionalSkill(
    ...         ReqCapability('brawny'),
    ...         'brawn',
    ...         'brains',
    ...     ),
    ...     CombinedSkill('brains', 'luck')
    ... )
    >>> sr.effectiveLevel(ctx)
    3
    >>> sr.effectiveLevel(brawny)
    2
    
InverseSkill(invert: Union[SkillCombination, str, int])
2234    def __init__(
2235        self,
2236        invert: Union[SkillCombination, Skill, Level]
2237    ):
2238        """
2239        Represents the effective level of the given `SkillCombination`,
2240        the level of the given `Skill`, or just the provided specific
2241        `Level`, except inverted (multiplied by -1).
2242        """
2243        self.invert = invert

Represents the effective level of the given SkillCombination, the level of the given Skill, or just the provided specific Level, except inverted (multiplied by -1).

invert
def walk( self) -> Generator[Union[SkillCombination, str, int], NoneType, NoneType]:
2257    def walk(self) -> Generator[
2258        Union[SkillCombination, Skill, Level],
2259        None,
2260        None
2261    ]:
2262        yield self
2263        if isinstance(self.invert, SkillCombination):
2264            yield from self.invert.walk()
2265        else:
2266            yield self.invert

Yields this combination and each sub-part in depth-first traversal order.

def effectiveLevel(self, ctx: RequirementContext) -> int:
2268    def effectiveLevel(self, ctx: 'RequirementContext') -> Level:
2269        """
2270        Determines whether the requirement is satisfied or not and then
2271        returns the effective level of either the `ifSatisfied` or
2272        `ifNot` skill combination, as appropriate.
2273        """
2274        if isinstance(self.invert, Level):
2275            return -self.invert
2276        elif isinstance(self.invert, Skill):
2277            return -getSkillLevel(ctx.state, self.invert)
2278        elif isinstance(self.invert, SkillCombination):
2279            return -self.invert.effectiveLevel(ctx)
2280        else:
2281            raise RuntimeError(
2282                f"Invalid InverseSkill: invert value {repr(self.invert)}"
2283                f" The invert value must be a Level (int), a Skill"
2284                f" (str), or a SkillCombination."
2285            )

Determines whether the requirement is satisfied or not and then returns the effective level of either the ifSatisfied or ifNot skill combination, as appropriate.

def unparse(self):
2287    def unparse(self):
2288        # TODO: Move these to `parsing` to avoid hard-coded tokens here?
2289        if isinstance(self.invert, SkillCombination):
2290            return '~' + self.invert.unparse()
2291        else:
2292            return '~' + str(self.invert)

Returns a string that SkillCombination.parse would turn back into a SkillCombination equivalent to this one. For example:

>>> BestSkill('brains').unparse()
'best(brains)'
>>> WorstSkill('brains', 'brawn').unparse()
'worst(brains, brawn)'
>>> CombinedSkill(
...     ConditionalSkill(ReqTokens('orb', 3), 'brains', 0),
...     InverseSkill('luck')
... ).unparse()
'sum(if(orb*3, brains, 0), ~luck)'
class ConditionalSkill(SkillCombination):
2295class ConditionalSkill(SkillCombination):
2296    def __init__(
2297        self,
2298        requirement: 'Requirement',
2299        ifSatisfied: Union[SkillCombination, Skill, Level],
2300        ifNot: Union[SkillCombination, Skill, Level] = 0
2301    ):
2302        """
2303        Given a `Requirement` and two different sub-`SkillCombination`s,
2304        which can also be `Skill` names or fixed `Level`s, represents
2305        situations where which skills come into play depends on what
2306        capabilities the player has. In situations where the given
2307        requirement is satisfied, the `ifSatisfied` combination's
2308        effective level is used, and otherwise the `ifNot` level is
2309        used. By default `ifNot` is just the fixed level 0.
2310        """
2311        self.requirement = requirement
2312        self.ifSatisfied = ifSatisfied
2313        self.ifNot = ifNot
2314
2315    def __eq__(self, other: Any) -> bool:
2316        return (
2317            isinstance(other, ConditionalSkill)
2318        and other.requirement == self.requirement
2319        and other.ifSatisfied == self.ifSatisfied
2320        and other.ifNot == self.ifNot
2321        )
2322
2323    def __hash__(self) -> int:
2324        return (
2325            1278
2326          + hash(self.requirement)
2327          + hash(self.ifSatisfied)
2328          + hash(self.ifNot)
2329        )
2330
2331    def __repr__(self) -> str:
2332        return (
2333            "ConditionalSkill("
2334          + repr(self.requirement) + ", "
2335          + repr(self.ifSatisfied) + ", "
2336          + repr(self.ifNot)
2337          + ")"
2338        )
2339
2340    def walk(self) -> Generator[
2341        Union[SkillCombination, Skill, Level],
2342        None,
2343        None
2344    ]:
2345        yield self
2346        for sub in (self.ifSatisfied, self.ifNot):
2347            if isinstance(sub, SkillCombination):
2348                yield from sub.walk()
2349            else:
2350                yield sub
2351
2352    def effectiveLevel(self, ctx: 'RequirementContext') -> Level:
2353        """
2354        Determines whether the requirement is satisfied or not and then
2355        returns the effective level of either the `ifSatisfied` or
2356        `ifNot` skill combination, as appropriate.
2357        """
2358        if self.requirement.satisfied(ctx):
2359            use = self.ifSatisfied
2360            sat = True
2361        else:
2362            use = self.ifNot
2363            sat = False
2364
2365        if isinstance(use, Level):
2366            return use
2367        elif isinstance(use, Skill):
2368            return getSkillLevel(ctx.state, use)
2369        elif isinstance(use, SkillCombination):
2370            return use.effectiveLevel(ctx)
2371        else:
2372            raise RuntimeError(
2373                f"Invalid ConditionalSkill: Requirement was"
2374                f" {'not ' if not sat else ''}satisfied, and the"
2375                f" corresponding skill value was not a level, skill, or"
2376                f" SkillCombination: {repr(use)}"
2377            )
2378
2379    def unparse(self):
2380        result = f"if({self.requirement.unparse()}, "
2381        if isinstance(self.ifSatisfied, SkillCombination):
2382            result += self.ifSatisfied.unparse()
2383        else:
2384            result += str(self.ifSatisfied)
2385        result += ', '
2386        if isinstance(self.ifNot, SkillCombination):
2387            result += self.ifNot.unparse()
2388        else:
2389            result += str(self.ifNot)
2390        return result + ')'

Represents which skill(s) are used for a Challenge, including under what circumstances different skills might apply using Requirements. This is an abstract class, use the subclasses BestSkill, WorstSkill, CombinedSkill, InverseSkill, and/or ConditionalSkill to represent a specific situation. To represent a single required skill, use a BestSkill or CombinedSkill with that skill as the only skill.

Use SkillCombination.effectiveLevel to figure out the effective level of the entire requirement in a given situation. Note that levels from the common and active FocalContexts are added together whenever a specific skill level is referenced.

Some examples:

>>> from . import core
>>> ctx = RequirementContext(emptyState(), core.DecisionGraph(), set())
>>> ctx.state['common']['capabilities']['skills']['brawn'] = 1
>>> ctx.state['common']['capabilities']['skills']['brains'] = 3
>>> ctx.state['common']['capabilities']['skills']['luck'] = -1
  1. To represent using just the 'brains' skill, you would use:

    BestSkill('brains')

    >>> sr = BestSkill('brains')
    >>> sr.effectiveLevel(ctx)
    3
    

    If a skill isn't listed, its level counts as 0:

    >>> sr = BestSkill('agility')
    >>> sr.effectiveLevel(ctx)
    0
    

    To represent using the higher of 'brains' or 'brawn' you'd use:

    BestSkill('brains', 'brawn')

    >>> sr = BestSkill('brains', 'brawn')
    >>> sr.effectiveLevel(ctx)
    3
    

    The zero default only applies if an unknown skill is in the mix:

    >>> sr = BestSkill('luck')
    >>> sr.effectiveLevel(ctx)
    -1
    >>> sr = BestSkill('luck', 'agility')
    >>> sr.effectiveLevel(ctx)
    0
    
  2. To represent using the lower of 'brains' or 'brawn' you'd use:

    WorstSkill('brains', 'brawn')

    >>> sr = WorstSkill('brains', 'brawn')
    >>> sr.effectiveLevel(ctx)
    1
    
  3. To represent using 'brawn' if the focal context has the 'brawny' capability, but brains if not, use:

    ConditionalSkill(
        ReqCapability('brawny'),
        'brawn',
        'brains'
    )
    
    >>> sr = ConditionalSkill(
    ...     ReqCapability('brawny'),
    ...     'brawn',
    ...     'brains'
    ... )
    >>> sr.effectiveLevel(ctx)
    3
    >>> brawny = copy.deepcopy(ctx)
    >>> brawny.state['common']['capabilities']['capabilities'].add(
    ...     'brawny'
    ... )
    >>> sr.effectiveLevel(brawny)
    1
    

    If the player can still choose to use 'brains' even when they have the 'brawny' capability, you would do:

    >>> sr = ConditionalSkill(
    ...     ReqCapability('brawny'),
    ...     BestSkill('brawn', 'brains'),
    ...     'brains'
    ... )
    >>> sr.effectiveLevel(ctx)
    3
    >>> sr.effectiveLevel(brawny)  # can still use brains if better
    3
    
  4. To represent using the combined level of the 'brains' and 'brawn' skills, you would use:

    CombinedSkill('brains', 'brawn')

    >>> sr = CombinedSkill('brains', 'brawn')
    >>> sr.effectiveLevel(ctx)
    4
    
  5. Skill names can be replaced by entire sub-SkillCombinations in any position, so more complex forms are possible:

    >>> sr = BestSkill(CombinedSkill('brains', 'luck'), 'brawn')
    >>> sr.effectiveLevel(ctx)
    2
    >>> sr = BestSkill(
    ...     ConditionalSkill(
    ...         ReqCapability('brawny'),
    ...         'brawn',
    ...         'brains',
    ...     ),
    ...     CombinedSkill('brains', 'luck')
    ... )
    >>> sr.effectiveLevel(ctx)
    3
    >>> sr.effectiveLevel(brawny)
    2
    
ConditionalSkill( requirement: Requirement, ifSatisfied: Union[SkillCombination, str, int], ifNot: Union[SkillCombination, str, int] = 0)
2296    def __init__(
2297        self,
2298        requirement: 'Requirement',
2299        ifSatisfied: Union[SkillCombination, Skill, Level],
2300        ifNot: Union[SkillCombination, Skill, Level] = 0
2301    ):
2302        """
2303        Given a `Requirement` and two different sub-`SkillCombination`s,
2304        which can also be `Skill` names or fixed `Level`s, represents
2305        situations where which skills come into play depends on what
2306        capabilities the player has. In situations where the given
2307        requirement is satisfied, the `ifSatisfied` combination's
2308        effective level is used, and otherwise the `ifNot` level is
2309        used. By default `ifNot` is just the fixed level 0.
2310        """
2311        self.requirement = requirement
2312        self.ifSatisfied = ifSatisfied
2313        self.ifNot = ifNot

Given a Requirement and two different sub-SkillCombinations, which can also be Skill names or fixed Levels, represents situations where which skills come into play depends on what capabilities the player has. In situations where the given requirement is satisfied, the ifSatisfied combination's effective level is used, and otherwise the ifNot level is used. By default ifNot is just the fixed level 0.

requirement
ifSatisfied
ifNot
def walk( self) -> Generator[Union[SkillCombination, str, int], NoneType, NoneType]:
2340    def walk(self) -> Generator[
2341        Union[SkillCombination, Skill, Level],
2342        None,
2343        None
2344    ]:
2345        yield self
2346        for sub in (self.ifSatisfied, self.ifNot):
2347            if isinstance(sub, SkillCombination):
2348                yield from sub.walk()
2349            else:
2350                yield sub

Yields this combination and each sub-part in depth-first traversal order.

def effectiveLevel(self, ctx: RequirementContext) -> int:
2352    def effectiveLevel(self, ctx: 'RequirementContext') -> Level:
2353        """
2354        Determines whether the requirement is satisfied or not and then
2355        returns the effective level of either the `ifSatisfied` or
2356        `ifNot` skill combination, as appropriate.
2357        """
2358        if self.requirement.satisfied(ctx):
2359            use = self.ifSatisfied
2360            sat = True
2361        else:
2362            use = self.ifNot
2363            sat = False
2364
2365        if isinstance(use, Level):
2366            return use
2367        elif isinstance(use, Skill):
2368            return getSkillLevel(ctx.state, use)
2369        elif isinstance(use, SkillCombination):
2370            return use.effectiveLevel(ctx)
2371        else:
2372            raise RuntimeError(
2373                f"Invalid ConditionalSkill: Requirement was"
2374                f" {'not ' if not sat else ''}satisfied, and the"
2375                f" corresponding skill value was not a level, skill, or"
2376                f" SkillCombination: {repr(use)}"
2377            )

Determines whether the requirement is satisfied or not and then returns the effective level of either the ifSatisfied or ifNot skill combination, as appropriate.

def unparse(self):
2379    def unparse(self):
2380        result = f"if({self.requirement.unparse()}, "
2381        if isinstance(self.ifSatisfied, SkillCombination):
2382            result += self.ifSatisfied.unparse()
2383        else:
2384            result += str(self.ifSatisfied)
2385        result += ', '
2386        if isinstance(self.ifNot, SkillCombination):
2387            result += self.ifNot.unparse()
2388        else:
2389            result += str(self.ifNot)
2390        return result + ')'

Returns a string that SkillCombination.parse would turn back into a SkillCombination equivalent to this one. For example:

>>> BestSkill('brains').unparse()
'best(brains)'
>>> WorstSkill('brains', 'brawn').unparse()
'worst(brains, brawn)'
>>> CombinedSkill(
...     ConditionalSkill(ReqTokens('orb', 3), 'brains', 0),
...     InverseSkill('luck')
... ).unparse()
'sum(if(orb*3, brains, 0), ~luck)'
class Challenge(typing.TypedDict):
2393class Challenge(TypedDict):
2394    """
2395    Represents a random binary decision between two possible outcomes,
2396    only one of which will actually occur. The 'outcome' can be set to
2397    `True` or `False` to represent that the outcome of the challenge has
2398    been observed, or to `None` (the default) to represent a pending
2399    challenge. The chance of 'success' is determined by the associated
2400    skill(s) and the challenge level, although one or both may be
2401    unknown in which case a variable is used in place of a concrete
2402    value. Probabilities that are of the form 1/2**n or (2**n - 1) /
2403    (2**n) can be represented, the specific formula for the chance of
2404    success is for a challenge with a single skill is:
2405
2406        s = interacting entity's skill level in associated skill
2407        c = challenge level
2408        P(success) = {
2409          1 - 1/2**(1 + s - c)    if s > c
2410          1/2                     if s == c
2411          1/2**(1 + c - s)        if c > s
2412        }
2413
2414    This probability formula is equivalent to the following procedure:
2415
2416    1. Flip one coin, plus one additional coin for each level difference
2417        between the skill and challenge levels.
2418    2. If the skill level is equal to or higher than the challenge
2419        level, the outcome is success if any single coin comes up heads.
2420    3. If the skill level is less than the challenge level, then the
2421        outcome is success only if *all* coins come up heads.
2422    4. If the outcome is not success, it is failure.
2423
2424    Multiple skills can be combined into a `SkillCombination`, which can
2425    use the max or min of several skills, add skill levels together,
2426    and/or have skills which are only relevant when a certain
2427    `Requirement` is satisfied. If a challenge has no skills associated
2428    with it, then the player's skill level counts as 0.
2429
2430    The slots are:
2431
2432    - 'skills': A `SkillCombination` that specifies the relevant
2433        skill(s).
2434    - 'level': An integer specifying the level of the challenge. Along
2435        with the appropriate skill level of the interacting entity, this
2436        determines the probability of success or failure.
2437    - 'success': A `Consequence` which will happen when the outcome is
2438        success. Note that since a `Consequence` can be a `Challenge`,
2439        multi-outcome challenges can be represented by chaining multiple
2440        challenges together.
2441    - 'failure': A `Consequence` which will happen when the outcome is
2442        failure.
2443    - 'outcome': The outcome of the challenge: `True` means success,
2444        `False` means failure, and `None` means "not known (yet)."
2445    """
2446    skills: SkillCombination
2447    level: Level
2448    success: 'Consequence'
2449    failure: 'Consequence'
2450    outcome: Optional[bool]

Represents a random binary decision between two possible outcomes, only one of which will actually occur. The 'outcome' can be set to True or False to represent that the outcome of the challenge has been observed, or to None (the default) to represent a pending challenge. The chance of 'success' is determined by the associated skill(s) and the challenge level, although one or both may be unknown in which case a variable is used in place of a concrete value. Probabilities that are of the form 1/2n or (2n - 1) / (2**n) can be represented, the specific formula for the chance of success is for a challenge with a single skill is:

s = interacting entity's skill level in associated skill
c = challenge level
P(success) = {
  1 - 1/2**(1 + s - c)    if s > c
  1/2                     if s == c
  1/2**(1 + c - s)        if c > s
}

This probability formula is equivalent to the following procedure:

  1. Flip one coin, plus one additional coin for each level difference between the skill and challenge levels.
  2. If the skill level is equal to or higher than the challenge level, the outcome is success if any single coin comes up heads.
  3. If the skill level is less than the challenge level, then the outcome is success only if all coins come up heads.
  4. If the outcome is not success, it is failure.

Multiple skills can be combined into a SkillCombination, which can use the max or min of several skills, add skill levels together, and/or have skills which are only relevant when a certain Requirement is satisfied. If a challenge has no skills associated with it, then the player's skill level counts as 0.

The slots are:

  • 'skills': A SkillCombination that specifies the relevant skill(s).
  • 'level': An integer specifying the level of the challenge. Along with the appropriate skill level of the interacting entity, this determines the probability of success or failure.
  • 'success': A Consequence which will happen when the outcome is success. Note that since a Consequence can be a Challenge, multi-outcome challenges can be represented by chaining multiple challenges together.
  • 'failure': A Consequence which will happen when the outcome is failure.
  • 'outcome': The outcome of the challenge: True means success, False means failure, and None means "not known (yet)."
level: int
success: List[Union[Challenge, Effect, Condition]]
failure: List[Union[Challenge, Effect, Condition]]
outcome: Optional[bool]
def challenge( skills: Optional[SkillCombination] = None, level: int = 0, success: Optional[List[Union[Challenge, Effect, Condition]]] = None, failure: Optional[List[Union[Challenge, Effect, Condition]]] = None, outcome: Optional[bool] = None):
2453def challenge(
2454    skills: Optional[SkillCombination] = None,
2455    level: Level = 0,
2456    success: Optional['Consequence'] = None,
2457    failure: Optional['Consequence'] = None,
2458    outcome: Optional[bool] = None
2459):
2460    """
2461    Factory for `Challenge`s, defaults to empty effects for both success
2462    and failure outcomes, so that you can just provide one or the other
2463    if you need to. Skills defaults to an empty list, the level defaults
2464    to 0 and the outcome defaults to `None` which means "not (yet)
2465    known."
2466    """
2467    if skills is None:
2468        skills = BestSkill(0)
2469    if success is None:
2470        success = []
2471    if failure is None:
2472        failure = []
2473    return {
2474        'skills': skills,
2475        'level': level,
2476        'success': success,
2477        'failure': failure,
2478        'outcome': outcome
2479    }

Factory for Challenges, defaults to empty effects for both success and failure outcomes, so that you can just provide one or the other if you need to. Skills defaults to an empty list, the level defaults to 0 and the outcome defaults to None which means "not (yet) known."

class Condition(typing.TypedDict):
2482class Condition(TypedDict):
2483    """
2484    Represents a condition over `Capability`, `Token`, and/or `Mechanism`
2485    states which applies to one or more `Effect`s or `Challenge`s as part
2486    of a `Consequence`. If the specified `Requirement` is satisfied, the
2487    included `Consequence` is treated as if it were part of the
2488    `Consequence` that the `Condition` is inside of, if the requirement
2489    is not satisfied, then the internal `Consequence` is skipped and the
2490    alternate consequence is used instead. Either sub-consequence may of
2491    course be an empty list.
2492    """
2493    condition: 'Requirement'
2494    consequence: 'Consequence'
2495    alternative: 'Consequence'

Represents a condition over Capability, Token, and/or Mechanism states which applies to one or more Effects or Challenges as part of a Consequence. If the specified Requirement is satisfied, the included Consequence is treated as if it were part of the Consequence that the Condition is inside of, if the requirement is not satisfied, then the internal Consequence is skipped and the alternate consequence is used instead. Either sub-consequence may of course be an empty list.

condition: Requirement
consequence: List[Union[Challenge, Effect, Condition]]
alternative: List[Union[Challenge, Effect, Condition]]
def condition( condition: Requirement, consequence: List[Union[Challenge, Effect, Condition]], alternative: Optional[List[Union[Challenge, Effect, Condition]]] = None):
2498def condition(
2499    condition: 'Requirement',
2500    consequence: 'Consequence',
2501    alternative: Optional['Consequence'] = None
2502):
2503    """
2504    Factory for conditions that just glues the given requirement,
2505    consequence, and alternative together. The alternative defaults to
2506    an empty list if not specified.
2507    """
2508    if alternative is None:
2509        alternative = []
2510    return {
2511        'condition': condition,
2512        'consequence': consequence,
2513        'alternative': alternative
2514    }

Factory for conditions that just glues the given requirement, consequence, and alternative together. The alternative defaults to an empty list if not specified.

Consequence: TypeAlias = List[Union[Challenge, Effect, Condition]]

Represents a theoretical space of consequences that can occur as a result of attempting an action, or as the success or failure outcome for a challenge. It includes multiple effects and/or challenges, and since challenges have consequences as their outcomes, consequences form a tree structure, with Effects as their leaves. Items in a Consequence are applied in-order resolving all outcomes and sub-outcomes of a challenge before considering the next item in the top-level consequence.

The Challenges in a Consequence may have their 'outcome' set to None to represent a theoretical challenge, or it may be set to either True or False to represent an observed outcome.

ChallengePolicy: TypeAlias = Literal['random', 'mostLikely', 'fewestEffects', 'success', 'failure', 'specified']

Specifies how challenges should be resolved. See observeChallengeOutcomes.

def resetChallengeOutcomes( consequence: List[Union[Challenge, Effect, Condition]]) -> None:
2552def resetChallengeOutcomes(consequence: Consequence) -> None:
2553    """
2554    Traverses all sub-consequences of the given consequence, setting the
2555    outcomes of any `Challenge`s it encounters to `None`, to prepare for
2556    a fresh call to `observeChallengeOutcomes`.
2557
2558    Resets all outcomes in every branch, regardless of previous
2559    outcomes.
2560
2561    For example:
2562
2563    >>> from . import core
2564    >>> e = core.emptySituation()
2565    >>> c = challenge(
2566    ...     success=[effect(gain=('money', 12))],
2567    ...     failure=[effect(lose=('money', 10))]
2568    ... )  # skill defaults to 'luck', level to 0, and outcome to None
2569    >>> c['outcome'] is None  # default outcome is None
2570    True
2571    >>> r = observeChallengeOutcomes(e, [c], policy='mostLikely')
2572    >>> r[0]['outcome']
2573    True
2574    >>> c['outcome']  # original outcome is changed from None
2575    True
2576    >>> r[0] is c
2577    True
2578    >>> resetChallengeOutcomes([c])
2579    >>> c['outcome'] is None  # now has been reset
2580    True
2581    >>> r[0]['outcome'] is None  # same object...
2582    True
2583    >>> resetChallengeOutcomes(c)  # can't reset just a Challenge
2584    Traceback (most recent call last):
2585    ...
2586    TypeError...
2587    >>> r = observeChallengeOutcomes(e, [c], policy='success')
2588    >>> r[0]['outcome']
2589    True
2590    >>> r = observeChallengeOutcomes(e, [c], policy='failure')
2591    >>> r[0]['outcome']  # wasn't reset
2592    True
2593    >>> resetChallengeOutcomes([c])  # now reset it
2594    >>> c['outcome'] is None
2595    True
2596    >>> r = observeChallengeOutcomes(e, [c], policy='failure')
2597    >>> r[0]['outcome']  # was reset
2598    False
2599    """
2600    if not isinstance(consequence, list):
2601        raise TypeError(
2602            f"Invalid consequence: must be a list."
2603            f"\nGot: {repr(consequence)}"
2604        )
2605
2606    for item in consequence:
2607        if not isinstance(item, dict):
2608            raise TypeError(
2609                f"Invalid consequence: items in the list must be"
2610                f" Effects, Challenges, or Conditions."
2611                f"\nGot item: {repr(item)}"
2612            )
2613        if 'skills' in item:  # must be a Challenge
2614            item = cast(Challenge, item)
2615            item['outcome'] = None
2616            # reset both branches
2617            resetChallengeOutcomes(item['success'])
2618            resetChallengeOutcomes(item['failure'])
2619
2620        elif 'value' in item:  # an Effect
2621            continue  # Effects don't have sub-outcomes
2622
2623        elif 'condition' in item:  # a Condition
2624            item = cast(Condition, item)
2625            resetChallengeOutcomes(item['consequence'])
2626            resetChallengeOutcomes(item['alternative'])
2627
2628        else:  # bad dict
2629            raise TypeError(
2630                f"Invalid consequence: items in the list must be"
2631                f" Effects, Challenges, or Conditions (got a dictionary"
2632                f" without 'skills', 'value', or 'condition' keys)."
2633                f"\nGot item: {repr(item)}"
2634            )

Traverses all sub-consequences of the given consequence, setting the outcomes of any Challenges it encounters to None, to prepare for a fresh call to observeChallengeOutcomes.

Resets all outcomes in every branch, regardless of previous outcomes.

For example:

>>> from . import core
>>> e = core.emptySituation()
>>> c = challenge(
...     success=[effect(gain=('money', 12))],
...     failure=[effect(lose=('money', 10))]
... )  # skill defaults to 'luck', level to 0, and outcome to None
>>> c['outcome'] is None  # default outcome is None
True
>>> r = observeChallengeOutcomes(e, [c], policy='mostLikely')
>>> r[0]['outcome']
True
>>> c['outcome']  # original outcome is changed from None
True
>>> r[0] is c
True
>>> resetChallengeOutcomes([c])
>>> c['outcome'] is None  # now has been reset
True
>>> r[0]['outcome'] is None  # same object...
True
>>> resetChallengeOutcomes(c)  # can't reset just a Challenge
Traceback (most recent call last):
...
TypeError...
>>> r = observeChallengeOutcomes(e, [c], policy='success')
>>> r[0]['outcome']
True
>>> r = observeChallengeOutcomes(e, [c], policy='failure')
>>> r[0]['outcome']  # wasn't reset
True
>>> resetChallengeOutcomes([c])  # now reset it
>>> c['outcome'] is None
True
>>> r = observeChallengeOutcomes(e, [c], policy='failure')
>>> r[0]['outcome']  # was reset
False
def observeChallengeOutcomes( context: RequirementContext, consequence: List[Union[Challenge, Effect, Condition]], location: Optional[Set[int]] = None, policy: Literal['random', 'mostLikely', 'fewestEffects', 'success', 'failure', 'specified'] = 'random', knownOutcomes: Optional[List[bool]] = None, makeCopy: bool = False) -> List[Union[Challenge, Effect, Condition]]:
2637def observeChallengeOutcomes(
2638    context: RequirementContext,
2639    consequence: Consequence,
2640    location: Optional[Set[DecisionID]] = None,
2641    policy: ChallengePolicy = 'random',
2642    knownOutcomes: Optional[List[bool]] = None,
2643    makeCopy: bool = False
2644) -> Consequence:
2645    """
2646    Given a `RequirementContext` (for `Capability`, `Token`, and `Skill`
2647    info as well as equivalences in the `DecisionGraph` and a
2648    search-from location for mechanism names) and a `Conseqeunce` to be
2649    observed, sets the 'outcome' value for each `Challenge` in it to
2650    either `True` or `False` by determining an outcome for each
2651    `Challenge` that's relevant (challenges locked behind unsatisfied
2652    `Condition`s or on untaken branches of other challenges are not
2653    given outcomes). `Challenge`s that already have assigned outcomes
2654    re-use those outcomes, call `resetChallengeOutcomes` beforehand if
2655    you want to re-decide each challenge with a new policy, and use the
2656    'specified' policy if you want to ensure only pre-specified outcomes
2657    are used.
2658
2659    Normally, the return value is just the original `consequence`
2660    object. However, if `makeCopy` is set to `True`, a deep copy is made
2661    and returned, so the original is not modified. One potential problem
2662    with this is that effects will be copied in this process, which
2663    means that if they are applied, things like delays and toggles won't
2664    update properly. `makeCopy` should thus normally not be used.
2665
2666    The 'policy' value can be one of the `ChallengePolicy` values. The
2667    default is 'random', in which case the `random.random` function is
2668    used to determine each outcome, based on the probability derived
2669    from the challenge level and the associated skill level. The other
2670    policies are:
2671
2672    - 'mostLikely': the result of each challenge will be whichever
2673        outcome is more likely, with success always happening instead of
2674        failure when the probabilities are 50/50.
2675    - 'fewestEffects`: whichever combination of outcomes leads to the
2676        fewest total number of effects will be chosen (modulo satisfying
2677        requirements of `Condition`s). Note that there's no estimation
2678        of the severity of effects, just the raw number. Ties in terms
2679        of number of effects are broken towards successes. This policy
2680        involves evaluating all possible outcome combinations to figure
2681        out which one has the fewest effects.
2682    - 'success' or 'failure': all outcomes will either succeed, or
2683        fail, as specified. Note that success/failure may cut off some
2684        challenges, so it's not the case that literally every challenge
2685        will succeed/fail; some may be skipped because of the
2686        specified success/failure of a prior challenge.
2687    - 'specified': all outcomes have already been specified, and those
2688        pre-specified outcomes should be used as-is.
2689
2690
2691    In call cases, outcomes specified via `knownOutcomes` take precedence
2692    over the challenge policy. The `knownOutcomes` list will be emptied
2693    out as this function works, but extra consequences beyond what's
2694    needed will be ignored (and left in the list).
2695
2696    Note that there are limits on the resolution of Python's random
2697    number generation; for challenges with extremely high or low levels
2698    relative to the associated skill(s) where the probability of success
2699    is very close to 1 or 0, there may not actually be any chance of
2700    success/failure at all. Typically you can ignore this, because such
2701    cases should not normally come up in practice, and because the odds
2702    of success/failure in those cases are such that to notice the
2703    missing possibility share you'd have to simulate outcomes a
2704    ridiculous number of times.
2705
2706    TODO: Location examples; move some of these to a separate testing
2707    file.
2708
2709    For example:
2710
2711    >>> random.seed(17)
2712    >>> warnings.filterwarnings('error')
2713    >>> from . import core
2714    >>> e = core.emptySituation()
2715    >>> c = challenge(
2716    ...     success=[effect(gain=('money', 12))],
2717    ...     failure=[effect(lose=('money', 10))]
2718    ... )  # skill defaults to 'luck', level to 0, and outcome to None
2719    >>> c['outcome'] is None  # default outcome is None
2720    True
2721    >>> r = observeChallengeOutcomes(e, [c])
2722    >>> r[0]['outcome']
2723    False
2724    >>> c['outcome']  # original outcome is changed from None
2725    False
2726    >>> all(
2727    ...     observeChallengeOutcomes(e, [c])[0]['outcome'] is False
2728    ...     for i in range(20)
2729    ... )  # no reset -> same outcome
2730    True
2731    >>> resetChallengeOutcomes([c])
2732    >>> observeChallengeOutcomes(e, [c])[0]['outcome']  # Random after reset
2733    False
2734    >>> resetChallengeOutcomes([c])
2735    >>> observeChallengeOutcomes(e, [c])[0]['outcome']  # Random after reset
2736    False
2737    >>> resetChallengeOutcomes([c])
2738    >>> observeChallengeOutcomes(e, [c])[0]['outcome'] # Random after reset
2739    True
2740    >>> observeChallengeOutcomes(e, c)  # Can't resolve just a Challenge
2741    Traceback (most recent call last):
2742    ...
2743    TypeError...
2744    >>> allSame = []
2745    >>> for i in range(20):
2746    ...    resetChallengeOutcomes([c])
2747    ...    obs = observeChallengeOutcomes(e, [c, c])
2748    ...    allSame.append(obs[0]['outcome'] == obs[1]['outcome'])
2749    >>> allSame == [True]*20
2750    True
2751    >>> different = []
2752    >>> for i in range(20):
2753    ...    resetChallengeOutcomes([c])
2754    ...    obs = observeChallengeOutcomes(e, [c, copy.deepcopy(c)])
2755    ...    different.append(obs[0]['outcome'] == obs[1]['outcome'])
2756    >>> False in different
2757    True
2758    >>> all(  # Tie breaks towards success
2759    ...     (
2760    ...         resetChallengeOutcomes([c]),
2761    ...         observeChallengeOutcomes(e, [c], policy='mostLikely')
2762    ...     )[1][0]['outcome'] is True
2763    ...     for i in range(20)
2764    ... )
2765    True
2766    >>> all(  # Tie breaks towards success
2767    ...     (
2768    ...         resetChallengeOutcomes([c]),
2769    ...         observeChallengeOutcomes(e, [c], policy='fewestEffects')
2770    ...     )[1][0]['outcome'] is True
2771    ...     for i in range(20)
2772    ... )
2773    True
2774    >>> all(
2775    ...     (
2776    ...         resetChallengeOutcomes([c]),
2777    ...         observeChallengeOutcomes(e, [c], policy='success')
2778    ...     )[1][0]['outcome'] is True
2779    ...     for i in range(20)
2780    ... )
2781    True
2782    >>> all(
2783    ...     (
2784    ...         resetChallengeOutcomes([c]),
2785    ...         observeChallengeOutcomes(e, [c], policy='failure')
2786    ...     )[1][0]['outcome'] is False
2787    ...     for i in range(20)
2788    ... )
2789    True
2790    >>> c['outcome'] = False  # Fix the outcome; now policy is ignored
2791    >>> observeChallengeOutcomes(e, [c], policy='success')[0]['outcome']
2792    False
2793    >>> c = challenge(
2794    ...     skills=BestSkill('charisma'),
2795    ...     level=8,
2796    ...     success=[
2797    ...         challenge(
2798    ...             skills=BestSkill('strength'),
2799    ...             success=[effect(gain='winner')]
2800    ...         )
2801    ...     ],  # level defaults to 0
2802    ...     failure=[
2803    ...         challenge(
2804    ...             skills=BestSkill('strength'),
2805    ...             failure=[effect(gain='loser')]
2806    ...         ),
2807    ...         effect(gain='sad')
2808    ...     ]
2809    ... )
2810    >>> r = observeChallengeOutcomes(e, [c])  # random
2811    >>> r[0]['outcome']
2812    False
2813    >>> r[0]['failure'][0]['outcome']  # also random
2814    True
2815    >>> r[0]['success'][0]['outcome'] is None  # skipped so not assigned
2816    True
2817    >>> resetChallengeOutcomes([c])
2818    >>> r2 = observeChallengeOutcomes(e, [c])  # random
2819    >>> r[0]['outcome']
2820    False
2821    >>> r[0]['success'][0]['outcome'] is None  # untaken branch no outcome
2822    True
2823    >>> r[0]['failure'][0]['outcome']  # also random
2824    False
2825    >>> def outcomeList(consequence):
2826    ...     'Lists outcomes from each challenge attempted.'
2827    ...     result = []
2828    ...     for item in consequence:
2829    ...         if 'skills' in item:
2830    ...             result.append(item['outcome'])
2831    ...             if item['outcome'] is True:
2832    ...                 result.extend(outcomeList(item['success']))
2833    ...             elif item['outcome'] is False:
2834    ...                 result.extend(outcomeList(item['failure']))
2835    ...             else:
2836    ...                 pass  # end here
2837    ...     return result
2838    >>> def skilled(**skills):
2839    ...     'Create a clone of our Situation with specific skills.'
2840    ...     r = copy.deepcopy(e)
2841    ...     r.state['common']['capabilities']['skills'].update(skills)
2842    ...     return r
2843    >>> resetChallengeOutcomes([c])
2844    >>> r = observeChallengeOutcomes(  # 'mostLikely' policy
2845    ...     skilled(charisma=9, strength=1),
2846    ...     [c],
2847    ...     policy='mostLikely'
2848    ... )
2849    >>> outcomeList(r)
2850    [True, True]
2851    >>> resetChallengeOutcomes([c])
2852    >>> outcomeList(observeChallengeOutcomes(
2853    ...     skilled(charisma=7, strength=-1),
2854    ...     [c],
2855    ...     policy='mostLikely'
2856    ... ))
2857    [False, False]
2858    >>> resetChallengeOutcomes([c])
2859    >>> outcomeList(observeChallengeOutcomes(
2860    ...     skilled(charisma=8, strength=-1),
2861    ...     [c],
2862    ...     policy='mostLikely'
2863    ... ))
2864    [True, False]
2865    >>> resetChallengeOutcomes([c])
2866    >>> outcomeList(observeChallengeOutcomes(
2867    ...     skilled(charisma=7, strength=0),
2868    ...     [c],
2869    ...     policy='mostLikely'
2870    ... ))
2871    [False, True]
2872    >>> resetChallengeOutcomes([c])
2873    >>> outcomeList(observeChallengeOutcomes(
2874    ...     skilled(charisma=20, strength=10),
2875    ...     [c],
2876    ...     policy='mostLikely'
2877    ... ))
2878    [True, True]
2879    >>> resetChallengeOutcomes([c])
2880    >>> outcomeList(observeChallengeOutcomes(
2881    ...     skilled(charisma=-10, strength=-10),
2882    ...     [c],
2883    ...     policy='mostLikely'
2884    ... ))
2885    [False, False]
2886    >>> resetChallengeOutcomes([c])
2887    >>> outcomeList(observeChallengeOutcomes(
2888    ...     e,
2889    ...     [c],
2890    ...     policy='fewestEffects'
2891    ... ))
2892    [True, False]
2893    >>> resetChallengeOutcomes([c])
2894    >>> outcomeList(observeChallengeOutcomes(
2895    ...     skilled(charisma=-100, strength=100),
2896    ...     [c],
2897    ...     policy='fewestEffects'
2898    ... ))  # unaffected by stats
2899    [True, False]
2900    >>> resetChallengeOutcomes([c])
2901    >>> outcomeList(observeChallengeOutcomes(e, [c], policy='success'))
2902    [True, True]
2903    >>> resetChallengeOutcomes([c])
2904    >>> outcomeList(observeChallengeOutcomes(e, [c], policy='failure'))
2905    [False, False]
2906    >>> cc = copy.deepcopy(c)
2907    >>> resetChallengeOutcomes([cc])
2908    >>> cc['outcome'] = False
2909    >>> outcomeList(observeChallengeOutcomes(
2910    ...     skilled(charisma=10, strength=10),
2911    ...     [cc],
2912    ...     policy='mostLikely'
2913    ... ))  # pre-observed outcome won't be changed
2914    [False, True]
2915    >>> resetChallengeOutcomes([cc])
2916    >>> cc['outcome'] = False
2917    >>> outcomeList(observeChallengeOutcomes(
2918    ...     e,
2919    ...     [cc],
2920    ...     policy='fewestEffects'
2921    ... ))  # pre-observed outcome won't be changed
2922    [False, True]
2923    >>> cc['success'][0]['outcome'] is None  # not assigned on other branch
2924    True
2925    >>> resetChallengeOutcomes([cc])
2926    >>> r = observeChallengeOutcomes(e, [cc], policy='fewestEffects')
2927    >>> r[0] is cc  # results are aliases, not clones
2928    True
2929    >>> outcomeList(r)
2930    [True, False]
2931    >>> cc['success'][0]['outcome']  # inner outcome now assigned
2932    False
2933    >>> cc['failure'][0]['outcome'] is None  # now this is other branch
2934    True
2935    >>> resetChallengeOutcomes([cc])
2936    >>> r = observeChallengeOutcomes(
2937    ...     e,
2938    ...     [cc],
2939    ...     policy='fewestEffects',
2940    ...     makeCopy=True
2941    ... )
2942    >>> r[0] is cc  # now result is a clone
2943    False
2944    >>> outcomeList(r)
2945    [True, False]
2946    >>> observedEffects(genericContextForSituation(e), r)
2947    []
2948    >>> r[0]['outcome']  # outcome was assigned
2949    True
2950    >>> cc['outcome'] is None  # only to the copy, not to the original
2951    True
2952    >>> cn = [
2953    ...     condition(
2954    ...         ReqCapability('boost'),
2955    ...         [
2956    ...             challenge(success=[effect(gain=('$', 1))]),
2957    ...             effect(gain=('$', 2))
2958    ...         ]
2959    ...     ),
2960    ...     challenge(failure=[effect(gain=('$', 4))])
2961    ... ]
2962    >>> o = observeChallengeOutcomes(e, cn, policy='fewestEffects')
2963    >>> # Without 'boost', inner challenge does not get an outcome
2964    >>> o[0]['consequence'][0]['outcome'] is None
2965    True
2966    >>> o[1]['outcome']  # avoids effect
2967    True
2968    >>> hasBoost = copy.deepcopy(e)
2969    >>> hasBoost.state['common']['capabilities']['capabilities'].add('boost')
2970    >>> resetChallengeOutcomes(cn)
2971    >>> o = observeChallengeOutcomes(hasBoost, cn, policy='fewestEffects')
2972    >>> o[0]['consequence'][0]['outcome']  # now assigned an outcome
2973    False
2974    >>> o[1]['outcome']  # avoids effect
2975    True
2976    >>> from . import core
2977    >>> e = core.emptySituation()
2978    >>> c = challenge(
2979    ...     skills=BestSkill('skill'),
2980    ...     level=4,  # very unlikely at level 0
2981    ...     success=[],
2982    ...     failure=[effect(lose=('money', 10))],
2983    ...     outcome=True
2984    ... )  # pre-assigned outcome
2985    >>> c['outcome']  # verify
2986    True
2987    >>> r = observeChallengeOutcomes(e, [c], policy='specified')
2988    >>> r[0]['outcome']
2989    True
2990    >>> c['outcome']  # original outcome is unchanged
2991    True
2992    >>> c['outcome'] = False  # the more likely outcome
2993    >>> r = observeChallengeOutcomes(e, [c], policy='specified')
2994    >>> r[0]['outcome']  # re-uses the new outcome
2995    False
2996    >>> c['outcome']  # outcome is unchanged
2997    False
2998    >>> c['outcome'] = True  # change it back
2999    >>> r = observeChallengeOutcomes(e, [c], policy='specified')
3000    >>> r[0]['outcome']  # re-use the outcome again
3001    True
3002    >>> c['outcome']  # outcome is unchanged
3003    True
3004    >>> c['outcome'] = None  # set it to no info; will crash
3005    >>> r = observeChallengeOutcomes(e, [c], policy='specified')
3006    Traceback (most recent call last):
3007    ...
3008    ValueError...
3009    >>> warnings.filterwarnings('default')
3010    >>> c['outcome'] is None  # same after crash
3011    True
3012    >>> r = observeChallengeOutcomes(
3013    ...     e,
3014    ...     [c],
3015    ...     policy='specified',
3016    ...     knownOutcomes=[True]
3017    ... )
3018    >>> r[0]['outcome']  # picked up known outcome
3019    True
3020    >>> c['outcome']  # outcome is changed
3021    True
3022    >>> resetChallengeOutcomes([c])
3023    >>> c['outcome'] is None  # has been reset
3024    True
3025    >>> r = observeChallengeOutcomes(
3026    ...     e,
3027    ...     [c],
3028    ...     policy='specified',
3029    ...     knownOutcomes=[True]
3030    ... )
3031    >>> c['outcome']  # from known outcomes
3032    True
3033    >>> ko = [False]
3034    >>> r = observeChallengeOutcomes(
3035    ...     e,
3036    ...     [c],
3037    ...     policy='specified',
3038    ...     knownOutcomes=ko
3039    ... )
3040    >>> c['outcome']  # from known outcomes
3041    False
3042    >>> ko  # known outcomes list gets used up
3043    []
3044    >>> ko = [False, False]
3045    >>> r = observeChallengeOutcomes(
3046    ...     e,
3047    ...     [c],
3048    ...     policy='specified',
3049    ...     knownOutcomes=ko
3050    ... )  # too many outcomes is an error
3051    >>> ko
3052    [False]
3053    """
3054    if not isinstance(consequence, list):
3055        raise TypeError(
3056            f"Invalid consequence: must be a list."
3057            f"\nGot: {repr(consequence)}"
3058        )
3059
3060    if knownOutcomes is None:
3061        knownOutcomes = []
3062
3063    if makeCopy:
3064        result = copy.deepcopy(consequence)
3065    else:
3066        result = consequence
3067
3068    for item in result:
3069        if not isinstance(item, dict):
3070            raise TypeError(
3071                f"Invalid consequence: items in the list must be"
3072                f" Effects, Challenges, or Conditions."
3073                f"\nGot item: {repr(item)}"
3074            )
3075        if 'skills' in item:  # must be a Challenge
3076            item = cast(Challenge, item)
3077            if len(knownOutcomes) > 0:
3078                item['outcome'] = knownOutcomes.pop(0)
3079            if item['outcome'] is not None:
3080                if item['outcome']:
3081                    observeChallengeOutcomes(
3082                        context,
3083                        item['success'],
3084                        location=location,
3085                        policy=policy,
3086                        knownOutcomes=knownOutcomes,
3087                        makeCopy=False
3088                    )
3089                else:
3090                    observeChallengeOutcomes(
3091                        context,
3092                        item['failure'],
3093                        location=location,
3094                        policy=policy,
3095                        knownOutcomes=knownOutcomes,
3096                        makeCopy=False
3097                    )
3098            else:  # need to assign an outcome
3099                if policy == 'specified':
3100                    raise ValueError(
3101                        f"Challenge has unspecified outcome so the"
3102                        f" 'specified' policy cannot be used when"
3103                        f" observing its outcomes:"
3104                        f"\n{item}"
3105                    )
3106                level = item['skills'].effectiveLevel(context)
3107                against = item['level']
3108                if level < against:
3109                    p = 1 / (2 ** (1 + against - level))
3110                else:
3111                    p = 1 - (1 / (2 ** (1 + level - against)))
3112                if policy == 'random':
3113                    if random.random() < p:  # success
3114                        item['outcome'] = True
3115                    else:
3116                        item['outcome'] = False
3117                elif policy == 'mostLikely':
3118                    if p >= 0.5:
3119                        item['outcome'] = True
3120                    else:
3121                        item['outcome'] = False
3122                elif policy == 'fewestEffects':
3123                    # Resolve copies so we don't affect original
3124                    subSuccess = observeChallengeOutcomes(
3125                        context,
3126                        item['success'],
3127                        location=location,
3128                        policy=policy,
3129                        knownOutcomes=knownOutcomes[:],
3130                        makeCopy=True
3131                    )
3132                    subFailure = observeChallengeOutcomes(
3133                        context,
3134                        item['failure'],
3135                        location=location,
3136                        policy=policy,
3137                        knownOutcomes=knownOutcomes[:],
3138                        makeCopy=True
3139                    )
3140                    if (
3141                        len(observedEffects(context, subSuccess))
3142                     <= len(observedEffects(context, subFailure))
3143                    ):
3144                        item['outcome'] = True
3145                    else:
3146                        item['outcome'] = False
3147                elif policy == 'success':
3148                    item['outcome'] = True
3149                elif policy == 'failure':
3150                    item['outcome'] = False
3151
3152                # Figure out outcomes for sub-consequence if we don't
3153                # already have them...
3154                if item['outcome'] not in (True, False):
3155                    raise TypeError(
3156                        f"Challenge has invalid outcome type"
3157                        f" {type(item['outcome'])} after observation."
3158                        f"\nOutcome value: {repr(item['outcome'])}"
3159                    )
3160
3161                if item['outcome']:
3162                    observeChallengeOutcomes(
3163                        context,
3164                        item['success'],
3165                        location=location,
3166                        policy=policy,
3167                        knownOutcomes=knownOutcomes,
3168                        makeCopy=False
3169                    )
3170                else:
3171                    observeChallengeOutcomes(
3172                        context,
3173                        item['failure'],
3174                        location=location,
3175                        policy=policy,
3176                        knownOutcomes=knownOutcomes,
3177                        makeCopy=False
3178                    )
3179
3180        elif 'value' in item:
3181            continue  # Effects do not need success/failure assigned
3182
3183        elif 'condition' in item:  # a Condition
3184            if item['condition'].satisfied(context):
3185                observeChallengeOutcomes(
3186                    context,
3187                    item['consequence'],
3188                    location=location,
3189                    policy=policy,
3190                    knownOutcomes=knownOutcomes,
3191                    makeCopy=False
3192                )
3193            else:
3194                observeChallengeOutcomes(
3195                    context,
3196                    item['alternative'],
3197                    location=location,
3198                    policy=policy,
3199                    knownOutcomes=knownOutcomes,
3200                    makeCopy=False
3201                )
3202
3203        else:  # bad dict
3204            raise TypeError(
3205                f"Invalid consequence: items in the list must be"
3206                f" Effects, Challenges, or Conditions (got a dictionary"
3207                f" without 'skills', 'value', or 'condition' keys)."
3208                f"\nGot item: {repr(item)}"
3209            )
3210
3211    # Return copy or original, now with options selected
3212    return result

Given a RequirementContext (for Capability, Token, and Skill info as well as equivalences in the DecisionGraph and a search-from location for mechanism names) and a Conseqeunce to be observed, sets the 'outcome' value for each Challenge in it to either True or False by determining an outcome for each Challenge that's relevant (challenges locked behind unsatisfied Conditions or on untaken branches of other challenges are not given outcomes). Challenges that already have assigned outcomes re-use those outcomes, call resetChallengeOutcomes beforehand if you want to re-decide each challenge with a new policy, and use the 'specified' policy if you want to ensure only pre-specified outcomes are used.

Normally, the return value is just the original consequence object. However, if makeCopy is set to True, a deep copy is made and returned, so the original is not modified. One potential problem with this is that effects will be copied in this process, which means that if they are applied, things like delays and toggles won't update properly. makeCopy should thus normally not be used.

The 'policy' value can be one of the ChallengePolicy values. The default is 'random', in which case the random.random function is used to determine each outcome, based on the probability derived from the challenge level and the associated skill level. The other policies are:

  • 'mostLikely': the result of each challenge will be whichever outcome is more likely, with success always happening instead of failure when the probabilities are 50/50.
  • 'fewestEffects: whichever combination of outcomes leads to the fewest total number of effects will be chosen (modulo satisfying requirements ofCondition`s). Note that there's no estimation of the severity of effects, just the raw number. Ties in terms of number of effects are broken towards successes. This policy involves evaluating all possible outcome combinations to figure out which one has the fewest effects.
  • 'success' or 'failure': all outcomes will either succeed, or fail, as specified. Note that success/failure may cut off some challenges, so it's not the case that literally every challenge will succeed/fail; some may be skipped because of the specified success/failure of a prior challenge.
  • 'specified': all outcomes have already been specified, and those pre-specified outcomes should be used as-is.

In call cases, outcomes specified via knownOutcomes take precedence over the challenge policy. The knownOutcomes list will be emptied out as this function works, but extra consequences beyond what's needed will be ignored (and left in the list).

Note that there are limits on the resolution of Python's random number generation; for challenges with extremely high or low levels relative to the associated skill(s) where the probability of success is very close to 1 or 0, there may not actually be any chance of success/failure at all. Typically you can ignore this, because such cases should not normally come up in practice, and because the odds of success/failure in those cases are such that to notice the missing possibility share you'd have to simulate outcomes a ridiculous number of times.

TODO: Location examples; move some of these to a separate testing file.

For example:

>>> random.seed(17)
>>> warnings.filterwarnings('error')
>>> from . import core
>>> e = core.emptySituation()
>>> c = challenge(
...     success=[effect(gain=('money', 12))],
...     failure=[effect(lose=('money', 10))]
... )  # skill defaults to 'luck', level to 0, and outcome to None
>>> c['outcome'] is None  # default outcome is None
True
>>> r = observeChallengeOutcomes(e, [c])
>>> r[0]['outcome']
False
>>> c['outcome']  # original outcome is changed from None
False
>>> all(
...     observeChallengeOutcomes(e, [c])[0]['outcome'] is False
...     for i in range(20)
... )  # no reset -> same outcome
True
>>> resetChallengeOutcomes([c])
>>> observeChallengeOutcomes(e, [c])[0]['outcome']  # Random after reset
False
>>> resetChallengeOutcomes([c])
>>> observeChallengeOutcomes(e, [c])[0]['outcome']  # Random after reset
False
>>> resetChallengeOutcomes([c])
>>> observeChallengeOutcomes(e, [c])[0]['outcome'] # Random after reset
True
>>> observeChallengeOutcomes(e, c)  # Can't resolve just a Challenge
Traceback (most recent call last):
...
TypeError...
>>> allSame = []
>>> for i in range(20):
...    resetChallengeOutcomes([c])
...    obs = observeChallengeOutcomes(e, [c, c])
...    allSame.append(obs[0]['outcome'] == obs[1]['outcome'])
>>> allSame == [True]*20
True
>>> different = []
>>> for i in range(20):
...    resetChallengeOutcomes([c])
...    obs = observeChallengeOutcomes(e, [c, copy.deepcopy(c)])
...    different.append(obs[0]['outcome'] == obs[1]['outcome'])
>>> False in different
True
>>> all(  # Tie breaks towards success
...     (
...         resetChallengeOutcomes([c]),
...         observeChallengeOutcomes(e, [c], policy='mostLikely')
...     )[1][0]['outcome'] is True
...     for i in range(20)
... )
True
>>> all(  # Tie breaks towards success
...     (
...         resetChallengeOutcomes([c]),
...         observeChallengeOutcomes(e, [c], policy='fewestEffects')
...     )[1][0]['outcome'] is True
...     for i in range(20)
... )
True
>>> all(
...     (
...         resetChallengeOutcomes([c]),
...         observeChallengeOutcomes(e, [c], policy='success')
...     )[1][0]['outcome'] is True
...     for i in range(20)
... )
True
>>> all(
...     (
...         resetChallengeOutcomes([c]),
...         observeChallengeOutcomes(e, [c], policy='failure')
...     )[1][0]['outcome'] is False
...     for i in range(20)
... )
True
>>> c['outcome'] = False  # Fix the outcome; now policy is ignored
>>> observeChallengeOutcomes(e, [c], policy='success')[0]['outcome']
False
>>> c = challenge(
...     skills=BestSkill('charisma'),
...     level=8,
...     success=[
...         challenge(
...             skills=BestSkill('strength'),
...             success=[effect(gain='winner')]
...         )
...     ],  # level defaults to 0
...     failure=[
...         challenge(
...             skills=BestSkill('strength'),
...             failure=[effect(gain='loser')]
...         ),
...         effect(gain='sad')
...     ]
... )
>>> r = observeChallengeOutcomes(e, [c])  # random
>>> r[0]['outcome']
False
>>> r[0]['failure'][0]['outcome']  # also random
True
>>> r[0]['success'][0]['outcome'] is None  # skipped so not assigned
True
>>> resetChallengeOutcomes([c])
>>> r2 = observeChallengeOutcomes(e, [c])  # random
>>> r[0]['outcome']
False
>>> r[0]['success'][0]['outcome'] is None  # untaken branch no outcome
True
>>> r[0]['failure'][0]['outcome']  # also random
False
>>> def outcomeList(consequence):
...     'Lists outcomes from each challenge attempted.'
...     result = []
...     for item in consequence:
...         if 'skills' in item:
...             result.append(item['outcome'])
...             if item['outcome'] is True:
...                 result.extend(outcomeList(item['success']))
...             elif item['outcome'] is False:
...                 result.extend(outcomeList(item['failure']))
...             else:
...                 pass  # end here
...     return result
>>> def skilled(**skills):
...     'Create a clone of our Situation with specific skills.'
...     r = copy.deepcopy(e)
...     r.state['common']['capabilities']['skills'].update(skills)
...     return r
>>> resetChallengeOutcomes([c])
>>> r = observeChallengeOutcomes(  # 'mostLikely' policy
...     skilled(charisma=9, strength=1),
...     [c],
...     policy='mostLikely'
... )
>>> outcomeList(r)
[True, True]
>>> resetChallengeOutcomes([c])
>>> outcomeList(observeChallengeOutcomes(
...     skilled(charisma=7, strength=-1),
...     [c],
...     policy='mostLikely'
... ))
[False, False]
>>> resetChallengeOutcomes([c])
>>> outcomeList(observeChallengeOutcomes(
...     skilled(charisma=8, strength=-1),
...     [c],
...     policy='mostLikely'
... ))
[True, False]
>>> resetChallengeOutcomes([c])
>>> outcomeList(observeChallengeOutcomes(
...     skilled(charisma=7, strength=0),
...     [c],
...     policy='mostLikely'
... ))
[False, True]
>>> resetChallengeOutcomes([c])
>>> outcomeList(observeChallengeOutcomes(
...     skilled(charisma=20, strength=10),
...     [c],
...     policy='mostLikely'
... ))
[True, True]
>>> resetChallengeOutcomes([c])
>>> outcomeList(observeChallengeOutcomes(
...     skilled(charisma=-10, strength=-10),
...     [c],
...     policy='mostLikely'
... ))
[False, False]
>>> resetChallengeOutcomes([c])
>>> outcomeList(observeChallengeOutcomes(
...     e,
...     [c],
...     policy='fewestEffects'
... ))
[True, False]
>>> resetChallengeOutcomes([c])
>>> outcomeList(observeChallengeOutcomes(
...     skilled(charisma=-100, strength=100),
...     [c],
...     policy='fewestEffects'
... ))  # unaffected by stats
[True, False]
>>> resetChallengeOutcomes([c])
>>> outcomeList(observeChallengeOutcomes(e, [c], policy='success'))
[True, True]
>>> resetChallengeOutcomes([c])
>>> outcomeList(observeChallengeOutcomes(e, [c], policy='failure'))
[False, False]
>>> cc = copy.deepcopy(c)
>>> resetChallengeOutcomes([cc])
>>> cc['outcome'] = False
>>> outcomeList(observeChallengeOutcomes(
...     skilled(charisma=10, strength=10),
...     [cc],
...     policy='mostLikely'
... ))  # pre-observed outcome won't be changed
[False, True]
>>> resetChallengeOutcomes([cc])
>>> cc['outcome'] = False
>>> outcomeList(observeChallengeOutcomes(
...     e,
...     [cc],
...     policy='fewestEffects'
... ))  # pre-observed outcome won't be changed
[False, True]
>>> cc['success'][0]['outcome'] is None  # not assigned on other branch
True
>>> resetChallengeOutcomes([cc])
>>> r = observeChallengeOutcomes(e, [cc], policy='fewestEffects')
>>> r[0] is cc  # results are aliases, not clones
True
>>> outcomeList(r)
[True, False]
>>> cc['success'][0]['outcome']  # inner outcome now assigned
False
>>> cc['failure'][0]['outcome'] is None  # now this is other branch
True
>>> resetChallengeOutcomes([cc])
>>> r = observeChallengeOutcomes(
...     e,
...     [cc],
...     policy='fewestEffects',
...     makeCopy=True
... )
>>> r[0] is cc  # now result is a clone
False
>>> outcomeList(r)
[True, False]
>>> observedEffects(genericContextForSituation(e), r)
[]
>>> r[0]['outcome']  # outcome was assigned
True
>>> cc['outcome'] is None  # only to the copy, not to the original
True
>>> cn = [
...     condition(
...         ReqCapability('boost'),
...         [
...             challenge(success=[effect(gain=('$', 1))]),
...             effect(gain=('$', 2))
...         ]
...     ),
...     challenge(failure=[effect(gain=('$', 4))])
... ]
>>> o = observeChallengeOutcomes(e, cn, policy='fewestEffects')
>>> # Without 'boost', inner challenge does not get an outcome
>>> o[0]['consequence'][0]['outcome'] is None
True
>>> o[1]['outcome']  # avoids effect
True
>>> hasBoost = copy.deepcopy(e)
>>> hasBoost.state['common']['capabilities']['capabilities'].add('boost')
>>> resetChallengeOutcomes(cn)
>>> o = observeChallengeOutcomes(hasBoost, cn, policy='fewestEffects')
>>> o[0]['consequence'][0]['outcome']  # now assigned an outcome
False
>>> o[1]['outcome']  # avoids effect
True
>>> from . import core
>>> e = core.emptySituation()
>>> c = challenge(
...     skills=BestSkill('skill'),
...     level=4,  # very unlikely at level 0
...     success=[],
...     failure=[effect(lose=('money', 10))],
...     outcome=True
... )  # pre-assigned outcome
>>> c['outcome']  # verify
True
>>> r = observeChallengeOutcomes(e, [c], policy='specified')
>>> r[0]['outcome']
True
>>> c['outcome']  # original outcome is unchanged
True
>>> c['outcome'] = False  # the more likely outcome
>>> r = observeChallengeOutcomes(e, [c], policy='specified')
>>> r[0]['outcome']  # re-uses the new outcome
False
>>> c['outcome']  # outcome is unchanged
False
>>> c['outcome'] = True  # change it back
>>> r = observeChallengeOutcomes(e, [c], policy='specified')
>>> r[0]['outcome']  # re-use the outcome again
True
>>> c['outcome']  # outcome is unchanged
True
>>> c['outcome'] = None  # set it to no info; will crash
>>> r = observeChallengeOutcomes(e, [c], policy='specified')
Traceback (most recent call last):
...
ValueError...
>>> warnings.filterwarnings('default')
>>> c['outcome'] is None  # same after crash
True
>>> r = observeChallengeOutcomes(
...     e,
...     [c],
...     policy='specified',
...     knownOutcomes=[True]
... )
>>> r[0]['outcome']  # picked up known outcome
True
>>> c['outcome']  # outcome is changed
True
>>> resetChallengeOutcomes([c])
>>> c['outcome'] is None  # has been reset
True
>>> r = observeChallengeOutcomes(
...     e,
...     [c],
...     policy='specified',
...     knownOutcomes=[True]
... )
>>> c['outcome']  # from known outcomes
True
>>> ko = [False]
>>> r = observeChallengeOutcomes(
...     e,
...     [c],
...     policy='specified',
...     knownOutcomes=ko
... )
>>> c['outcome']  # from known outcomes
False
>>> ko  # known outcomes list gets used up
[]
>>> ko = [False, False]
>>> r = observeChallengeOutcomes(
...     e,
...     [c],
...     policy='specified',
...     knownOutcomes=ko
... )  # too many outcomes is an error
>>> ko
[False]
class UnassignedOutcomeWarning(builtins.Warning):
3215class UnassignedOutcomeWarning(Warning):
3216    """
3217    A warning issued when asking for observed effects of a `Consequence`
3218    whose `Challenge` outcomes have not been fully assigned.
3219    """
3220    pass

A warning issued when asking for observed effects of a Consequence whose Challenge outcomes have not been fully assigned.

Inherited Members
builtins.Warning
Warning
builtins.BaseException
with_traceback
add_note
args
def observedEffects( context: RequirementContext, observed: List[Union[Challenge, Effect, Condition]], skipWarning=False, baseIndex: int = 0) -> List[int]:
3223def observedEffects(
3224    context: RequirementContext,
3225    observed: Consequence,
3226    skipWarning=False,
3227    baseIndex: int = 0
3228) -> List[int]:
3229    """
3230    Given a `Situation` and a `Consequence` whose challenges have
3231    outcomes assigned, returns a tuple containing a list of the
3232    depth-first-indices of each effect to apply. You can use
3233    `consequencePart` to extract the actual `Effect` values from the
3234    consequence based on their indices.
3235
3236    Only effects that actually apply are included, based on the observed
3237    outcomes as well as which `Condition`(s) are met, although charges
3238    and delays for the effects are not taken into account.
3239
3240    `baseIndex` can be set to something other than 0 to start indexing
3241    at that value. Issues an `UnassignedOutcomeWarning` if it encounters
3242    a challenge whose outcome has not been observed, unless
3243    `skipWarning` is set to `True`. In that case, no effects are listed
3244    for outcomes of that challenge.
3245
3246    For example:
3247
3248    >>> from . import core
3249    >>> warnings.filterwarnings('error')
3250    >>> e = core.emptySituation()
3251    >>> def skilled(**skills):
3252    ...     'Create a clone of our FocalContext with specific skills.'
3253    ...     r = copy.deepcopy(e)
3254    ...     r.state['common']['capabilities']['skills'].update(skills)
3255    ...     return r
3256    >>> c = challenge(  # index 1 in [c] (index 0 is the outer list)
3257    ...     skills=BestSkill('charisma'),
3258    ...     level=8,
3259    ...     success=[
3260    ...         effect(gain='happy'),  # index 3 in [c]
3261    ...         challenge(
3262    ...             skills=BestSkill('strength'),
3263    ...             success=[effect(gain='winner')]  # index 6 in [c]
3264    ...             # failure is index 7
3265    ...         )  # level defaults to 0
3266    ...     ],
3267    ...     failure=[
3268    ...         challenge(
3269    ...             skills=BestSkill('strength'),
3270    ...             # success is index 10
3271    ...             failure=[effect(gain='loser')]  # index 12 in [c]
3272    ...         ),
3273    ...         effect(gain='sad')  # index 13 in [c]
3274    ...     ]
3275    ... )
3276    >>> import pytest
3277    >>> with pytest.warns(UnassignedOutcomeWarning):
3278    ...     observedEffects(e, [c])
3279    []
3280    >>> with pytest.warns(UnassignedOutcomeWarning):
3281    ...     observedEffects(e, [c, c])
3282    []
3283    >>> observedEffects(e, [c, c], skipWarning=True)
3284    []
3285    >>> c['outcome'] = 'invalid value'  # must be True, False, or None
3286    >>> observedEffects(e, [c])
3287    Traceback (most recent call last):
3288    ...
3289    TypeError...
3290    >>> yesYes = skilled(charisma=10, strength=5)
3291    >>> yesNo = skilled(charisma=10, strength=-1)
3292    >>> noYes = skilled(charisma=4, strength=5)
3293    >>> noNo = skilled(charisma=4, strength=-1)
3294    >>> resetChallengeOutcomes([c])
3295    >>> observedEffects(
3296    ...     yesYes,
3297    ...     observeChallengeOutcomes(yesYes, [c], policy='mostLikely')
3298    ... )
3299    [3, 6]
3300    >>> resetChallengeOutcomes([c])
3301    >>> observedEffects(
3302    ...     yesNo,
3303    ...     observeChallengeOutcomes(yesNo, [c], policy='mostLikely')
3304    ... )
3305    [3]
3306    >>> resetChallengeOutcomes([c])
3307    >>> observedEffects(
3308    ...     noYes,
3309    ...     observeChallengeOutcomes(noYes, [c], policy='mostLikely')
3310    ... )
3311    [13]
3312    >>> resetChallengeOutcomes([c])
3313    >>> observedEffects(
3314    ...     noNo,
3315    ...     observeChallengeOutcomes(noNo, [c], policy='mostLikely')
3316    ... )
3317    [12, 13]
3318    >>> warnings.filterwarnings('default')
3319    >>> # known outcomes override policy & pre-specified outcomes
3320    >>> observedEffects(
3321    ...     noNo,
3322    ...     observeChallengeOutcomes(
3323    ...         noNo,
3324    ...         [c],
3325    ...         policy='mostLikely',
3326    ...         knownOutcomes=[True, True])
3327    ... )
3328    [3, 6]
3329    >>> observedEffects(
3330    ...     yesYes,
3331    ...     observeChallengeOutcomes(
3332    ...         yesYes,
3333    ...         [c],
3334    ...         policy='mostLikely',
3335    ...         knownOutcomes=[False, False])
3336    ... )
3337    [12, 13]
3338    >>> resetChallengeOutcomes([c])
3339    >>> observedEffects(
3340    ...     yesYes,
3341    ...     observeChallengeOutcomes(
3342    ...         yesYes,
3343    ...         [c],
3344    ...         policy='mostLikely',
3345    ...         knownOutcomes=[False, False])
3346    ... )
3347    [12, 13]
3348    """
3349    result: List[int] = []
3350    totalCount: int = baseIndex + 1  # +1 for the outer list
3351    if not isinstance(observed, list):
3352        raise TypeError(
3353            f"Invalid consequence: must be a list."
3354            f"\nGot: {repr(observed)}"
3355        )
3356    for item in observed:
3357        if not isinstance(item, dict):
3358            raise TypeError(
3359                f"Invalid consequence: items in the list must be"
3360                f" Effects, Challenges, or Conditions."
3361                f"\nGot item: {repr(item)}"
3362            )
3363
3364        if 'skills' in item:  # must be a Challenge
3365            item = cast(Challenge, item)
3366            succeeded = item['outcome']
3367            useCh: Optional[Literal['success', 'failure']]
3368            if succeeded is True:
3369                useCh = 'success'
3370            elif succeeded is False:
3371                useCh = 'failure'
3372            else:
3373                useCh = None
3374                level = item["level"]
3375                if succeeded is not None:
3376                    raise TypeError(
3377                        f"Invalid outcome for level-{level} challenge:"
3378                        f" should be True, False, or None, but got:"
3379                        f" {repr(succeeded)}"
3380                    )
3381                else:
3382                    if not skipWarning:
3383                        warnings.warn(
3384                            (
3385                                f"A level-{level} challenge in the"
3386                                f" consequence being observed has no"
3387                                f" observed outcome; no effects from"
3388                                f" either success or failure branches"
3389                                f" will be included. Use"
3390                                f" observeChallengeOutcomes to fill in"
3391                                f" unobserved outcomes."
3392                            ),
3393                            UnassignedOutcomeWarning
3394                        )
3395
3396            if useCh is not None:
3397                skipped = 0
3398                if useCh == 'failure':
3399                    skipped = countParts(item['success'])
3400                subEffects = observedEffects(
3401                    context,
3402                    item[useCh],
3403                    skipWarning=skipWarning,
3404                    baseIndex=totalCount + skipped + 1
3405                )
3406                result.extend(subEffects)
3407
3408            # TODO: Go back to returning tuples but fix counts to include
3409            # skipped stuff; this is horribly inefficient :(
3410            totalCount += countParts(item)
3411
3412        elif 'value' in item:  # an effect, not a challenge
3413            item = cast(Effect, item)
3414            result.append(totalCount)
3415            totalCount += 1
3416
3417        elif 'condition' in item:  # a Condition
3418            item = cast(Condition, item)
3419            useCo: Literal['consequence', 'alternative']
3420            if item['condition'].satisfied(context):
3421                useCo = 'consequence'
3422                skipped = 0
3423            else:
3424                useCo = 'alternative'
3425                skipped = countParts(item['consequence'])
3426            subEffects = observedEffects(
3427                context,
3428                item[useCo],
3429                skipWarning=skipWarning,
3430                baseIndex=totalCount + skipped + 1
3431            )
3432            result.extend(subEffects)
3433            totalCount += countParts(item)
3434
3435        else:  # bad dict
3436            raise TypeError(
3437                f"Invalid consequence: items in the list must be"
3438                f" Effects, Challenges, or Conditions (got a dictionary"
3439                f" without 'skills', 'value', or 'condition' keys)."
3440                f"\nGot item: {repr(item)}"
3441            )
3442
3443    return result

Given a Situation and a Consequence whose challenges have outcomes assigned, returns a tuple containing a list of the depth-first-indices of each effect to apply. You can use consequencePart to extract the actual Effect values from the consequence based on their indices.

Only effects that actually apply are included, based on the observed outcomes as well as which Condition(s) are met, although charges and delays for the effects are not taken into account.

baseIndex can be set to something other than 0 to start indexing at that value. Issues an UnassignedOutcomeWarning if it encounters a challenge whose outcome has not been observed, unless skipWarning is set to True. In that case, no effects are listed for outcomes of that challenge.

For example:

>>> from . import core
>>> warnings.filterwarnings('error')
>>> e = core.emptySituation()
>>> def skilled(**skills):
...     'Create a clone of our FocalContext with specific skills.'
...     r = copy.deepcopy(e)
...     r.state['common']['capabilities']['skills'].update(skills)
...     return r
>>> c = challenge(  # index 1 in [c] (index 0 is the outer list)
...     skills=BestSkill('charisma'),
...     level=8,
...     success=[
...         effect(gain='happy'),  # index 3 in [c]
...         challenge(
...             skills=BestSkill('strength'),
...             success=[effect(gain='winner')]  # index 6 in [c]
...             # failure is index 7
...         )  # level defaults to 0
...     ],
...     failure=[
...         challenge(
...             skills=BestSkill('strength'),
...             # success is index 10
...             failure=[effect(gain='loser')]  # index 12 in [c]
...         ),
...         effect(gain='sad')  # index 13 in [c]
...     ]
... )
>>> import pytest
>>> with pytest.warns(UnassignedOutcomeWarning):
...     observedEffects(e, [c])
[]
>>> with pytest.warns(UnassignedOutcomeWarning):
...     observedEffects(e, [c, c])
[]
>>> observedEffects(e, [c, c], skipWarning=True)
[]
>>> c['outcome'] = 'invalid value'  # must be True, False, or None
>>> observedEffects(e, [c])
Traceback (most recent call last):
...
TypeError...
>>> yesYes = skilled(charisma=10, strength=5)
>>> yesNo = skilled(charisma=10, strength=-1)
>>> noYes = skilled(charisma=4, strength=5)
>>> noNo = skilled(charisma=4, strength=-1)
>>> resetChallengeOutcomes([c])
>>> observedEffects(
...     yesYes,
...     observeChallengeOutcomes(yesYes, [c], policy='mostLikely')
... )
[3, 6]
>>> resetChallengeOutcomes([c])
>>> observedEffects(
...     yesNo,
...     observeChallengeOutcomes(yesNo, [c], policy='mostLikely')
... )
[3]
>>> resetChallengeOutcomes([c])
>>> observedEffects(
...     noYes,
...     observeChallengeOutcomes(noYes, [c], policy='mostLikely')
... )
[13]
>>> resetChallengeOutcomes([c])
>>> observedEffects(
...     noNo,
...     observeChallengeOutcomes(noNo, [c], policy='mostLikely')
... )
[12, 13]
>>> warnings.filterwarnings('default')
>>> # known outcomes override policy & pre-specified outcomes
>>> observedEffects(
...     noNo,
...     observeChallengeOutcomes(
...         noNo,
...         [c],
...         policy='mostLikely',
...         knownOutcomes=[True, True])
... )
[3, 6]
>>> observedEffects(
...     yesYes,
...     observeChallengeOutcomes(
...         yesYes,
...         [c],
...         policy='mostLikely',
...         knownOutcomes=[False, False])
... )
[12, 13]
>>> resetChallengeOutcomes([c])
>>> observedEffects(
...     yesYes,
...     observeChallengeOutcomes(
...         yesYes,
...         [c],
...         policy='mostLikely',
...         knownOutcomes=[False, False])
... )
[12, 13]
MECHANISM_STATE_SUFFIX_RE = re.compile('(.*)(?<!:):([^:]+)$')

Regular expression for finding mechanism state suffixes. These are a single colon followed by any amount of non-colon characters until the end of a token.

class Requirement:
3458class Requirement:
3459    """
3460    Represents a precondition for traversing an edge or taking an action.
3461    This can be any boolean expression over `Capability`, mechanism (see
3462    `MechanismName`), and/or `Token` states that must obtain, with
3463    numerical values for the number of tokens required, and specific
3464    mechanism states or active capabilities necessary. For example, if
3465    the player needs either the wall-break capability or the wall-jump
3466    capability plus a balloon token, or for the switch mechanism to be
3467    on, you could represent that using:
3468
3469        ReqAny(
3470            ReqCapability('wall-break'),
3471            ReqAll(
3472                ReqCapability('wall-jump'),
3473                ReqTokens('balloon', 1)
3474            ),
3475            ReqMechanism('switch', 'on')
3476        )
3477
3478    The subclasses define concrete requirements.
3479
3480    Note that mechanism names are searched for using `lookupMechanism`,
3481    starting from the `DecisionID`s of the decisions on either end of
3482    the transition where a requirement is being checked. You may need to
3483    rename mechanisms to avoid a `MechanismCollisionError`if decisions
3484    on either end of a transition use the same mechanism name.
3485    """
3486    def satisfied(
3487        self,
3488        context: RequirementContext,
3489        dontRecurse: Optional[
3490            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
3491        ] = None
3492    ) -> bool:
3493        """
3494        This will return `True` if the requirement is satisfied in the
3495        given `RequirementContext`, resolving mechanisms from the
3496        context's set of decisions and graph, and respecting the
3497        context's equivalences. It returns `False` otherwise.
3498
3499        The `dontRecurse` set should be unspecified to start, and will
3500        be used to avoid infinite recursion in cases of circular
3501        equivalences (requirements are not considered satisfied by
3502        equivalence loops).
3503
3504        TODO: Examples
3505        """
3506        raise NotImplementedError(
3507            "Requirement is an abstract class and cannot be"
3508            " used directly."
3509        )
3510
3511    def __eq__(self, other: Any) -> bool:
3512        raise NotImplementedError(
3513            "Requirement is an abstract class and cannot be compared."
3514        )
3515
3516    def __hash__(self) -> int:
3517        raise NotImplementedError(
3518            "Requirement is an abstract class and cannot be hashed."
3519        )
3520
3521    def walk(self) -> Generator['Requirement', None, None]:
3522        """
3523        Yields every part of the requirement in depth-first traversal
3524        order.
3525        """
3526        raise NotImplementedError(
3527            "Requirement is an abstract class and cannot be walked."
3528        )
3529
3530    def asEffectList(self) -> List[Effect]:
3531        """
3532        Transforms this `Requirement` into a list of `Effect`
3533        objects that gain the `Capability`, set the `Token` amounts, and
3534        set the `Mechanism` states mentioned by the requirement. The
3535        requirement must be either a `ReqTokens`, a `ReqCapability`, a
3536        `ReqMechanism`, or a `ReqAll` which includes nothing besides
3537        those types as sub-requirements. The token and capability
3538        requirements at the leaves of the tree will be collected into a
3539        list for the result (note that whether `ReqAny` or `ReqAll` is
3540        used is ignored, all of the tokens/capabilities/mechanisms
3541        mentioned are listed). For each `Capability` requirement a
3542        'gain' effect for that capability will be included. For each
3543        `Mechanism` or `Token` requirement, a 'set' effect for that
3544        mechanism state or token count will be included. Note that if
3545        the requirement has contradictory clauses (e.g., two different
3546        mechanism states) multiple effects which cancel each other out
3547        will be included. Also note that setting token amounts may end
3548        up decreasing them unnecessarily.
3549
3550        Raises a `TypeError` if this requirement is not suitable for
3551        transformation into an effect list.
3552        """
3553        raise NotImplementedError("Requirement is an abstract class.")
3554
3555    def flatten(self) -> 'Requirement':
3556        """
3557        Returns a simplified version of this requirement that merges
3558        multiple redundant layers of `ReqAny`/`ReqAll` into single
3559        `ReqAny`/`ReqAll` structures, including recursively. May return
3560        the original requirement if there's no simplification to be done.
3561
3562        Default implementation just returns `self`.
3563        """
3564        return self
3565
3566    def unparse(self) -> str:
3567        """
3568        Returns a string which would convert back into this `Requirement`
3569        object if you fed it to `parsing.ParseFormat.parseRequirement`.
3570
3571        TODO: Move this over into `parsing`?
3572
3573        Examples:
3574
3575        >>> r = ReqAny([
3576        ...     ReqCapability('capability'),
3577        ...     ReqTokens('token', 3),
3578        ...     ReqMechanism('mechanism', 'state')
3579        ... ])
3580        >>> rep = r.unparse()
3581        >>> rep
3582        '(capability|token*3|mechanism:state)'
3583        >>> from . import parsing
3584        >>> pf = parsing.ParseFormat()
3585        >>> back = pf.parseRequirement(rep)
3586        >>> back == r
3587        True
3588        >>> ReqNot(ReqNothing()).unparse()
3589        '!(O)'
3590        >>> ReqImpossible().unparse()
3591        'X'
3592        >>> r = ReqAny([ReqCapability('A'), ReqCapability('B'),
3593        ...     ReqCapability('C')])
3594        >>> rep = r.unparse()
3595        >>> rep
3596        '(A|B|C)'
3597        >>> back = pf.parseRequirement(rep)
3598        >>> back == r
3599        True
3600        """
3601        raise NotImplementedError("Requirement is an abstract class.")

Represents a precondition for traversing an edge or taking an action. This can be any boolean expression over Capability, mechanism (see MechanismName), and/or Token states that must obtain, with numerical values for the number of tokens required, and specific mechanism states or active capabilities necessary. For example, if the player needs either the wall-break capability or the wall-jump capability plus a balloon token, or for the switch mechanism to be on, you could represent that using:

ReqAny(
    ReqCapability('wall-break'),
    ReqAll(
        ReqCapability('wall-jump'),
        ReqTokens('balloon', 1)
    ),
    ReqMechanism('switch', 'on')
)

The subclasses define concrete requirements.

Note that mechanism names are searched for using lookupMechanism, starting from the DecisionIDs of the decisions on either end of the transition where a requirement is being checked. You may need to rename mechanisms to avoid a MechanismCollisionErrorif decisions on either end of a transition use the same mechanism name.

def satisfied( self, context: RequirementContext, dontRecurse: Optional[Set[Union[str, Tuple[int, str]]]] = None) -> bool:
3486    def satisfied(
3487        self,
3488        context: RequirementContext,
3489        dontRecurse: Optional[
3490            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
3491        ] = None
3492    ) -> bool:
3493        """
3494        This will return `True` if the requirement is satisfied in the
3495        given `RequirementContext`, resolving mechanisms from the
3496        context's set of decisions and graph, and respecting the
3497        context's equivalences. It returns `False` otherwise.
3498
3499        The `dontRecurse` set should be unspecified to start, and will
3500        be used to avoid infinite recursion in cases of circular
3501        equivalences (requirements are not considered satisfied by
3502        equivalence loops).
3503
3504        TODO: Examples
3505        """
3506        raise NotImplementedError(
3507            "Requirement is an abstract class and cannot be"
3508            " used directly."
3509        )

This will return True if the requirement is satisfied in the given RequirementContext, resolving mechanisms from the context's set of decisions and graph, and respecting the context's equivalences. It returns False otherwise.

The dontRecurse set should be unspecified to start, and will be used to avoid infinite recursion in cases of circular equivalences (requirements are not considered satisfied by equivalence loops).

TODO: Examples

def walk(self) -> Generator[Requirement, NoneType, NoneType]:
3521    def walk(self) -> Generator['Requirement', None, None]:
3522        """
3523        Yields every part of the requirement in depth-first traversal
3524        order.
3525        """
3526        raise NotImplementedError(
3527            "Requirement is an abstract class and cannot be walked."
3528        )

Yields every part of the requirement in depth-first traversal order.

def asEffectList(self) -> List[Effect]:
3530    def asEffectList(self) -> List[Effect]:
3531        """
3532        Transforms this `Requirement` into a list of `Effect`
3533        objects that gain the `Capability`, set the `Token` amounts, and
3534        set the `Mechanism` states mentioned by the requirement. The
3535        requirement must be either a `ReqTokens`, a `ReqCapability`, a
3536        `ReqMechanism`, or a `ReqAll` which includes nothing besides
3537        those types as sub-requirements. The token and capability
3538        requirements at the leaves of the tree will be collected into a
3539        list for the result (note that whether `ReqAny` or `ReqAll` is
3540        used is ignored, all of the tokens/capabilities/mechanisms
3541        mentioned are listed). For each `Capability` requirement a
3542        'gain' effect for that capability will be included. For each
3543        `Mechanism` or `Token` requirement, a 'set' effect for that
3544        mechanism state or token count will be included. Note that if
3545        the requirement has contradictory clauses (e.g., two different
3546        mechanism states) multiple effects which cancel each other out
3547        will be included. Also note that setting token amounts may end
3548        up decreasing them unnecessarily.
3549
3550        Raises a `TypeError` if this requirement is not suitable for
3551        transformation into an effect list.
3552        """
3553        raise NotImplementedError("Requirement is an abstract class.")

Transforms this Requirement into a list of Effect objects that gain the Capability, set the Token amounts, and set the Mechanism states mentioned by the requirement. The requirement must be either a ReqTokens, a ReqCapability, a ReqMechanism, or a ReqAll which includes nothing besides those types as sub-requirements. The token and capability requirements at the leaves of the tree will be collected into a list for the result (note that whether ReqAny or ReqAll is used is ignored, all of the tokens/capabilities/mechanisms mentioned are listed). For each Capability requirement a 'gain' effect for that capability will be included. For each Mechanism or Token requirement, a 'set' effect for that mechanism state or token count will be included. Note that if the requirement has contradictory clauses (e.g., two different mechanism states) multiple effects which cancel each other out will be included. Also note that setting token amounts may end up decreasing them unnecessarily.

Raises a TypeError if this requirement is not suitable for transformation into an effect list.

def flatten(self) -> Requirement:
3555    def flatten(self) -> 'Requirement':
3556        """
3557        Returns a simplified version of this requirement that merges
3558        multiple redundant layers of `ReqAny`/`ReqAll` into single
3559        `ReqAny`/`ReqAll` structures, including recursively. May return
3560        the original requirement if there's no simplification to be done.
3561
3562        Default implementation just returns `self`.
3563        """
3564        return self

Returns a simplified version of this requirement that merges multiple redundant layers of ReqAny/ReqAll into single ReqAny/ReqAll structures, including recursively. May return the original requirement if there's no simplification to be done.

Default implementation just returns self.

def unparse(self) -> str:
3566    def unparse(self) -> str:
3567        """
3568        Returns a string which would convert back into this `Requirement`
3569        object if you fed it to `parsing.ParseFormat.parseRequirement`.
3570
3571        TODO: Move this over into `parsing`?
3572
3573        Examples:
3574
3575        >>> r = ReqAny([
3576        ...     ReqCapability('capability'),
3577        ...     ReqTokens('token', 3),
3578        ...     ReqMechanism('mechanism', 'state')
3579        ... ])
3580        >>> rep = r.unparse()
3581        >>> rep
3582        '(capability|token*3|mechanism:state)'
3583        >>> from . import parsing
3584        >>> pf = parsing.ParseFormat()
3585        >>> back = pf.parseRequirement(rep)
3586        >>> back == r
3587        True
3588        >>> ReqNot(ReqNothing()).unparse()
3589        '!(O)'
3590        >>> ReqImpossible().unparse()
3591        'X'
3592        >>> r = ReqAny([ReqCapability('A'), ReqCapability('B'),
3593        ...     ReqCapability('C')])
3594        >>> rep = r.unparse()
3595        >>> rep
3596        '(A|B|C)'
3597        >>> back = pf.parseRequirement(rep)
3598        >>> back == r
3599        True
3600        """
3601        raise NotImplementedError("Requirement is an abstract class.")

Returns a string which would convert back into this Requirement object if you fed it to parsing.ParseFormat.parseRequirement.

TODO: Move this over into parsing?

Examples:

>>> r = ReqAny([
...     ReqCapability('capability'),
...     ReqTokens('token', 3),
...     ReqMechanism('mechanism', 'state')
... ])
>>> rep = r.unparse()
>>> rep
'(capability|token*3|mechanism:state)'
>>> from . import parsing
>>> pf = parsing.ParseFormat()
>>> back = pf.parseRequirement(rep)
>>> back == r
True
>>> ReqNot(ReqNothing()).unparse()
'!(O)'
>>> ReqImpossible().unparse()
'X'
>>> r = ReqAny([ReqCapability('A'), ReqCapability('B'),
...     ReqCapability('C')])
>>> rep = r.unparse()
>>> rep
'(A|B|C)'
>>> back = pf.parseRequirement(rep)
>>> back == r
True
class ReqAny(Requirement):
3604class ReqAny(Requirement):
3605    """
3606    A disjunction requirement satisfied when any one of its
3607    sub-requirements is satisfied.
3608    """
3609    def __init__(self, subs: Iterable[Requirement]) -> None:
3610        self.subs = list(subs)
3611
3612    def __hash__(self) -> int:
3613        result = 179843
3614        for sub in self.subs:
3615            result = 31 * (result + hash(sub))
3616        return result
3617
3618    def __eq__(self, other: Any) -> bool:
3619        return isinstance(other, ReqAny) and other.subs == self.subs
3620
3621    def __repr__(self):
3622        return "ReqAny(" + repr(self.subs) + ")"
3623
3624    def satisfied(
3625        self,
3626        context: RequirementContext,
3627        dontRecurse: Optional[
3628            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
3629        ] = None
3630    ) -> bool:
3631        """
3632        True as long as any one of the sub-requirements is satisfied.
3633        """
3634        return any(
3635            sub.satisfied(context, dontRecurse)
3636            for sub in self.subs
3637        )
3638
3639    def walk(self) -> Generator[Requirement, None, None]:
3640        yield self
3641        for sub in self.subs:
3642            yield from sub.walk()
3643
3644    def asEffectList(self) -> List[Effect]:
3645        """
3646        Raises a `TypeError` since disjunctions don't have a translation
3647        into a simple list of effects to satisfy them.
3648        """
3649        raise TypeError(
3650            "Cannot convert ReqAny into an effect list:"
3651            " contradictory token or mechanism requirements on"
3652            " different branches are not easy to synthesize."
3653        )
3654
3655    def flatten(self) -> Requirement:
3656        """
3657        Flattens this requirement by merging any sub-requirements which
3658        are also `ReqAny` instances into this one.
3659        """
3660        merged = []
3661        for sub in self.subs:
3662            flat = sub.flatten()
3663            if isinstance(flat, ReqAny):
3664                merged.extend(flat.subs)
3665            else:
3666                merged.append(flat)
3667
3668        return ReqAny(merged)
3669
3670    def unparse(self) -> str:
3671        return '(' + '|'.join(sub.unparse() for sub in self.subs) + ')'

A disjunction requirement satisfied when any one of its sub-requirements is satisfied.

ReqAny(subs: Iterable[Requirement])
3609    def __init__(self, subs: Iterable[Requirement]) -> None:
3610        self.subs = list(subs)
subs
def satisfied( self, context: RequirementContext, dontRecurse: Optional[Set[Union[str, Tuple[int, str]]]] = None) -> bool:
3624    def satisfied(
3625        self,
3626        context: RequirementContext,
3627        dontRecurse: Optional[
3628            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
3629        ] = None
3630    ) -> bool:
3631        """
3632        True as long as any one of the sub-requirements is satisfied.
3633        """
3634        return any(
3635            sub.satisfied(context, dontRecurse)
3636            for sub in self.subs
3637        )

True as long as any one of the sub-requirements is satisfied.

def walk(self) -> Generator[Requirement, NoneType, NoneType]:
3639    def walk(self) -> Generator[Requirement, None, None]:
3640        yield self
3641        for sub in self.subs:
3642            yield from sub.walk()

Yields every part of the requirement in depth-first traversal order.

def asEffectList(self) -> List[Effect]:
3644    def asEffectList(self) -> List[Effect]:
3645        """
3646        Raises a `TypeError` since disjunctions don't have a translation
3647        into a simple list of effects to satisfy them.
3648        """
3649        raise TypeError(
3650            "Cannot convert ReqAny into an effect list:"
3651            " contradictory token or mechanism requirements on"
3652            " different branches are not easy to synthesize."
3653        )

Raises a TypeError since disjunctions don't have a translation into a simple list of effects to satisfy them.

def flatten(self) -> Requirement:
3655    def flatten(self) -> Requirement:
3656        """
3657        Flattens this requirement by merging any sub-requirements which
3658        are also `ReqAny` instances into this one.
3659        """
3660        merged = []
3661        for sub in self.subs:
3662            flat = sub.flatten()
3663            if isinstance(flat, ReqAny):
3664                merged.extend(flat.subs)
3665            else:
3666                merged.append(flat)
3667
3668        return ReqAny(merged)

Flattens this requirement by merging any sub-requirements which are also ReqAny instances into this one.

def unparse(self) -> str:
3670    def unparse(self) -> str:
3671        return '(' + '|'.join(sub.unparse() for sub in self.subs) + ')'

Returns a string which would convert back into this Requirement object if you fed it to parsing.ParseFormat.parseRequirement.

TODO: Move this over into parsing?

Examples:

>>> r = ReqAny([
...     ReqCapability('capability'),
...     ReqTokens('token', 3),
...     ReqMechanism('mechanism', 'state')
... ])
>>> rep = r.unparse()
>>> rep
'(capability|token*3|mechanism:state)'
>>> from . import parsing
>>> pf = parsing.ParseFormat()
>>> back = pf.parseRequirement(rep)
>>> back == r
True
>>> ReqNot(ReqNothing()).unparse()
'!(O)'
>>> ReqImpossible().unparse()
'X'
>>> r = ReqAny([ReqCapability('A'), ReqCapability('B'),
...     ReqCapability('C')])
>>> rep = r.unparse()
>>> rep
'(A|B|C)'
>>> back = pf.parseRequirement(rep)
>>> back == r
True
class ReqAll(Requirement):
3674class ReqAll(Requirement):
3675    """
3676    A conjunction requirement satisfied when all of its sub-requirements
3677    are satisfied.
3678    """
3679    def __init__(self, subs: Iterable[Requirement]) -> None:
3680        self.subs = list(subs)
3681
3682    def __hash__(self) -> int:
3683        result = 182971
3684        for sub in self.subs:
3685            result = 17 * (result + hash(sub))
3686        return result
3687
3688    def __eq__(self, other: Any) -> bool:
3689        return isinstance(other, ReqAll) and other.subs == self.subs
3690
3691    def __repr__(self):
3692        return "ReqAll(" + repr(self.subs) + ")"
3693
3694    def satisfied(
3695        self,
3696        context: RequirementContext,
3697        dontRecurse: Optional[
3698            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
3699        ] = None
3700    ) -> bool:
3701        """
3702        True as long as all of the sub-requirements are satisfied.
3703        """
3704        return all(
3705            sub.satisfied(context, dontRecurse)
3706            for sub in self.subs
3707        )
3708
3709    def walk(self) -> Generator[Requirement, None, None]:
3710        yield self
3711        for sub in self.subs:
3712            yield from sub.walk()
3713
3714    def asEffectList(self) -> List[Effect]:
3715        """
3716        Returns a gain list composed by adding together the gain lists
3717        for each sub-requirement. Note that some types of requirement
3718        will raise a `TypeError` during this process if they appear as a
3719        sub-requirement.
3720        """
3721        result = []
3722        for sub in self.subs:
3723            result += sub.asEffectList()
3724
3725        return result
3726
3727    def flatten(self) -> Requirement:
3728        """
3729        Flattens this requirement by merging any sub-requirements which
3730        are also `ReqAll` instances into this one.
3731        """
3732        merged = []
3733        for sub in self.subs:
3734            flat = sub.flatten()
3735            if isinstance(flat, ReqAll):
3736                merged.extend(flat.subs)
3737            else:
3738                merged.append(flat)
3739
3740        return ReqAll(merged)
3741
3742    def unparse(self) -> str:
3743        return '(' + '&'.join(sub.unparse() for sub in self.subs) + ')'

A conjunction requirement satisfied when all of its sub-requirements are satisfied.

ReqAll(subs: Iterable[Requirement])
3679    def __init__(self, subs: Iterable[Requirement]) -> None:
3680        self.subs = list(subs)
subs
def satisfied( self, context: RequirementContext, dontRecurse: Optional[Set[Union[str, Tuple[int, str]]]] = None) -> bool:
3694    def satisfied(
3695        self,
3696        context: RequirementContext,
3697        dontRecurse: Optional[
3698            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
3699        ] = None
3700    ) -> bool:
3701        """
3702        True as long as all of the sub-requirements are satisfied.
3703        """
3704        return all(
3705            sub.satisfied(context, dontRecurse)
3706            for sub in self.subs
3707        )

True as long as all of the sub-requirements are satisfied.

def walk(self) -> Generator[Requirement, NoneType, NoneType]:
3709    def walk(self) -> Generator[Requirement, None, None]:
3710        yield self
3711        for sub in self.subs:
3712            yield from sub.walk()

Yields every part of the requirement in depth-first traversal order.

def asEffectList(self) -> List[Effect]:
3714    def asEffectList(self) -> List[Effect]:
3715        """
3716        Returns a gain list composed by adding together the gain lists
3717        for each sub-requirement. Note that some types of requirement
3718        will raise a `TypeError` during this process if they appear as a
3719        sub-requirement.
3720        """
3721        result = []
3722        for sub in self.subs:
3723            result += sub.asEffectList()
3724
3725        return result

Returns a gain list composed by adding together the gain lists for each sub-requirement. Note that some types of requirement will raise a TypeError during this process if they appear as a sub-requirement.

def flatten(self) -> Requirement:
3727    def flatten(self) -> Requirement:
3728        """
3729        Flattens this requirement by merging any sub-requirements which
3730        are also `ReqAll` instances into this one.
3731        """
3732        merged = []
3733        for sub in self.subs:
3734            flat = sub.flatten()
3735            if isinstance(flat, ReqAll):
3736                merged.extend(flat.subs)
3737            else:
3738                merged.append(flat)
3739
3740        return ReqAll(merged)

Flattens this requirement by merging any sub-requirements which are also ReqAll instances into this one.

def unparse(self) -> str:
3742    def unparse(self) -> str:
3743        return '(' + '&'.join(sub.unparse() for sub in self.subs) + ')'

Returns a string which would convert back into this Requirement object if you fed it to parsing.ParseFormat.parseRequirement.

TODO: Move this over into parsing?

Examples:

>>> r = ReqAny([
...     ReqCapability('capability'),
...     ReqTokens('token', 3),
...     ReqMechanism('mechanism', 'state')
... ])
>>> rep = r.unparse()
>>> rep
'(capability|token*3|mechanism:state)'
>>> from . import parsing
>>> pf = parsing.ParseFormat()
>>> back = pf.parseRequirement(rep)
>>> back == r
True
>>> ReqNot(ReqNothing()).unparse()
'!(O)'
>>> ReqImpossible().unparse()
'X'
>>> r = ReqAny([ReqCapability('A'), ReqCapability('B'),
...     ReqCapability('C')])
>>> rep = r.unparse()
>>> rep
'(A|B|C)'
>>> back = pf.parseRequirement(rep)
>>> back == r
True
class ReqNot(Requirement):
3746class ReqNot(Requirement):
3747    """
3748    A negation requirement satisfied when its sub-requirement is NOT
3749    satisfied.
3750    """
3751    def __init__(self, sub: Requirement) -> None:
3752        self.sub = sub
3753
3754    def __hash__(self) -> int:
3755        return 17293 + hash(self.sub)
3756
3757    def __eq__(self, other: Any) -> bool:
3758        return isinstance(other, ReqNot) and other.sub == self.sub
3759
3760    def __repr__(self):
3761        return "ReqNot(" + repr(self.sub) + ")"
3762
3763    def satisfied(
3764        self,
3765        context: RequirementContext,
3766        dontRecurse: Optional[
3767            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
3768        ] = None
3769    ) -> bool:
3770        """
3771        True as long as the sub-requirement is not satisfied.
3772        """
3773        return not self.sub.satisfied(context, dontRecurse)
3774
3775    def walk(self) -> Generator[Requirement, None, None]:
3776        yield self
3777        yield self.sub
3778
3779    def asEffectList(self) -> List[Effect]:
3780        """
3781        Raises a `TypeError` since understanding a `ReqNot` in terms of
3782        capabilities/tokens to be gained is not straightforward, and would
3783        need to be done relative to a game state in any case.
3784        """
3785        raise TypeError(
3786            "Cannot convert ReqNot into an effect list:"
3787            " capabilities or tokens would have to be lost, not gained to"
3788            " satisfy this requirement."
3789        )
3790
3791    def flatten(self) -> Requirement:
3792        return ReqNot(self.sub.flatten())
3793
3794    def unparse(self) -> str:
3795        return '!(' + self.sub.unparse() + ')'

A negation requirement satisfied when its sub-requirement is NOT satisfied.

ReqNot(sub: Requirement)
3751    def __init__(self, sub: Requirement) -> None:
3752        self.sub = sub
sub
def satisfied( self, context: RequirementContext, dontRecurse: Optional[Set[Union[str, Tuple[int, str]]]] = None) -> bool:
3763    def satisfied(
3764        self,
3765        context: RequirementContext,
3766        dontRecurse: Optional[
3767            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
3768        ] = None
3769    ) -> bool:
3770        """
3771        True as long as the sub-requirement is not satisfied.
3772        """
3773        return not self.sub.satisfied(context, dontRecurse)

True as long as the sub-requirement is not satisfied.

def walk(self) -> Generator[Requirement, NoneType, NoneType]:
3775    def walk(self) -> Generator[Requirement, None, None]:
3776        yield self
3777        yield self.sub

Yields every part of the requirement in depth-first traversal order.

def asEffectList(self) -> List[Effect]:
3779    def asEffectList(self) -> List[Effect]:
3780        """
3781        Raises a `TypeError` since understanding a `ReqNot` in terms of
3782        capabilities/tokens to be gained is not straightforward, and would
3783        need to be done relative to a game state in any case.
3784        """
3785        raise TypeError(
3786            "Cannot convert ReqNot into an effect list:"
3787            " capabilities or tokens would have to be lost, not gained to"
3788            " satisfy this requirement."
3789        )

Raises a TypeError since understanding a ReqNot in terms of capabilities/tokens to be gained is not straightforward, and would need to be done relative to a game state in any case.

def flatten(self) -> Requirement:
3791    def flatten(self) -> Requirement:
3792        return ReqNot(self.sub.flatten())

Returns a simplified version of this requirement that merges multiple redundant layers of ReqAny/ReqAll into single ReqAny/ReqAll structures, including recursively. May return the original requirement if there's no simplification to be done.

Default implementation just returns self.

def unparse(self) -> str:
3794    def unparse(self) -> str:
3795        return '!(' + self.sub.unparse() + ')'

Returns a string which would convert back into this Requirement object if you fed it to parsing.ParseFormat.parseRequirement.

TODO: Move this over into parsing?

Examples:

>>> r = ReqAny([
...     ReqCapability('capability'),
...     ReqTokens('token', 3),
...     ReqMechanism('mechanism', 'state')
... ])
>>> rep = r.unparse()
>>> rep
'(capability|token*3|mechanism:state)'
>>> from . import parsing
>>> pf = parsing.ParseFormat()
>>> back = pf.parseRequirement(rep)
>>> back == r
True
>>> ReqNot(ReqNothing()).unparse()
'!(O)'
>>> ReqImpossible().unparse()
'X'
>>> r = ReqAny([ReqCapability('A'), ReqCapability('B'),
...     ReqCapability('C')])
>>> rep = r.unparse()
>>> rep
'(A|B|C)'
>>> back = pf.parseRequirement(rep)
>>> back == r
True
class ReqCapability(Requirement):
3798class ReqCapability(Requirement):
3799    """
3800    A capability requirement is satisfied if the specified capability is
3801    possessed by the player according to the given state.
3802    """
3803    def __init__(self, capability: Capability) -> None:
3804        self.capability = capability
3805
3806    def __hash__(self) -> int:
3807        return 47923 + hash(self.capability)
3808
3809    def __eq__(self, other: Any) -> bool:
3810        return (
3811            isinstance(other, ReqCapability)
3812        and other.capability == self.capability
3813        )
3814
3815    def __repr__(self):
3816        return "ReqCapability(" + repr(self.capability) + ")"
3817
3818    def satisfied(
3819        self,
3820        context: RequirementContext,
3821        dontRecurse: Optional[
3822            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
3823        ] = None
3824    ) -> bool:
3825        return hasCapabilityOrEquivalent(
3826            self.capability,
3827            context,
3828            dontRecurse
3829        )
3830
3831    def walk(self) -> Generator[Requirement, None, None]:
3832        yield self
3833
3834    def asEffectList(self) -> List[Effect]:
3835        """
3836        Returns a list containing a single 'gain' effect which grants
3837        the required capability.
3838        """
3839        return [effect(gain=self.capability)]
3840
3841    def unparse(self) -> str:
3842        return self.capability

A capability requirement is satisfied if the specified capability is possessed by the player according to the given state.

ReqCapability(capability: str)
3803    def __init__(self, capability: Capability) -> None:
3804        self.capability = capability
capability
def satisfied( self, context: RequirementContext, dontRecurse: Optional[Set[Union[str, Tuple[int, str]]]] = None) -> bool:
3818    def satisfied(
3819        self,
3820        context: RequirementContext,
3821        dontRecurse: Optional[
3822            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
3823        ] = None
3824    ) -> bool:
3825        return hasCapabilityOrEquivalent(
3826            self.capability,
3827            context,
3828            dontRecurse
3829        )

This will return True if the requirement is satisfied in the given RequirementContext, resolving mechanisms from the context's set of decisions and graph, and respecting the context's equivalences. It returns False otherwise.

The dontRecurse set should be unspecified to start, and will be used to avoid infinite recursion in cases of circular equivalences (requirements are not considered satisfied by equivalence loops).

TODO: Examples

def walk(self) -> Generator[Requirement, NoneType, NoneType]:
3831    def walk(self) -> Generator[Requirement, None, None]:
3832        yield self

Yields every part of the requirement in depth-first traversal order.

def asEffectList(self) -> List[Effect]:
3834    def asEffectList(self) -> List[Effect]:
3835        """
3836        Returns a list containing a single 'gain' effect which grants
3837        the required capability.
3838        """
3839        return [effect(gain=self.capability)]

Returns a list containing a single 'gain' effect which grants the required capability.

def unparse(self) -> str:
3841    def unparse(self) -> str:
3842        return self.capability

Returns a string which would convert back into this Requirement object if you fed it to parsing.ParseFormat.parseRequirement.

TODO: Move this over into parsing?

Examples:

>>> r = ReqAny([
...     ReqCapability('capability'),
...     ReqTokens('token', 3),
...     ReqMechanism('mechanism', 'state')
... ])
>>> rep = r.unparse()
>>> rep
'(capability|token*3|mechanism:state)'
>>> from . import parsing
>>> pf = parsing.ParseFormat()
>>> back = pf.parseRequirement(rep)
>>> back == r
True
>>> ReqNot(ReqNothing()).unparse()
'!(O)'
>>> ReqImpossible().unparse()
'X'
>>> r = ReqAny([ReqCapability('A'), ReqCapability('B'),
...     ReqCapability('C')])
>>> rep = r.unparse()
>>> rep
'(A|B|C)'
>>> back = pf.parseRequirement(rep)
>>> back == r
True
Inherited Members
Requirement
flatten
class ReqTokens(Requirement):
3845class ReqTokens(Requirement):
3846    """
3847    A token requirement satisfied if the player possesses at least a
3848    certain number of a given type of token.
3849
3850    Note that checking the satisfaction of individual doors in a specific
3851    state is not enough to guarantee they're jointly traversable, since
3852    if a series of doors requires the same kind of token and they use up
3853    those tokens, further logic is needed to understand that as the
3854    tokens get used up, their requirements may no longer be satisfied.
3855
3856    Also note that a requirement for tokens does NOT mean that tokens
3857    will be subtracted when traversing the door (you can have re-usable
3858    tokens after all). To implement a token cost, use both a requirement
3859    and a 'lose' effect.
3860    """
3861    def __init__(self, tokenType: Token, cost: TokenCount) -> None:
3862        self.tokenType = tokenType
3863        self.cost = cost
3864
3865    def __hash__(self) -> int:
3866        return (17 * hash(self.tokenType)) + (11 * self.cost)
3867
3868    def __eq__(self, other: Any) -> bool:
3869        return (
3870            isinstance(other, ReqTokens)
3871        and other.tokenType == self.tokenType
3872        and other.cost == self.cost
3873        )
3874
3875    def __repr__(self):
3876        return f"ReqTokens({repr(self.tokenType)}, {repr(self.cost)})"
3877
3878    def satisfied(
3879        self,
3880        context: RequirementContext,
3881        dontRecurse: Optional[
3882            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
3883        ] = None
3884    ) -> bool:
3885        return combinedTokenCount(context.state, self.tokenType) >= self.cost
3886
3887    def walk(self) -> Generator[Requirement, None, None]:
3888        yield self
3889
3890    def asEffectList(self) -> List[Effect]:
3891        """
3892        Returns a list containing a single 'set' effect which sets the
3893        required tokens (note that this may unnecessarily subtract
3894        tokens if the state had more than enough tokens beforehand).
3895        """
3896        return [effect(set=(self.tokenType, self.cost))]
3897
3898    def unparse(self) -> str:
3899        return f'{self.tokenType}*{self.cost}'

A token requirement satisfied if the player possesses at least a certain number of a given type of token.

Note that checking the satisfaction of individual doors in a specific state is not enough to guarantee they're jointly traversable, since if a series of doors requires the same kind of token and they use up those tokens, further logic is needed to understand that as the tokens get used up, their requirements may no longer be satisfied.

Also note that a requirement for tokens does NOT mean that tokens will be subtracted when traversing the door (you can have re-usable tokens after all). To implement a token cost, use both a requirement and a 'lose' effect.

ReqTokens(tokenType: str, cost: int)
3861    def __init__(self, tokenType: Token, cost: TokenCount) -> None:
3862        self.tokenType = tokenType
3863        self.cost = cost
tokenType
cost
def satisfied( self, context: RequirementContext, dontRecurse: Optional[Set[Union[str, Tuple[int, str]]]] = None) -> bool:
3878    def satisfied(
3879        self,
3880        context: RequirementContext,
3881        dontRecurse: Optional[
3882            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
3883        ] = None
3884    ) -> bool:
3885        return combinedTokenCount(context.state, self.tokenType) >= self.cost

This will return True if the requirement is satisfied in the given RequirementContext, resolving mechanisms from the context's set of decisions and graph, and respecting the context's equivalences. It returns False otherwise.

The dontRecurse set should be unspecified to start, and will be used to avoid infinite recursion in cases of circular equivalences (requirements are not considered satisfied by equivalence loops).

TODO: Examples

def walk(self) -> Generator[Requirement, NoneType, NoneType]:
3887    def walk(self) -> Generator[Requirement, None, None]:
3888        yield self

Yields every part of the requirement in depth-first traversal order.

def asEffectList(self) -> List[Effect]:
3890    def asEffectList(self) -> List[Effect]:
3891        """
3892        Returns a list containing a single 'set' effect which sets the
3893        required tokens (note that this may unnecessarily subtract
3894        tokens if the state had more than enough tokens beforehand).
3895        """
3896        return [effect(set=(self.tokenType, self.cost))]

Returns a list containing a single 'set' effect which sets the required tokens (note that this may unnecessarily subtract tokens if the state had more than enough tokens beforehand).

def unparse(self) -> str:
3898    def unparse(self) -> str:
3899        return f'{self.tokenType}*{self.cost}'

Returns a string which would convert back into this Requirement object if you fed it to parsing.ParseFormat.parseRequirement.

TODO: Move this over into parsing?

Examples:

>>> r = ReqAny([
...     ReqCapability('capability'),
...     ReqTokens('token', 3),
...     ReqMechanism('mechanism', 'state')
... ])
>>> rep = r.unparse()
>>> rep
'(capability|token*3|mechanism:state)'
>>> from . import parsing
>>> pf = parsing.ParseFormat()
>>> back = pf.parseRequirement(rep)
>>> back == r
True
>>> ReqNot(ReqNothing()).unparse()
'!(O)'
>>> ReqImpossible().unparse()
'X'
>>> r = ReqAny([ReqCapability('A'), ReqCapability('B'),
...     ReqCapability('C')])
>>> rep = r.unparse()
>>> rep
'(A|B|C)'
>>> back = pf.parseRequirement(rep)
>>> back == r
True
Inherited Members
Requirement
flatten
class ReqMechanism(Requirement):
3902class ReqMechanism(Requirement):
3903    """
3904    A mechanism requirement satisfied if the specified mechanism is in
3905    the specified state. The mechanism is specified by name and a lookup
3906    on that name will be performed when assessing the requirement, based
3907    on the specific position at which the requirement applies. However,
3908    if a `where` value is supplied, the lookup on the mechanism name will
3909    always start from that decision, regardless of where the requirement
3910    is being evaluated.
3911    """
3912    def __init__(
3913        self,
3914        mechanism: AnyMechanismSpecifier,
3915        state: MechanismState,
3916    ) -> None:
3917        self.mechanism = mechanism
3918        self.reqState = state
3919
3920        # Normalize mechanism specifiers without any position information
3921        if isinstance(mechanism, tuple):
3922            if len(mechanism) != 4:
3923                raise ValueError(
3924                    f"Mechanism specifier must have 4 parts if it's a"
3925                    f" tuple. (Got: {mechanism})."
3926                )
3927            elif all(x is None for x in mechanism[:3]):
3928                self.mechanism = mechanism[3]
3929
3930    def __hash__(self) -> int:
3931        return (
3932            (11 * hash(self.mechanism))
3933          + (31 * hash(self.reqState))
3934        )
3935
3936    def __eq__(self, other: Any) -> bool:
3937        return (
3938            isinstance(other, ReqMechanism)
3939        and other.mechanism == self.mechanism
3940        and other.reqState == self.reqState
3941        )
3942
3943    def __repr__(self):
3944        mRep = repr(self.mechanism)
3945        sRep = repr(self.reqState)
3946        return f"ReqMechanism({mRep}, {sRep})"
3947
3948    def satisfied(
3949        self,
3950        context: RequirementContext,
3951        dontRecurse: Optional[
3952            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
3953        ] = None
3954    ) -> bool:
3955        return mechanismInStateOrEquivalent(
3956            self.mechanism,
3957            self.reqState,
3958            context,
3959            dontRecurse
3960        )
3961
3962    def walk(self) -> Generator[Requirement, None, None]:
3963        yield self
3964
3965    def asEffectList(self) -> List[Effect]:
3966        """
3967        Returns a list containing a single 'set' effect which sets the
3968        required mechanism to the required state.
3969        """
3970        return [effect(set=(self.mechanism, self.reqState))]
3971
3972    def unparse(self) -> str:
3973        if isinstance(self.mechanism, (MechanismID, MechanismName)):
3974            return f'{self.mechanism}:{self.reqState}'
3975        else:  # Must be a MechanismSpecifier
3976            # TODO: This elsewhere!
3977            domain, zone, decision, mechanism = self.mechanism
3978            mspec = ''
3979            if domain is not None:
3980                mspec += domain + '//'
3981            if zone is not None:
3982                mspec += zone + '::'
3983            if decision is not None:
3984                mspec += decision + '::'
3985            mspec += mechanism
3986            return f'{mspec}:{self.reqState}'

A mechanism requirement satisfied if the specified mechanism is in the specified state. The mechanism is specified by name and a lookup on that name will be performed when assessing the requirement, based on the specific position at which the requirement applies. However, if a where value is supplied, the lookup on the mechanism name will always start from that decision, regardless of where the requirement is being evaluated.

ReqMechanism( mechanism: Union[int, str, MechanismSpecifier], state: str)
3912    def __init__(
3913        self,
3914        mechanism: AnyMechanismSpecifier,
3915        state: MechanismState,
3916    ) -> None:
3917        self.mechanism = mechanism
3918        self.reqState = state
3919
3920        # Normalize mechanism specifiers without any position information
3921        if isinstance(mechanism, tuple):
3922            if len(mechanism) != 4:
3923                raise ValueError(
3924                    f"Mechanism specifier must have 4 parts if it's a"
3925                    f" tuple. (Got: {mechanism})."
3926                )
3927            elif all(x is None for x in mechanism[:3]):
3928                self.mechanism = mechanism[3]
mechanism
reqState
def satisfied( self, context: RequirementContext, dontRecurse: Optional[Set[Union[str, Tuple[int, str]]]] = None) -> bool:
3948    def satisfied(
3949        self,
3950        context: RequirementContext,
3951        dontRecurse: Optional[
3952            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
3953        ] = None
3954    ) -> bool:
3955        return mechanismInStateOrEquivalent(
3956            self.mechanism,
3957            self.reqState,
3958            context,
3959            dontRecurse
3960        )

This will return True if the requirement is satisfied in the given RequirementContext, resolving mechanisms from the context's set of decisions and graph, and respecting the context's equivalences. It returns False otherwise.

The dontRecurse set should be unspecified to start, and will be used to avoid infinite recursion in cases of circular equivalences (requirements are not considered satisfied by equivalence loops).

TODO: Examples

def walk(self) -> Generator[Requirement, NoneType, NoneType]:
3962    def walk(self) -> Generator[Requirement, None, None]:
3963        yield self

Yields every part of the requirement in depth-first traversal order.

def asEffectList(self) -> List[Effect]:
3965    def asEffectList(self) -> List[Effect]:
3966        """
3967        Returns a list containing a single 'set' effect which sets the
3968        required mechanism to the required state.
3969        """
3970        return [effect(set=(self.mechanism, self.reqState))]

Returns a list containing a single 'set' effect which sets the required mechanism to the required state.

def unparse(self) -> str:
3972    def unparse(self) -> str:
3973        if isinstance(self.mechanism, (MechanismID, MechanismName)):
3974            return f'{self.mechanism}:{self.reqState}'
3975        else:  # Must be a MechanismSpecifier
3976            # TODO: This elsewhere!
3977            domain, zone, decision, mechanism = self.mechanism
3978            mspec = ''
3979            if domain is not None:
3980                mspec += domain + '//'
3981            if zone is not None:
3982                mspec += zone + '::'
3983            if decision is not None:
3984                mspec += decision + '::'
3985            mspec += mechanism
3986            return f'{mspec}:{self.reqState}'

Returns a string which would convert back into this Requirement object if you fed it to parsing.ParseFormat.parseRequirement.

TODO: Move this over into parsing?

Examples:

>>> r = ReqAny([
...     ReqCapability('capability'),
...     ReqTokens('token', 3),
...     ReqMechanism('mechanism', 'state')
... ])
>>> rep = r.unparse()
>>> rep
'(capability|token*3|mechanism:state)'
>>> from . import parsing
>>> pf = parsing.ParseFormat()
>>> back = pf.parseRequirement(rep)
>>> back == r
True
>>> ReqNot(ReqNothing()).unparse()
'!(O)'
>>> ReqImpossible().unparse()
'X'
>>> r = ReqAny([ReqCapability('A'), ReqCapability('B'),
...     ReqCapability('C')])
>>> rep = r.unparse()
>>> rep
'(A|B|C)'
>>> back = pf.parseRequirement(rep)
>>> back == r
True
Inherited Members
Requirement
flatten
class ReqLevel(Requirement):
3989class ReqLevel(Requirement):
3990    """
3991    A tag requirement satisfied if a specific skill is at or above the
3992    specified level.
3993    """
3994    def __init__(
3995        self,
3996        skill: Skill,
3997        minLevel: Level,
3998    ) -> None:
3999        self.skill = skill
4000        self.minLevel = minLevel
4001
4002    def __hash__(self) -> int:
4003        return (
4004            (79 * hash(self.skill))
4005          + (55 * hash(self.minLevel))
4006        )
4007
4008    def __eq__(self, other: Any) -> bool:
4009        return (
4010            isinstance(other, ReqLevel)
4011        and other.skill == self.skill
4012        and other.minLevel == self.minLevel
4013        )
4014
4015    def __repr__(self):
4016        sRep = repr(self.skill)
4017        lRep = repr(self.minLevel)
4018        return f"ReqLevel({sRep}, {lRep})"
4019
4020    def satisfied(
4021        self,
4022        context: RequirementContext,
4023        dontRecurse: Optional[
4024            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
4025        ] = None
4026    ) -> bool:
4027        return getSkillLevel(context.state, self.skill) >= self.minLevel
4028
4029    def walk(self) -> Generator[Requirement, None, None]:
4030        yield self
4031
4032    def asEffectList(self) -> List[Effect]:
4033        """
4034        Returns a list containing a single 'set' effect which sets the
4035        required skill to the minimum required level. Note that this may
4036        reduce a skill level that was more than sufficient to meet the
4037        requirement.
4038        """
4039        return [effect(set=("skill", self.skill, self.minLevel))]
4040
4041    def unparse(self) -> str:
4042        return f'{self.skill}^{self.minLevel}'

A tag requirement satisfied if a specific skill is at or above the specified level.

ReqLevel(skill: str, minLevel: int)
3994    def __init__(
3995        self,
3996        skill: Skill,
3997        minLevel: Level,
3998    ) -> None:
3999        self.skill = skill
4000        self.minLevel = minLevel
skill
minLevel
def satisfied( self, context: RequirementContext, dontRecurse: Optional[Set[Union[str, Tuple[int, str]]]] = None) -> bool:
4020    def satisfied(
4021        self,
4022        context: RequirementContext,
4023        dontRecurse: Optional[
4024            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
4025        ] = None
4026    ) -> bool:
4027        return getSkillLevel(context.state, self.skill) >= self.minLevel

This will return True if the requirement is satisfied in the given RequirementContext, resolving mechanisms from the context's set of decisions and graph, and respecting the context's equivalences. It returns False otherwise.

The dontRecurse set should be unspecified to start, and will be used to avoid infinite recursion in cases of circular equivalences (requirements are not considered satisfied by equivalence loops).

TODO: Examples

def walk(self) -> Generator[Requirement, NoneType, NoneType]:
4029    def walk(self) -> Generator[Requirement, None, None]:
4030        yield self

Yields every part of the requirement in depth-first traversal order.

def asEffectList(self) -> List[Effect]:
4032    def asEffectList(self) -> List[Effect]:
4033        """
4034        Returns a list containing a single 'set' effect which sets the
4035        required skill to the minimum required level. Note that this may
4036        reduce a skill level that was more than sufficient to meet the
4037        requirement.
4038        """
4039        return [effect(set=("skill", self.skill, self.minLevel))]

Returns a list containing a single 'set' effect which sets the required skill to the minimum required level. Note that this may reduce a skill level that was more than sufficient to meet the requirement.

def unparse(self) -> str:
4041    def unparse(self) -> str:
4042        return f'{self.skill}^{self.minLevel}'

Returns a string which would convert back into this Requirement object if you fed it to parsing.ParseFormat.parseRequirement.

TODO: Move this over into parsing?

Examples:

>>> r = ReqAny([
...     ReqCapability('capability'),
...     ReqTokens('token', 3),
...     ReqMechanism('mechanism', 'state')
... ])
>>> rep = r.unparse()
>>> rep
'(capability|token*3|mechanism:state)'
>>> from . import parsing
>>> pf = parsing.ParseFormat()
>>> back = pf.parseRequirement(rep)
>>> back == r
True
>>> ReqNot(ReqNothing()).unparse()
'!(O)'
>>> ReqImpossible().unparse()
'X'
>>> r = ReqAny([ReqCapability('A'), ReqCapability('B'),
...     ReqCapability('C')])
>>> rep = r.unparse()
>>> rep
'(A|B|C)'
>>> back = pf.parseRequirement(rep)
>>> back == r
True
Inherited Members
Requirement
flatten
class ReqTag(Requirement):
4045class ReqTag(Requirement):
4046    """
4047    A tag requirement satisfied if there is any active decision that has
4048    the specified value for the given tag (default value is 1 for tags
4049    where a value wasn't specified). Zone tags also satisfy the
4050    requirement if they're applied to zones that include active
4051    decisions.
4052    """
4053    def __init__(
4054        self,
4055        tag: "Tag",
4056        value: "TagValue",
4057    ) -> None:
4058        self.tag = tag
4059        self.value = value
4060
4061    def __hash__(self) -> int:
4062        return (
4063            (71 * hash(self.tag))
4064          + (43 * hash(self.value))
4065        )
4066
4067    def __eq__(self, other: Any) -> bool:
4068        return (
4069            isinstance(other, ReqTag)
4070        and other.tag == self.tag
4071        and other.value == self.value
4072        )
4073
4074    def __repr__(self):
4075        tRep = repr(self.tag)
4076        vRep = repr(self.value)
4077        return f"ReqTag({tRep}, {vRep})"
4078
4079    def satisfied(
4080        self,
4081        context: RequirementContext,
4082        dontRecurse: Optional[
4083            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
4084        ] = None
4085    ) -> bool:
4086        active = combinedDecisionSet(context.state)
4087        graph = context.graph
4088        zones = set()
4089        for decision in active:
4090            tags = graph.decisionTags(decision)
4091            if self.tag in tags and tags[self.tag] == self.value:
4092                return True
4093            zones |= graph.zoneAncestors(decision)
4094        for zone in zones:
4095            zTags = graph.zoneTags(zone)
4096            if self.tag in zTags and zTags[self.tag] == self.value:
4097                return True
4098
4099        return False
4100
4101    def walk(self) -> Generator[Requirement, None, None]:
4102        yield self
4103
4104    def asEffectList(self) -> List[Effect]:
4105        """
4106        Returns a list containing a single 'set' effect which sets the
4107        required mechanism to the required state.
4108        """
4109        raise TypeError(
4110            "Cannot convert ReqTag into an effect list:"
4111            " effects cannot apply/remove/change tags"
4112        )
4113
4114    def unparse(self) -> str:
4115        return f'{self.tag}~{self.value!r}'

A tag requirement satisfied if there is any active decision that has the specified value for the given tag (default value is 1 for tags where a value wasn't specified). Zone tags also satisfy the requirement if they're applied to zones that include active decisions.

ReqTag( tag: str, value: Union[bool, int, float, str, list, dict, NoneType, Requirement, List[Union[Challenge, Effect, Condition]]])
4053    def __init__(
4054        self,
4055        tag: "Tag",
4056        value: "TagValue",
4057    ) -> None:
4058        self.tag = tag
4059        self.value = value
tag
value
def satisfied( self, context: RequirementContext, dontRecurse: Optional[Set[Union[str, Tuple[int, str]]]] = None) -> bool:
4079    def satisfied(
4080        self,
4081        context: RequirementContext,
4082        dontRecurse: Optional[
4083            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
4084        ] = None
4085    ) -> bool:
4086        active = combinedDecisionSet(context.state)
4087        graph = context.graph
4088        zones = set()
4089        for decision in active:
4090            tags = graph.decisionTags(decision)
4091            if self.tag in tags and tags[self.tag] == self.value:
4092                return True
4093            zones |= graph.zoneAncestors(decision)
4094        for zone in zones:
4095            zTags = graph.zoneTags(zone)
4096            if self.tag in zTags and zTags[self.tag] == self.value:
4097                return True
4098
4099        return False

This will return True if the requirement is satisfied in the given RequirementContext, resolving mechanisms from the context's set of decisions and graph, and respecting the context's equivalences. It returns False otherwise.

The dontRecurse set should be unspecified to start, and will be used to avoid infinite recursion in cases of circular equivalences (requirements are not considered satisfied by equivalence loops).

TODO: Examples

def walk(self) -> Generator[Requirement, NoneType, NoneType]:
4101    def walk(self) -> Generator[Requirement, None, None]:
4102        yield self

Yields every part of the requirement in depth-first traversal order.

def asEffectList(self) -> List[Effect]:
4104    def asEffectList(self) -> List[Effect]:
4105        """
4106        Returns a list containing a single 'set' effect which sets the
4107        required mechanism to the required state.
4108        """
4109        raise TypeError(
4110            "Cannot convert ReqTag into an effect list:"
4111            " effects cannot apply/remove/change tags"
4112        )

Returns a list containing a single 'set' effect which sets the required mechanism to the required state.

def unparse(self) -> str:
4114    def unparse(self) -> str:
4115        return f'{self.tag}~{self.value!r}'

Returns a string which would convert back into this Requirement object if you fed it to parsing.ParseFormat.parseRequirement.

TODO: Move this over into parsing?

Examples:

>>> r = ReqAny([
...     ReqCapability('capability'),
...     ReqTokens('token', 3),
...     ReqMechanism('mechanism', 'state')
... ])
>>> rep = r.unparse()
>>> rep
'(capability|token*3|mechanism:state)'
>>> from . import parsing
>>> pf = parsing.ParseFormat()
>>> back = pf.parseRequirement(rep)
>>> back == r
True
>>> ReqNot(ReqNothing()).unparse()
'!(O)'
>>> ReqImpossible().unparse()
'X'
>>> r = ReqAny([ReqCapability('A'), ReqCapability('B'),
...     ReqCapability('C')])
>>> rep = r.unparse()
>>> rep
'(A|B|C)'
>>> back = pf.parseRequirement(rep)
>>> back == r
True
Inherited Members
Requirement
flatten
class ReqNothing(Requirement):
4118class ReqNothing(Requirement):
4119    """
4120    A requirement representing that something doesn't actually have a
4121    requirement. This requirement is always satisfied.
4122    """
4123    def __hash__(self) -> int:
4124        return 127942
4125
4126    def __eq__(self, other: Any) -> bool:
4127        return isinstance(other, ReqNothing)
4128
4129    def __repr__(self):
4130        return "ReqNothing()"
4131
4132    def satisfied(
4133        self,
4134        context: RequirementContext,
4135        dontRecurse: Optional[
4136            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
4137        ] = None
4138    ) -> bool:
4139        return True
4140
4141    def walk(self) -> Generator[Requirement, None, None]:
4142        yield self
4143
4144    def asEffectList(self) -> List[Effect]:
4145        """
4146        Returns an empty list, since nothing is required.
4147        """
4148        return []
4149
4150    def unparse(self) -> str:
4151        return 'O'

A requirement representing that something doesn't actually have a requirement. This requirement is always satisfied.

def satisfied( self, context: RequirementContext, dontRecurse: Optional[Set[Union[str, Tuple[int, str]]]] = None) -> bool:
4132    def satisfied(
4133        self,
4134        context: RequirementContext,
4135        dontRecurse: Optional[
4136            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
4137        ] = None
4138    ) -> bool:
4139        return True

This will return True if the requirement is satisfied in the given RequirementContext, resolving mechanisms from the context's set of decisions and graph, and respecting the context's equivalences. It returns False otherwise.

The dontRecurse set should be unspecified to start, and will be used to avoid infinite recursion in cases of circular equivalences (requirements are not considered satisfied by equivalence loops).

TODO: Examples

def walk(self) -> Generator[Requirement, NoneType, NoneType]:
4141    def walk(self) -> Generator[Requirement, None, None]:
4142        yield self

Yields every part of the requirement in depth-first traversal order.

def asEffectList(self) -> List[Effect]:
4144    def asEffectList(self) -> List[Effect]:
4145        """
4146        Returns an empty list, since nothing is required.
4147        """
4148        return []

Returns an empty list, since nothing is required.

def unparse(self) -> str:
4150    def unparse(self) -> str:
4151        return 'O'

Returns a string which would convert back into this Requirement object if you fed it to parsing.ParseFormat.parseRequirement.

TODO: Move this over into parsing?

Examples:

>>> r = ReqAny([
...     ReqCapability('capability'),
...     ReqTokens('token', 3),
...     ReqMechanism('mechanism', 'state')
... ])
>>> rep = r.unparse()
>>> rep
'(capability|token*3|mechanism:state)'
>>> from . import parsing
>>> pf = parsing.ParseFormat()
>>> back = pf.parseRequirement(rep)
>>> back == r
True
>>> ReqNot(ReqNothing()).unparse()
'!(O)'
>>> ReqImpossible().unparse()
'X'
>>> r = ReqAny([ReqCapability('A'), ReqCapability('B'),
...     ReqCapability('C')])
>>> rep = r.unparse()
>>> rep
'(A|B|C)'
>>> back = pf.parseRequirement(rep)
>>> back == r
True
Inherited Members
Requirement
flatten
class ReqImpossible(Requirement):
4154class ReqImpossible(Requirement):
4155    """
4156    A requirement representing that something is impossible. This
4157    requirement is never satisfied.
4158    """
4159    def __hash__(self) -> int:
4160        return 478743
4161
4162    def __eq__(self, other: Any) -> bool:
4163        return isinstance(other, ReqImpossible)
4164
4165    def __repr__(self):
4166        return "ReqImpossible()"
4167
4168    def satisfied(
4169        self,
4170        context: RequirementContext,
4171        dontRecurse: Optional[
4172            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
4173        ] = None
4174    ) -> bool:
4175        return False
4176
4177    def walk(self) -> Generator[Requirement, None, None]:
4178        yield self
4179
4180    def asEffectList(self) -> List[Effect]:
4181        """
4182        Raises a `TypeError` since a `ReqImpossible` cannot be converted
4183        into an effect which would allow the transition to be taken.
4184        """
4185        raise TypeError(
4186            "Cannot convert ReqImpossible into an effect list:"
4187            " there are no powers or tokens which could be gained to"
4188            " satisfy this requirement."
4189        )
4190
4191    def unparse(self) -> str:
4192        return 'X'

A requirement representing that something is impossible. This requirement is never satisfied.

def satisfied( self, context: RequirementContext, dontRecurse: Optional[Set[Union[str, Tuple[int, str]]]] = None) -> bool:
4168    def satisfied(
4169        self,
4170        context: RequirementContext,
4171        dontRecurse: Optional[
4172            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
4173        ] = None
4174    ) -> bool:
4175        return False

This will return True if the requirement is satisfied in the given RequirementContext, resolving mechanisms from the context's set of decisions and graph, and respecting the context's equivalences. It returns False otherwise.

The dontRecurse set should be unspecified to start, and will be used to avoid infinite recursion in cases of circular equivalences (requirements are not considered satisfied by equivalence loops).

TODO: Examples

def walk(self) -> Generator[Requirement, NoneType, NoneType]:
4177    def walk(self) -> Generator[Requirement, None, None]:
4178        yield self

Yields every part of the requirement in depth-first traversal order.

def asEffectList(self) -> List[Effect]:
4180    def asEffectList(self) -> List[Effect]:
4181        """
4182        Raises a `TypeError` since a `ReqImpossible` cannot be converted
4183        into an effect which would allow the transition to be taken.
4184        """
4185        raise TypeError(
4186            "Cannot convert ReqImpossible into an effect list:"
4187            " there are no powers or tokens which could be gained to"
4188            " satisfy this requirement."
4189        )

Raises a TypeError since a ReqImpossible cannot be converted into an effect which would allow the transition to be taken.

def unparse(self) -> str:
4191    def unparse(self) -> str:
4192        return 'X'

Returns a string which would convert back into this Requirement object if you fed it to parsing.ParseFormat.parseRequirement.

TODO: Move this over into parsing?

Examples:

>>> r = ReqAny([
...     ReqCapability('capability'),
...     ReqTokens('token', 3),
...     ReqMechanism('mechanism', 'state')
... ])
>>> rep = r.unparse()
>>> rep
'(capability|token*3|mechanism:state)'
>>> from . import parsing
>>> pf = parsing.ParseFormat()
>>> back = pf.parseRequirement(rep)
>>> back == r
True
>>> ReqNot(ReqNothing()).unparse()
'!(O)'
>>> ReqImpossible().unparse()
'X'
>>> r = ReqAny([ReqCapability('A'), ReqCapability('B'),
...     ReqCapability('C')])
>>> rep = r.unparse()
>>> rep
'(A|B|C)'
>>> back = pf.parseRequirement(rep)
>>> back == r
True
Inherited Members
Requirement
flatten
Equivalences = typing.Dict[typing.Union[str, typing.Tuple[int, str]], typing.Set[Requirement]]

An Equivalences dictionary maps Capability names and/or (MechanismID, MechanismState) pairs to Requirement objects, indicating that that single capability or mechanism state should be considered active if the specified requirement is met. Note that this can lead to multiple states of the same mechanism being effectively active at once if a state other than the current state is active via an equivalence.

When a circular dependency is created via equivalences, the capability or mechanism state in question is considered inactive when the circular dependency on it comes up, but the equivalence may still succeed (if it uses a disjunction, for example).

Tag: TypeAlias = str

A type alias: tags are strings.

A tag is an arbitrary string key attached to a decision or transition, with an associated value (default 1 to just mean "present").

Meanings are left up to the map-maker, but some conventions include:

TODO: Actually use these conventions, or abandon them

  • 'hard' indicates that an edge is non-trivial to navigate. An annotation starting with 'fail:' can be used to name another edge which would be traversed instead if the player fails to navigate the edge (e.g., a difficult series of platforms with a pit below that takes you to another decision). This is of course entirely subjective.
  • 'false' indicates that an edge doesn't actually exist, although it appears to. This tag is added in the same exploration step that requirements are updated (normally to ReqImpossible) to indicate that although the edge appeared to be traversable, it wasn't. This distinguishes that case from a case where edge requirements actually change.
  • 'error' indicates that an edge does not actually exist, and it's different than 'false' because it indicates an error on the player's part rather than intentional deception by the game (another subjective distinction). It can also be used with a colon and another tag to indicate that that tag was applied in error (e.g., a ledge thought to be too high was not actually too high). This should be used sparingly, because in most cases capturing the player's perception of the world is what's desired. This is normally applied in the step before an edge is removed from the graph.
  • 'hidden' indicates that an edge is non-trivial to perceive. Again this is subjective. 'hinted' can be used as well to indicate that despite being obfuscated, there are hints that suggest the edge's existence.
  • 'created' indicates that this transition is newly created and represents a change to the decision layout. Normally, when entering a decision point, all visible options will be listed. When revisiting a decision, several things can happen: 1. You could notice a transition you hadn't noticed before. 2. You could traverse part of the room that you couldn't before, observing new transitions that have always been there (this would be represented as an internal edge to another decision node). 3. You could observe that the decision had changed due to some action or event, and discover a new transition that didn't exist previously. This tag distinguishes case 3 from case 1. The presence or absence of a 'hidden' tag in case 1 represents whether the newly-observed (but not new) transition was overlooked because it was hidden or was just overlooked accidentally.
TagValueTypes: Tuple = (<class 'bool'>, <class 'int'>, <class 'float'>, <class 'str'>, <class 'list'>, <class 'dict'>, None, <class 'Requirement'>, typing.List[typing.Union[Challenge, Effect, Condition]])
TagValue: TypeAlias = Union[bool, int, float, str, list, dict, NoneType, Requirement, List[Union[Challenge, Effect, Condition]]]

A type alias: tag values are any kind of JSON-serializable data (so booleans, ints, floats, strings, lists, dicts, or Nones, plus Requirement and Consequence which have custom serialization defined (see parsing.CustomJSONEncoder) The default value for tags is the integer 1. Note that this is not enforced recursively in some places...

class NoTagValue:
4304class NoTagValue:
4305    """
4306    Class used to indicate no tag value for things that return tag values
4307    since `None` is a valid tag value.
4308    """
4309    pass

Class used to indicate no tag value for things that return tag values since None is a valid tag value.

TagUpdateFunction: TypeAlias = Callable[[Dict[str, Union[bool, int, float, str, list, dict, NoneType, Requirement, List[Union[Challenge, Effect, Condition]]]], str, Union[bool, int, float, str, list, dict, NoneType, Requirement, List[Union[Challenge, Effect, Condition]]]], Union[bool, int, float, str, list, dict, NoneType, Requirement, List[Union[Challenge, Effect, Condition]]]]

A tag update function gets three arguments: the entire tags dictionary for the thing being updated, the tag name of the tag being updated, and the tag value for that tag. It must return a new tag value.

Annotation: TypeAlias = str

A type alias: annotations are strings.

class ZoneInfo(typing.NamedTuple):
4331class ZoneInfo(NamedTuple):
4332    """
4333    Zone info holds a level integer (starting from 0 as the level directly
4334    above decisions), a set of parent zones, a set of child decisions
4335    and/or zones, and zone tags and annotations. Zones at a particular
4336    level may only contain zones in lower levels, although zones at any
4337    level may also contain decisions directly.  The norm is for zones at
4338    level 0 to contain decisions, while zones at higher levels contain
4339    zones from the level directly below them.
4340
4341    Note that zones may have multiple parents, because one sub-zone may be
4342    contained within multiple super-zones.
4343    """
4344    level: int
4345    parents: Set[Zone]
4346    contents: Set[Union[DecisionID, Zone]]
4347    tags: Dict[Tag, TagValue]
4348    annotations: List[Annotation]

Zone info holds a level integer (starting from 0 as the level directly above decisions), a set of parent zones, a set of child decisions and/or zones, and zone tags and annotations. Zones at a particular level may only contain zones in lower levels, although zones at any level may also contain decisions directly. The norm is for zones at level 0 to contain decisions, while zones at higher levels contain zones from the level directly below them.

Note that zones may have multiple parents, because one sub-zone may be contained within multiple super-zones.

ZoneInfo( level: int, parents: Set[str], contents: Set[Union[int, str]], tags: Dict[str, Union[bool, int, float, str, list, dict, NoneType, Requirement, List[Union[Challenge, Effect, Condition]]]], annotations: List[str])

Create new instance of ZoneInfo(level, parents, contents, tags, annotations)

level: int

Alias for field number 0

parents: Set[str]

Alias for field number 1

contents: Set[Union[int, str]]

Alias for field number 2

tags: Dict[str, Union[bool, int, float, str, list, dict, NoneType, Requirement, List[Union[Challenge, Effect, Condition]]]]

Alias for field number 3

annotations: List[str]

Alias for field number 4

Inherited Members
builtins.tuple
index
count
DefaultZone: str = ''

An alias for the empty string to indicate a default zone.

ExplorationActionType = typing.Literal['noAction', 'start', 'take', 'explore', 'warp', 'focus', 'swap', 'focalize', 'revertTo']

The valid action types for exploration actions (see ExplorationAction).

ExplorationAction: TypeAlias = Union[Tuple[Literal['noAction']], Tuple[Literal['start'], Union[int, Dict[str, int], Set[int]], Optional[int], str, Optional[CapabilitySet], Optional[Dict[int, str]], Optional[dict]], Tuple[Literal['explore'], Literal['common', 'active'], int, Tuple[str, List[bool]], Union[str, int, NoneType], Optional[str], Optional[str]], Tuple[Literal['explore'], Tuple[Literal['common', 'active'], str, str], Tuple[str, List[bool]], Union[str, int, NoneType], Optional[str], Optional[str]], Tuple[Literal['take'], Literal['common', 'active'], int, Tuple[str, List[bool]]], Tuple[Literal['take'], Tuple[Literal['common', 'active'], str, str], Tuple[str, List[bool]]], Tuple[Literal['warp'], Literal['common', 'active'], int], Tuple[Literal['warp'], Tuple[Literal['common', 'active'], str, str], int], Tuple[Literal['focus'], Literal['common', 'active'], Set[str], Set[str]], Tuple[Literal['swap'], str], Tuple[Literal['focalize'], str], Tuple[Literal['revertTo'], str, Set[str]]]

Represents an action taken at one step of a DiscreteExploration. It's a always a tuple, and the first element is a string naming the action. It has multiple possible configurations:

  • The string 'noAction' as a singlet means that no action has been taken, which can be used to represent waiting or an ending. In situations where the player is still deciding on an action, None (which is not a valid ExplorationAction should be used instead.
  • The string 'start' followed by a DecisionID / (FocalPointName-to-DecisionID dictionary) / set-of-DecisionIDs position(s) specifier, another DecisionID (or None), a Domain, and then optional CapabilitySet, mechanism state dictionary, and custom state dictionary objects (each of which could instead be None for default). This indicates setting up starting state in a new focal context. The first decision ID (or similar) specifies active decisions, the second specifies the primary decision (which ought to be one of the active ones). It always affects the active focal context, and a BadStart error will result if that context already has any active decisions in the specified domain. The specified domain must already exist and must have the appropriate focalization depending on the type of position(s) specifier given; use DiscreteExploration.createDomain to create a domain first if necessary. Likewise, any specified decisions to activate must already exist, use DecisionGraph.addDecision to create them before using a 'start' action.

    When mechanism states and/or custom state is specified, these replace current mechanism/custom states for the entire current state, since these things aren't focal-context-specific. Similarly, if capabilities are provided, these replace existing capabilities for the active focal context, since those aren't domain-specific.

  • The string 'explore' followed by:

    • A ContextSpecifier indicating which context to use
    • A DecisionID indicating the starting decision
    • Alternatively, a FocalPointSpecifier can be used in place of the context specifier and decision to specify which focal point moves in a plural-focalized domain.
    • A TransitionWithOutcomes indicating the transition taken and outcomes observed (if any).
    • An optional DecisionName used to rename the destination.
    • An optional Transition used to rename the reciprocal transition.
    • An optional Zone used to place the destination into a (possibly-new) level-0 zone. This represents exploration of a previously-unexplored decision, in contrast to 'take' (see below) which represents moving across a previously-explored transition.
  • The string 'take' followed by a ContextSpecifier, DecisionID, and TransitionWithOutcomes represents taking that transition at that decision, updating the specified context (i.e., common vs. active; to update a non-active context first swap to it). Normal DomainFocalization-based rules for updating active decisions determine what happens besides transition consequences, but for a 'singular'-focalized domain (as determined by the active FocalContext in the DiscreteExploration's current State), the current active decision becomes inactive and the decision at the other end of the selected transition becomes active. A warning or error may be issued if the DecisionID used is an inactive decision.

  • The string 'warp' followed by either a DecisionID, or a FocalPointSpecifier tuple followed by a DecisionID. This represents activating a new decision without following a transition in the decision graph, such as when a cutscene moves you. Things like teleporters can be represented by normal transitions; a warp should be used when there's a 1-time effect that has no reciprocal.

  • The string 'focus' followed by a ContextSpecifier and then two sets of Domains. The first one lists domains that become inactive, and the second lists domains that become active. This can be used to represent opening up a menu, although if the menu can easily be closed and re-opened anywhere, it's usually not necessary to track the focus swaps (think a cutscene that forces you to make a choice before being able to continue normal exploration). A focus swap can also be the consequence of taking a transition, in which case the exploration action just identifies the transition using one of the formats above.

  • The string 'swap' is followed by a FocalContextName and represents a complete FocalContext swap. If this something the player can trigger at will (or under certain conditions) it's better to use a transition consequence and have the action be taking that transition.

  • The string 'focalize' is followed by an unused FocalContextName and represents the creation of a new empty focal context (which will also be swapped-to).

    TODO: domain and context focus swaps as effects!

  • The string 'revertTo' followed by a SaveSlot and then a set of reversion aspects (see revertedState). This will update the situation by restoring a previous state (or potentially only parts of it). An empty set of reversion aspects invokes the default revert behavior, which reverts all aspects of the state, except that changes to the DecisionGraph are preserved.

def describeExplorationAction( situation: Situation, action: Union[Tuple[Literal['noAction']], Tuple[Literal['start'], Union[int, Dict[str, int], Set[int]], Optional[int], str, Optional[CapabilitySet], Optional[Dict[int, str]], Optional[dict]], Tuple[Literal['explore'], Literal['common', 'active'], int, Tuple[str, List[bool]], Union[str, int, NoneType], Optional[str], Optional[str]], Tuple[Literal['explore'], Tuple[Literal['common', 'active'], str, str], Tuple[str, List[bool]], Union[str, int, NoneType], Optional[str], Optional[str]], Tuple[Literal['take'], Literal['common', 'active'], int, Tuple[str, List[bool]]], Tuple[Literal['take'], Tuple[Literal['common', 'active'], str, str], Tuple[str, List[bool]]], Tuple[Literal['warp'], Literal['common', 'active'], int], Tuple[Literal['warp'], Tuple[Literal['common', 'active'], str, str], int], Tuple[Literal['focus'], Literal['common', 'active'], Set[str], Set[str]], Tuple[Literal['swap'], str], Tuple[Literal['focalize'], str], Tuple[Literal['revertTo'], str, Set[str]], NoneType]) -> str:
4524def describeExplorationAction(
4525    situation: 'Situation',
4526    action: Optional[ExplorationAction]
4527) -> str:
4528    """
4529    Returns a string description of the action represented by an
4530    `ExplorationAction` object (or the string '(no action)' for the value
4531    `None`). Uses the provided situation to look up things like decision
4532    names, focal point positions, and destinations where relevant. Does
4533    not know details of which graph it is applied to or the outcomes of
4534    the action, so just describes what is being attempted.
4535    """
4536    if action is None:
4537        return '(no action)'
4538
4539    if (
4540        not isinstance(action, tuple)
4541     or len(action) == 0
4542    ):
4543        raise TypeError(f"Not an exploration action: {action!r}")
4544
4545    graph = situation.graph
4546
4547    if action[0] not in get_args(ExplorationActionType):
4548        raise ValueError(f"Invalid exploration action type: {action[0]!r}")
4549
4550    aType = action[0]
4551    if aType == 'noAction':
4552        return "wait"
4553
4554    elif aType == 'start':
4555        if len(action) != 7:
4556            raise ValueError(
4557                f"Wrong number of parts for 'start' action: {action!r}"
4558            )
4559        (
4560            _,
4561            startActive,
4562            primary,
4563            domain,
4564            capabilities,
4565            mechanisms,
4566            custom
4567        ) = action
4568        Union[DecisionID, Dict[FocalPointName, DecisionID], Set[DecisionID]]
4569        at: str
4570        if primary is None:
4571            if isinstance(startActive, DecisionID):
4572                at = f" at {graph.identityOf(startActive)}"
4573            elif isinstance(startActive, dict):
4574                at = f" with {len(startActive)} focal point(s)"
4575            elif isinstance(startActive, set):
4576                at = f" from {len(startActive)} decisions"
4577            else:
4578                raise TypeError(
4579                    f"Invalid type for starting location:"
4580                    f" {type(startActive)}"
4581                )
4582        else:
4583            at = f" at {graph.identityOf(primary)}"
4584            if isinstance(startActive, dict):
4585                at += f" (among {len(startActive)} focal point(s))"
4586            elif isinstance(startActive, set):
4587                at += f" (among {len(startActive)} decisions)"
4588
4589        return (
4590            f"start exploring domain {domain}{at}"
4591        )
4592
4593    elif aType == 'explore':
4594        if len(action) == 7:
4595            assert isinstance(action[2], DecisionID)
4596            fromID = action[2]
4597            assert isinstance(action[3], tuple)
4598            transitionName, specified = action[3]
4599            assert isinstance(action[3][0], Transition)
4600            assert isinstance(action[3][1], list)
4601            assert all(isinstance(x, bool) for x in action[3][1])
4602        elif len(action) == 6:
4603            assert isinstance(action[1], tuple)
4604            assert len(action[1]) == 3
4605            fpPos = resolvePosition(situation, action[1])
4606            if fpPos is None:
4607                raise ValueError(
4608                    f"Invalid focal point specifier: no position found"
4609                    f" for:\n{action[1]}"
4610                )
4611            else:
4612                fromID = fpPos
4613            transitionName, specified = action[2]
4614        else:
4615            raise ValueError(
4616                f"Wrong number of parts for 'explore' action: {action!r}"
4617            )
4618
4619        destID = graph.getDestination(fromID, transitionName)
4620
4621        frDesc = graph.identityOf(fromID)
4622        deDesc = graph.identityOf(destID)
4623
4624        newNameOrDest: Union[DecisionName, DecisionID, None] = action[-3]
4625        nowWord = "now "
4626        if newNameOrDest is None:
4627            if destID is None:
4628                nowWord = ""
4629                newName = "INVALID: an unspecified + unnamed decision"
4630            else:
4631                nowWord = ""
4632                newName = graph.nameFor(destID)
4633        elif isinstance(newNameOrDest, DecisionName):
4634            newName = newNameOrDest
4635        else:
4636            assert isinstance(newNameOrDest, DecisionID)
4637            destID = newNameOrDest
4638            nowWord = "now reaches "
4639            newName = graph.identityOf(destID)
4640
4641        newZone: Union[Zone, None] = action[-1]
4642        if newZone in (None, ""):
4643            deDesc = f"{destID} ({nowWord}{newName})"
4644        else:
4645            deDesc = f"{destID} ({nowWord}{newZone}::{newName})"
4646            # TODO: Don't hardcode '::' here?
4647
4648        oDesc = ""
4649        if len(specified) > 0:
4650            oDesc = " with outcomes: "
4651            first = True
4652            for o in specified:
4653                if first:
4654                    first = False
4655                else:
4656                    oDesc += ", "
4657                if o:
4658                    oDesc += "success"
4659                else:
4660                    oDesc += "failure"
4661
4662        return (
4663            f"explore {transitionName} from decision {frDesc} to"
4664            f" {deDesc}{oDesc}"
4665        )
4666
4667    elif aType == 'take':
4668        if len(action) == 4:
4669            assert action[1] in get_args(ContextSpecifier)
4670            assert isinstance(action[2], DecisionID)
4671            assert isinstance(action[3], tuple)
4672            assert len(action[3]) == 2
4673            assert isinstance(action[3][0], Transition)
4674            assert isinstance(action[3][1], list)
4675            context = action[1]
4676            fromID = action[2]
4677            transitionName, specified = action[3]
4678            destID = graph.getDestination(fromID, transitionName)
4679            oDesc = ""
4680            if len(specified) > 0:
4681                oDesc = " with outcomes: "
4682                first = True
4683                for o in specified:
4684                    if first:
4685                        first = False
4686                    else:
4687                        oDesc += ", "
4688                    if o:
4689                        oDesc += "success"
4690                    else:
4691                        oDesc += "failure"
4692            if fromID == destID:  # an action
4693                return f"do action {transitionName}"
4694            else:  # normal transition
4695                frDesc = graph.identityOf(fromID)
4696                deDesc = graph.identityOf(destID)
4697
4698                return (
4699                    f"take {transitionName} from decision {frDesc} to"
4700                    f" {deDesc}{oDesc}"
4701                )
4702        elif len(action) == 3:
4703            assert isinstance(action[1], tuple)
4704            assert len(action[1]) == 3
4705            assert isinstance(action[2], tuple)
4706            assert len(action[2]) == 2
4707            assert isinstance(action[2][0], Transition)
4708            assert isinstance(action[2][1], list)
4709            _, focalPoint, transition = action
4710            context, domain, name = focalPoint
4711            frID = resolvePosition(situation, focalPoint)
4712
4713            transitionName, specified = action[2]
4714            oDesc = ""
4715            if len(specified) > 0:
4716                oDesc = " with outcomes: "
4717                first = True
4718                for o in specified:
4719                    if first:
4720                        first = False
4721                    else:
4722                        oDesc += ", "
4723                    if o:
4724                        oDesc += "success"
4725                    else:
4726                        oDesc += "failure"
4727
4728            if frID is None:
4729                return (
4730                    f"invalid action (moves {focalPoint} which doesn't"
4731                    f" exist)"
4732                )
4733            else:
4734                destID = graph.getDestination(frID, transitionName)
4735
4736                if frID == destID:
4737                    return "do action {transition}{oDesc}"
4738                else:
4739                    frDesc = graph.identityOf(frID)
4740                    deDesc = graph.identityOf(destID)
4741                    return (
4742                        f"{name} takes {transition} from {frDesc} to"
4743                        f" {deDesc}{oDesc}"
4744                    )
4745        else:
4746            raise ValueError(
4747                f"Wrong number of parts for 'take' action: {action!r}"
4748            )
4749
4750    elif aType == 'warp':
4751        if len(action) != 3:
4752            raise ValueError(
4753                f"Wrong number of parts for 'warp' action: {action!r}"
4754            )
4755        if action[1] in get_args(ContextSpecifier):
4756            assert isinstance(action[1], str)
4757            assert isinstance(action[2], DecisionID)
4758            _, context, destination = action
4759            deDesc = graph.identityOf(destination)
4760            return f"warp to {deDesc!r}"
4761        elif isinstance(action[1], tuple) and len(action[1]) == 3:
4762            assert isinstance(action[2], DecisionID)
4763            _, focalPoint, destination = action
4764            context, domain, name = focalPoint
4765            deDesc = graph.identityOf(destination)
4766            frID = resolvePosition(situation, focalPoint)
4767            frDesc = graph.identityOf(frID)
4768            return f"{name} warps to {deDesc!r}"
4769        else:
4770            raise TypeError(
4771                f"Invalid second part for 'warp' action: {action!r}"
4772            )
4773
4774    elif aType == 'focus':
4775        if len(action) != 4:
4776            raise ValueError(
4777                "Wrong number of parts for 'focus' action: {action!r}"
4778            )
4779        _, context, deactivate, activate = action
4780        assert isinstance(deactivate, set)
4781        assert isinstance(activate, set)
4782        result = "change in active domains: "
4783        clauses = []
4784        if len(deactivate) > 0:
4785            clauses.append("deactivate domain(s) {', '.join(deactivate)}")
4786        if len(activate) > 0:
4787            clauses.append("activate domain(s) {', '.join(activate)}")
4788        result += '; '.join(clauses)
4789        return result
4790
4791    elif aType == 'swap':
4792        if len(action) != 2:
4793            raise ValueError(
4794                "Wrong number of parts for 'swap' action: {action!r}"
4795            )
4796        _, fcName = action
4797        return f"swap to focal context {fcName!r}"
4798
4799    elif aType == 'focalize':
4800        if len(action) != 2:
4801            raise ValueError(
4802                "Wrong number of parts for 'focalize' action: {action!r}"
4803            )
4804        _, fcName = action
4805        return f"create new focal context {fcName!r}"
4806
4807    else:
4808        raise RuntimeError(
4809            "Missing case for exploration action type: {action[0]!r}"
4810        )

Returns a string description of the action represented by an ExplorationAction object (or the string '(no action)' for the value None). Uses the provided situation to look up things like decision names, focal point positions, and destinations where relevant. Does not know details of which graph it is applied to or the outcomes of the action, so just describes what is being attempted.

DecisionType = typing.Literal['pending', 'active', 'unintended', 'imposed', 'consequence']

The types for decisions are:

  • 'pending': A decision that hasn't been made yet.
  • 'active': A decision made actively and consciously (the default).
  • 'unintended': A decision was made but the execution of that decision resulted in a different action than the one intended (note that we don't currently record the original intent). TODO: that?
  • 'imposed': A course of action was changed/taken, but no conscious decision was made, meaning that the action was imposed by external circumstances.
  • 'consequence': A different course of action resulted in a follow-up consequence that wasn't part of the original intent.
class Situation(typing.NamedTuple):
4835class Situation(NamedTuple):
4836    """
4837    Holds all of the pieces of an exploration's state at a single
4838    exploration step, including:
4839
4840    - 'graph': The `DecisionGraph` for that step. Continuity between
4841        graphs can be established because they use the same `DecisionID`
4842        for unchanged nodes.
4843    - 'state': The game `State` for that step, including common and
4844        active `FocalContext`s which determine both what capabilities
4845        are active in the step and which decision point(s) the player
4846        may select an option at.
4847    - 'type': The `DecisionType` for the decision made at this
4848        situation.
4849    - 'taken': an `ExplorationAction` specifying what action was taken,
4850        or `None` for situations where an action has not yet been
4851        decided on (distinct from `(`noAction`,)` for waiting). The
4852        effects of that action are represented by the following
4853        `Situation` in the `DiscreteExploration`. Note that the final
4854        situation in an exploration will also use `('noAction',)` as the
4855        'taken' value to indicate that either no further choices are
4856        possible (e.g., at an ending), or it will use `None` to indicate
4857        that no choice has been made yet.
4858    - 'saves': A dictionary mapping save-slot names to (graph, state)
4859        pairs for saved states.
4860    - 'tags': A dictionary of tag-name: tag-value information for this
4861        step, allowing custom tags with custom values to be added.
4862    - 'annotations': A list of `Annotation` strings allowing custom
4863        annotations to be applied to a situation.
4864    """
4865    graph: 'DecisionGraph'
4866    state: State
4867    type: DecisionType
4868    action: Optional[ExplorationAction]
4869    saves: Dict[SaveSlot, Tuple['DecisionGraph', State]]
4870    tags: Dict[Tag, TagValue]
4871    annotations: List[Annotation]

Holds all of the pieces of an exploration's state at a single exploration step, including:

  • 'graph': The DecisionGraph for that step. Continuity between graphs can be established because they use the same DecisionID for unchanged nodes.
  • 'state': The game State for that step, including common and active FocalContexts which determine both what capabilities are active in the step and which decision point(s) the player may select an option at.
  • 'type': The DecisionType for the decision made at this situation.
  • 'taken': an ExplorationAction specifying what action was taken, or None for situations where an action has not yet been decided on (distinct from (noAction,) for waiting). The effects of that action are represented by the following Situation in the DiscreteExploration. Note that the final situation in an exploration will also use ('noAction',) as the 'taken' value to indicate that either no further choices are possible (e.g., at an ending), or it will use None to indicate that no choice has been made yet.
  • 'saves': A dictionary mapping save-slot names to (graph, state) pairs for saved states.
  • 'tags': A dictionary of tag-name: tag-value information for this step, allowing custom tags with custom values to be added.
  • 'annotations': A list of Annotation strings allowing custom annotations to be applied to a situation.
Situation( graph: ForwardRef('DecisionGraph'), state: State, type: Literal['pending', 'active', 'unintended', 'imposed', 'consequence'], action: Union[Tuple[Literal['noAction']], Tuple[Literal['start'], Union[int, Dict[str, int], Set[int]], Optional[int], str, Optional[CapabilitySet], Optional[Dict[int, str]], Optional[dict]], Tuple[Literal['explore'], Literal['common', 'active'], int, Tuple[str, List[bool]], Union[str, int, NoneType], Optional[str], Optional[str]], Tuple[Literal['explore'], Tuple[Literal['common', 'active'], str, str], Tuple[str, List[bool]], Union[str, int, NoneType], Optional[str], Optional[str]], Tuple[Literal['take'], Literal['common', 'active'], int, Tuple[str, List[bool]]], Tuple[Literal['take'], Tuple[Literal['common', 'active'], str, str], Tuple[str, List[bool]]], Tuple[Literal['warp'], Literal['common', 'active'], int], Tuple[Literal['warp'], Tuple[Literal['common', 'active'], str, str], int], Tuple[Literal['focus'], Literal['common', 'active'], Set[str], Set[str]], Tuple[Literal['swap'], str], Tuple[Literal['focalize'], str], Tuple[Literal['revertTo'], str, Set[str]], NoneType], saves: Dict[str, Tuple[ForwardRef('DecisionGraph'), State]], tags: Dict[str, Union[bool, int, float, str, list, dict, NoneType, Requirement, List[Union[Challenge, Effect, Condition]]]], annotations: List[str])

Create new instance of Situation(graph, state, type, action, saves, tags, annotations)

Alias for field number 0

state: State

Alias for field number 1

type: Literal['pending', 'active', 'unintended', 'imposed', 'consequence']

Alias for field number 2

action: Union[Tuple[Literal['noAction']], Tuple[Literal['start'], Union[int, Dict[str, int], Set[int]], Optional[int], str, Optional[CapabilitySet], Optional[Dict[int, str]], Optional[dict]], Tuple[Literal['explore'], Literal['common', 'active'], int, Tuple[str, List[bool]], Union[str, int, NoneType], Optional[str], Optional[str]], Tuple[Literal['explore'], Tuple[Literal['common', 'active'], str, str], Tuple[str, List[bool]], Union[str, int, NoneType], Optional[str], Optional[str]], Tuple[Literal['take'], Literal['common', 'active'], int, Tuple[str, List[bool]]], Tuple[Literal['take'], Tuple[Literal['common', 'active'], str, str], Tuple[str, List[bool]]], Tuple[Literal['warp'], Literal['common', 'active'], int], Tuple[Literal['warp'], Tuple[Literal['common', 'active'], str, str], int], Tuple[Literal['focus'], Literal['common', 'active'], Set[str], Set[str]], Tuple[Literal['swap'], str], Tuple[Literal['focalize'], str], Tuple[Literal['revertTo'], str, Set[str]], NoneType]

Alias for field number 3

saves: Dict[str, Tuple[exploration.core.DecisionGraph, State]]

Alias for field number 4

tags: Dict[str, Union[bool, int, float, str, list, dict, NoneType, Requirement, List[Union[Challenge, Effect, Condition]]]]

Alias for field number 5

annotations: List[str]

Alias for field number 6

Inherited Members
builtins.tuple
index
count
def contextForTransition( situation: Situation, decision: Union[int, DecisionSpecifier, str], transition: str) -> RequirementContext:
4878def contextForTransition(
4879    situation: Situation,
4880    decision: AnyDecisionSpecifier,
4881    transition: Transition
4882) -> RequirementContext:
4883    """
4884    Given a `Situation` along with an `AnyDecisionSpecifier` and a
4885    `Transition` that together identify a particular transition of
4886    interest, returns the appropriate `RequirementContext` to use to
4887    evaluate requirements and resolve consequences for that transition,
4888    which involves the state & graph from the specified situation, along
4889    with the two ends of that transition as the search-from location.
4890    """
4891    return RequirementContext(
4892        graph=situation.graph,
4893        state=situation.state,
4894        searchFrom=situation.graph.bothEnds(decision, transition)
4895    )

Given a Situation along with an AnyDecisionSpecifier and a Transition that together identify a particular transition of interest, returns the appropriate RequirementContext to use to evaluate requirements and resolve consequences for that transition, which involves the state & graph from the specified situation, along with the two ends of that transition as the search-from location.

def genericContextForSituation( situation: Situation, searchFrom: Optional[Set[int]] = None) -> RequirementContext:
4898def genericContextForSituation(
4899    situation: Situation,
4900    searchFrom: Optional[Set[DecisionID]] = None
4901) -> RequirementContext:
4902    """
4903    Turns a `Situation` into a `RequirementContext` without a specific
4904    transition as the origin (use `contextForTransition` if there's a
4905    relevant transition). By default, the `searchFrom` part of the
4906    requirement context will be the set of active decisions in the
4907    situation, but the search-from part can be overridden by supplying
4908    an explicit `searchFrom` set of decision IDs here.
4909    """
4910    if searchFrom is None:
4911        searchFrom = combinedDecisionSet(situation.state)
4912
4913    return RequirementContext(
4914        state=situation.state,
4915        graph=situation.graph,
4916        searchFrom=searchFrom
4917    )

Turns a Situation into a RequirementContext without a specific transition as the origin (use contextForTransition if there's a relevant transition). By default, the searchFrom part of the requirement context will be the set of active decisions in the situation, but the search-from part can be overridden by supplying an explicit searchFrom set of decision IDs here.

def hasCapabilityOrEquivalent( capability: str, context: RequirementContext, dontRecurse: Optional[Set[Union[str, Tuple[int, str]]]] = None):
4920def hasCapabilityOrEquivalent(
4921    capability: Capability,
4922    context: RequirementContext,
4923    dontRecurse: Optional[
4924        Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
4925    ] = None
4926):
4927    """
4928    Determines whether a capability should be considered obtained for the
4929    purposes of requirements, given an entire game state and an
4930    equivalences dictionary which maps capabilities and/or
4931    mechanism/state pairs  to sets of requirements that when fulfilled
4932    should count as activating that capability or mechanism state.
4933    """
4934    if dontRecurse is None:
4935        dontRecurse = set()
4936
4937    if (
4938        capability in context.state['common']['capabilities']['capabilities']
4939     or capability in (
4940            context.state['contexts']
4941                [context.state['activeContext']]
4942                ['capabilities']
4943                ['capabilities']
4944        )
4945    ):
4946        return True  # Capability is explicitly obtained
4947    elif capability in dontRecurse:
4948        return False  # Treat circular requirements as unsatisfied
4949    elif not context.graph.hasAnyEquivalents(capability):
4950        # No equivalences to check
4951        return False
4952    else:
4953        # Need to check for a satisfied equivalence
4954        subDont = set(dontRecurse)  # Where not to recurse
4955        subDont.add(capability)
4956        # equivalences for this capability
4957        options = context.graph.allEquivalents(capability)
4958        for req in options:
4959            if req.satisfied(context, subDont):
4960                return True
4961
4962        return False

Determines whether a capability should be considered obtained for the purposes of requirements, given an entire game state and an equivalences dictionary which maps capabilities and/or mechanism/state pairs to sets of requirements that when fulfilled should count as activating that capability or mechanism state.

def stateOfMechanism( ctx: RequirementContext, mechanism: Union[int, str, MechanismSpecifier]) -> str:
4965def stateOfMechanism(
4966    ctx: RequirementContext,
4967    mechanism: AnyMechanismSpecifier
4968) -> MechanismState:
4969    """
4970    Returns the current state of the specified mechanism, returning
4971    `DEFAULT_MECHANISM_STATE` if that mechanism doesn't yet have an
4972    assigned state.
4973    """
4974    mID = ctx.graph.resolveMechanism(mechanism, ctx.searchFrom)
4975
4976    return ctx.state['mechanisms'].get(
4977        mID,
4978        DEFAULT_MECHANISM_STATE
4979    )

Returns the current state of the specified mechanism, returning DEFAULT_MECHANISM_STATE if that mechanism doesn't yet have an assigned state.

def mechanismInStateOrEquivalent( mechanism: Union[int, str, MechanismSpecifier], reqState: str, context: RequirementContext, dontRecurse: Optional[Set[Union[str, Tuple[int, str]]]] = None):
4982def mechanismInStateOrEquivalent(
4983    mechanism: AnyMechanismSpecifier,
4984    reqState: MechanismState,
4985    context: RequirementContext,
4986    dontRecurse: Optional[
4987        Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
4988    ] = None
4989):
4990    """
4991    Determines whether a mechanism should be considered as being in the
4992    given state for the purposes of requirements, given an entire game
4993    state and an equivalences dictionary which maps capabilities and/or
4994    mechanism/state pairs to sets of requirements that when fulfilled
4995    should count as activating that capability or mechanism state.
4996
4997    The `dontRecurse` set of capabilities and/or mechanisms indicates
4998    requirements which should not be considered for alternate
4999    fulfillment during recursion.
5000
5001    Mechanisms with unspecified state are considered to be in the
5002    `DEFAULT_MECHANISM_STATE`, but mechanisms which don't exist are not
5003    considered to be in any state (i.e., this will always return False).
5004    """
5005    if dontRecurse is None:
5006        dontRecurse = set()
5007
5008    mID = context.graph.resolveMechanism(mechanism, context.searchFrom)
5009
5010    currentState = stateOfMechanism(context, mID)
5011    if currentState == reqState:
5012        return True  # Mechanism is explicitly in the target state
5013    elif (mID, reqState) in dontRecurse:
5014        return False  # Treat circular requirements as unsatisfied
5015    elif not context.graph.hasAnyEquivalents((mID, reqState)):
5016        return False  # If there are no equivalences, nothing to check
5017    else:
5018        # Need to check for a satisfied equivalence
5019        subDont = set(dontRecurse)  # Where not to recurse
5020        subDont.add((mID, reqState))
5021        # equivalences for this capability
5022        options = context.graph.allEquivalents((mID, reqState))
5023        for req in options:
5024            if req.satisfied(context, subDont):
5025                return True
5026
5027        return False

Determines whether a mechanism should be considered as being in the given state for the purposes of requirements, given an entire game state and an equivalences dictionary which maps capabilities and/or mechanism/state pairs to sets of requirements that when fulfilled should count as activating that capability or mechanism state.

The dontRecurse set of capabilities and/or mechanisms indicates requirements which should not be considered for alternate fulfillment during recursion.

Mechanisms with unspecified state are considered to be in the DEFAULT_MECHANISM_STATE, but mechanisms which don't exist are not considered to be in any state (i.e., this will always return False).

def combinedTokenCount(state: State, tokenType: str) -> int:
5030def combinedTokenCount(state: State, tokenType: Token) -> TokenCount:
5031    """
5032    Returns the token count for a particular token type for a state,
5033    combining tokens from the common and active `FocalContext`s.
5034    """
5035    return (
5036        state['common']['capabilities']['tokens'].get(tokenType, 0)
5037      + state[
5038          'contexts'
5039        ][state['activeContext']]['capabilities']['tokens'].get(tokenType, 0)
5040    )

Returns the token count for a particular token type for a state, combining tokens from the common and active FocalContexts.

def explorationStatusOf( situation: Situation, decision: Union[int, DecisionSpecifier, str]) -> Literal['unknown', 'hypothesized', 'noticed', 'exploring', 'explored']:
5043def explorationStatusOf(
5044    situation: Situation,
5045    decision: AnyDecisionSpecifier
5046) -> ExplorationStatus:
5047    """
5048    Returns the exploration status of the specified decision in the
5049    given situation, or the `DEFAULT_EXPLORATION_STATUS` if no status
5050    has been set for that decision.
5051    """
5052    dID = situation.graph.resolveDecision(decision)
5053    return situation.state['exploration'].get(
5054        dID,
5055        DEFAULT_EXPLORATION_STATUS
5056    )

Returns the exploration status of the specified decision in the given situation, or the DEFAULT_EXPLORATION_STATUS if no status has been set for that decision.

def setExplorationStatus( situation: Situation, decision: Union[int, DecisionSpecifier, str], status: Literal['unknown', 'hypothesized', 'noticed', 'exploring', 'explored'], upgradeOnly: bool = False) -> None:
5059def setExplorationStatus(
5060    situation: Situation,
5061    decision: AnyDecisionSpecifier,
5062    status: ExplorationStatus,
5063    upgradeOnly: bool = False
5064) -> None:
5065    """
5066    Sets the exploration status of the specified decision in the
5067    given situation. If `upgradeOnly` is set to True (default is False)
5068    then the exploration status will be changed only if the new status
5069    counts as more-explored than the old one (see `moreExplored`).
5070    """
5071    dID = situation.graph.resolveDecision(decision)
5072    eMap = situation.state['exploration']
5073    if upgradeOnly:
5074        status = moreExplored(
5075            status,
5076            eMap.get(dID, 'unknown')
5077        )
5078    eMap[dID] = status

Sets the exploration status of the specified decision in the given situation. If upgradeOnly is set to True (default is False) then the exploration status will be changed only if the new status counts as more-explored than the old one (see moreExplored).

def hasBeenVisited( situation: Situation, decision: Union[int, DecisionSpecifier, str]) -> bool:
5081def hasBeenVisited(
5082    situation: Situation,
5083    decision: AnyDecisionSpecifier
5084) -> bool:
5085    """
5086    Returns `True` if the specified decision has an exploration status
5087    which counts as having been visited (see `base.statusVisited`). Note
5088    that this works differently from `DecisionGraph.isConfirmed` which
5089    just checks for the 'unconfirmed' tag.
5090    """
5091    return statusVisited(explorationStatusOf(situation, decision))

Returns True if the specified decision has an exploration status which counts as having been visited (see base.statusVisited). Note that this works differently from DecisionGraph.isConfirmed which just checks for the 'unconfirmed' tag.

class IndexTooFarError(builtins.IndexError):
5094class IndexTooFarError(IndexError):
5095    """
5096    An index error that also holds a number specifying how far beyond
5097    the end of the valid indices the given index was. If you are '0'
5098    beyond the end that means you're at the next element after the end.
5099    """
5100    def __init__(self, msg, beyond=0):
5101        """
5102        You need a message and can also include a 'beyond' value
5103        (default is 0).
5104        """
5105        self.msg = msg
5106        self.beyond = beyond
5107
5108    def __str__(self):
5109        return self.msg + f" ({self.beyond} beyond sequence)"
5110
5111    def __repr(self):
5112        return f"IndexTooFarError({repr(self.msg)}, {repr(self.beyond)})"

An index error that also holds a number specifying how far beyond the end of the valid indices the given index was. If you are '0' beyond the end that means you're at the next element after the end.

IndexTooFarError(msg, beyond=0)
5100    def __init__(self, msg, beyond=0):
5101        """
5102        You need a message and can also include a 'beyond' value
5103        (default is 0).
5104        """
5105        self.msg = msg
5106        self.beyond = beyond

You need a message and can also include a 'beyond' value (default is 0).

msg
beyond
Inherited Members
builtins.BaseException
with_traceback
add_note
args
def countParts( consequence: Union[List[Union[Challenge, Effect, Condition]], Challenge, Condition, Effect]) -> int:
5115def countParts(
5116    consequence: Union[Consequence, Challenge, Condition, Effect]
5117) -> int:
5118    """
5119    Returns the number of parts the given consequence has for
5120    depth-first indexing purposes. The consequence itself counts as a
5121    part, plus each `Challenge`, `Condition`, and `Effect` within it,
5122    along with the part counts of any sub-`Consequence`s in challenges
5123    or conditions.
5124
5125    For example:
5126
5127    >>> countParts([])
5128    1
5129    >>> countParts([effect(gain='jump'), effect(lose='jump')])
5130    3
5131    >>> c = [  # 1
5132    ...     challenge(  # 2
5133    ...         skills=BestSkill('skill'),
5134    ...         level=4,
5135    ...         success=[],  # 3
5136    ...         failure=[effect(lose=('money', 10))],  # 4, 5
5137    ...         outcome=True
5138    ...    ),
5139    ...    condition(  # 6
5140    ...        ReqCapability('jump'),
5141    ...        [],  # 7
5142    ...        [effect(gain='jump')]  # 8, 9
5143    ...    ),
5144    ...    effect(set=('door', 'open'))  # 10
5145    ... ]
5146    >>> countParts(c)
5147    10
5148    >>> countParts(c[0])
5149    4
5150    >>> countParts(c[1])
5151    4
5152    >>> countParts(c[2])
5153    1
5154    >>> # (last part of the 10 is the outer list itself)
5155    >>> c = [  # index 0
5156    ...     effect(gain='happy'),  # index 1
5157    ...     challenge(  # index 2
5158    ...         skills=BestSkill('strength'),
5159    ...         success=[effect(gain='winner')]  # indices 3 & 4
5160    ...         # failure is implicit; gets index 5
5161    ...     )  # level defaults to 0
5162    ... ]
5163    >>> countParts(c)
5164    6
5165    >>> countParts(c[0])
5166    1
5167    >>> countParts(c[1])
5168    4
5169    >>> countParts(c[1]['success'])
5170    2
5171    >>> countParts(c[1]['failure'])
5172    1
5173    """
5174    total = 1
5175    if isinstance(consequence, list):
5176        for part in consequence:
5177            total += countParts(part)
5178    elif isinstance(consequence, dict):
5179        if 'skills' in consequence:  # it's a Challenge
5180            consequence = cast(Challenge, consequence)
5181            total += (
5182                countParts(consequence['success'])
5183              + countParts(consequence['failure'])
5184            )
5185        elif 'condition' in consequence:  # it's a Condition
5186            consequence = cast(Condition, consequence)
5187            total += (
5188                countParts(consequence['consequence'])
5189              + countParts(consequence['alternative'])
5190            )
5191        elif 'value' in consequence:  # it's an Effect
5192            pass  # counted already
5193        else:  # bad dict
5194            raise TypeError(
5195                f"Invalid consequence: items must be Effects,"
5196                f" Challenges, or Conditions (got a dictionary without"
5197                f" 'skills', 'value', or 'condition' keys)."
5198                f"\nGot consequence: {repr(consequence)}"
5199            )
5200    else:
5201        raise TypeError(
5202            f"Invalid consequence: must be an Effect, Challenge, or"
5203            f" Condition, or a list of those."
5204            f"\nGot part: {repr(consequence)}"
5205        )
5206
5207    return total

Returns the number of parts the given consequence has for depth-first indexing purposes. The consequence itself counts as a part, plus each Challenge, Condition, and Effect within it, along with the part counts of any sub-Consequences in challenges or conditions.

For example:

>>> countParts([])
1
>>> countParts([effect(gain='jump'), effect(lose='jump')])
3
>>> c = [  # 1
...     challenge(  # 2
...         skills=BestSkill('skill'),
...         level=4,
...         success=[],  # 3
...         failure=[effect(lose=('money', 10))],  # 4, 5
...         outcome=True
...    ),
...    condition(  # 6
...        ReqCapability('jump'),
...        [],  # 7
...        [effect(gain='jump')]  # 8, 9
...    ),
...    effect(set=('door', 'open'))  # 10
... ]
>>> countParts(c)
10
>>> countParts(c[0])
4
>>> countParts(c[1])
4
>>> countParts(c[2])
1
>>> # (last part of the 10 is the outer list itself)
>>> c = [  # index 0
...     effect(gain='happy'),  # index 1
...     challenge(  # index 2
...         skills=BestSkill('strength'),
...         success=[effect(gain='winner')]  # indices 3 & 4
...         # failure is implicit; gets index 5
...     )  # level defaults to 0
... ]
>>> countParts(c)
6
>>> countParts(c[0])
1
>>> countParts(c[1])
4
>>> countParts(c[1]['success'])
2
>>> countParts(c[1]['failure'])
1
def walkParts( consequence: Union[List[Union[Challenge, Effect, Condition]], Challenge, Condition, Effect], startIndex: int = 0) -> Generator[Tuple[int, Union[List[Union[Challenge, Effect, Condition]], Challenge, Condition, Effect]], NoneType, NoneType]:
5210def walkParts(
5211    consequence: Union[Consequence, Challenge, Condition, Effect],
5212    startIndex: int = 0
5213) -> Generator[
5214    Tuple[int, Union[Consequence, Challenge, Condition, Effect]],
5215    None,
5216    None
5217]:
5218    """
5219    Yields tuples containing all indices and the associated
5220    `Consequence`s in the given consequence tree, in depth-first
5221    traversal order.
5222
5223    A `startIndex` other than 0 may be supplied and the indices yielded
5224    will start there.
5225
5226    For example:
5227
5228    >>> list(walkParts([]))
5229    [(0, [])]
5230    >>> e = []
5231    >>> list(walkParts(e))[0][1] is e
5232    True
5233    >>> c = [effect(gain='jump'), effect(lose='jump')]
5234    >>> list(walkParts(c)) == [
5235    ...     (0, c),
5236    ...     (1, c[0]),
5237    ...     (2, c[1]),
5238    ... ]
5239    True
5240    >>> c = [  # 1
5241    ...     challenge(  # 2
5242    ...         skills=BestSkill('skill'),
5243    ...         level=4,
5244    ...         success=[],  # 3
5245    ...         failure=[effect(lose=('money', 10))],  # 4, 5
5246    ...         outcome=True
5247    ...    ),
5248    ...    condition(  # 6
5249    ...        ReqCapability('jump'),
5250    ...        [],  # 7
5251    ...        [effect(gain='jump')]  # 8, 9
5252    ...    ),
5253    ...    effect(set=('door', 'open'))  # 10
5254    ... ]
5255    >>> list(walkParts(c)) == [
5256    ...     (0, c),
5257    ...     (1, c[0]),
5258    ...     (2, c[0]['success']),
5259    ...     (3, c[0]['failure']),
5260    ...     (4, c[0]['failure'][0]),
5261    ...     (5, c[1]),
5262    ...     (6, c[1]['consequence']),
5263    ...     (7, c[1]['alternative']),
5264    ...     (8, c[1]['alternative'][0]),
5265    ...     (9, c[2]),
5266    ... ]
5267    True
5268    """
5269    index = startIndex
5270    yield (index, consequence)
5271    index += 1
5272    if isinstance(consequence, list):
5273        for part in consequence:
5274            for (subIndex, subItem) in walkParts(part, index):
5275                yield (subIndex, subItem)
5276            index = subIndex + 1
5277    elif isinstance(consequence, dict) and 'skills' in consequence:
5278        # a Challenge
5279        challenge = cast(Challenge, consequence)
5280        for (subIndex, subItem) in walkParts(challenge['success'], index):
5281            yield (subIndex, subItem)
5282        index = subIndex + 1
5283        for (subIndex, subItem) in walkParts(challenge['failure'], index):
5284            yield (subIndex, subItem)
5285    elif isinstance(consequence, dict) and 'condition' in consequence:
5286        # a Condition
5287        condition = cast(Condition, consequence)
5288        for (subIndex, subItem) in walkParts(
5289            condition['consequence'],
5290            index
5291        ):
5292            yield (subIndex, subItem)
5293        index = subIndex + 1
5294        for (subIndex, subItem) in walkParts(
5295            condition['alternative'],
5296            index
5297        ):
5298            yield (subIndex, subItem)
5299    elif isinstance(consequence, dict) and 'value' in consequence:
5300        # an Effect; we already yielded it above
5301        pass
5302    else:
5303        raise TypeError(
5304            f"Invalid consequence: items must be lists, Effects,"
5305            f" Challenges, or Conditions.\nGot part:"
5306            f" {repr(consequence)}"
5307        )

Yields tuples containing all indices and the associated Consequences in the given consequence tree, in depth-first traversal order.

A startIndex other than 0 may be supplied and the indices yielded will start there.

For example:

>>> list(walkParts([]))
[(0, [])]
>>> e = []
>>> list(walkParts(e))[0][1] is e
True
>>> c = [effect(gain='jump'), effect(lose='jump')]
>>> list(walkParts(c)) == [
...     (0, c),
...     (1, c[0]),
...     (2, c[1]),
... ]
True
>>> c = [  # 1
...     challenge(  # 2
...         skills=BestSkill('skill'),
...         level=4,
...         success=[],  # 3
...         failure=[effect(lose=('money', 10))],  # 4, 5
...         outcome=True
...    ),
...    condition(  # 6
...        ReqCapability('jump'),
...        [],  # 7
...        [effect(gain='jump')]  # 8, 9
...    ),
...    effect(set=('door', 'open'))  # 10
... ]
>>> list(walkParts(c)) == [
...     (0, c),
...     (1, c[0]),
...     (2, c[0]['success']),
...     (3, c[0]['failure']),
...     (4, c[0]['failure'][0]),
...     (5, c[1]),
...     (6, c[1]['consequence']),
...     (7, c[1]['alternative']),
...     (8, c[1]['alternative'][0]),
...     (9, c[2]),
... ]
True
def consequencePart( consequence: List[Union[Challenge, Effect, Condition]], index: int) -> Union[List[Union[Challenge, Effect, Condition]], Challenge, Condition, Effect]:
5310def consequencePart(
5311    consequence: Consequence,
5312    index: int
5313) -> Union[Consequence, Challenge, Condition, Effect]:
5314    """
5315    Given a `Consequence`, returns the part at the specified index, in
5316    depth-first traversal order, including the consequence itself at
5317    index 0. Raises an `IndexTooFarError` if the index is beyond the end
5318    of the tree; the 'beyond' value of the error will indicate how many
5319    indices beyond the end it was, with 0 for an index that's just
5320    beyond the end.
5321
5322    For example:
5323
5324    >>> c = []
5325    >>> consequencePart(c, 0) is c
5326    True
5327    >>> try:
5328    ...     consequencePart(c, 1)
5329    ... except IndexTooFarError as e:
5330    ...     e.beyond
5331    0
5332    >>> try:
5333    ...     consequencePart(c, 2)
5334    ... except IndexTooFarError as e:
5335    ...     e.beyond
5336    1
5337    >>> c = [effect(gain='jump'), effect(lose='jump')]
5338    >>> consequencePart(c, 0) is c
5339    True
5340    >>> consequencePart(c, 1) is c[0]
5341    True
5342    >>> consequencePart(c, 2) is c[1]
5343    True
5344    >>> try:
5345    ...     consequencePart(c, 3)
5346    ... except IndexTooFarError as e:
5347    ...     e.beyond
5348    0
5349    >>> try:
5350    ...     consequencePart(c, 4)
5351    ... except IndexTooFarError as e:
5352    ...     e.beyond
5353    1
5354    >>> c = [
5355    ...     challenge(
5356    ...         skills=BestSkill('skill'),
5357    ...         level=4,
5358    ...         success=[],
5359    ...         failure=[effect(lose=('money', 10))],
5360    ...         outcome=True
5361    ...    ),
5362    ...    condition(ReqCapability('jump'), [], [effect(gain='jump')]),
5363    ...    effect(set=('door', 'open'))
5364    ... ]
5365    >>> consequencePart(c, 0) is c
5366    True
5367    >>> consequencePart(c, 1) is c[0]
5368    True
5369    >>> consequencePart(c, 2) is c[0]['success']
5370    True
5371    >>> consequencePart(c, 3) is c[0]['failure']
5372    True
5373    >>> consequencePart(c, 4) is c[0]['failure'][0]
5374    True
5375    >>> consequencePart(c, 5) is c[1]
5376    True
5377    >>> consequencePart(c, 6) is c[1]['consequence']
5378    True
5379    >>> consequencePart(c, 7) is c[1]['alternative']
5380    True
5381    >>> consequencePart(c, 8) is c[1]['alternative'][0]
5382    True
5383    >>> consequencePart(c, 9) is c[2]
5384    True
5385    >>> consequencePart(c, 10)
5386    Traceback (most recent call last):
5387    ...
5388    exploration.base.IndexTooFarError...
5389    >>> try:
5390    ...     consequencePart(c, 10)
5391    ... except IndexTooFarError as e:
5392    ...     e.beyond
5393    0
5394    >>> try:
5395    ...     consequencePart(c, 11)
5396    ... except IndexTooFarError as e:
5397    ...     e.beyond
5398    1
5399    >>> try:
5400    ...     consequencePart(c, 14)
5401    ... except IndexTooFarError as e:
5402    ...     e.beyond
5403    4
5404    """
5405    if index == 0:
5406        return consequence
5407    index -= 1
5408    for part in consequence:
5409        if index == 0:
5410            return part
5411        else:
5412            index -= 1
5413        if not isinstance(part, dict):
5414            raise TypeError(
5415                f"Invalid consequence: items in the list must be"
5416                f" Effects, Challenges, or Conditions."
5417                f"\nGot part: {repr(part)}"
5418            )
5419        elif 'skills' in part:  # it's a Challenge
5420            part = cast(Challenge, part)
5421            try:
5422                return consequencePart(part['success'], index)
5423            except IndexTooFarError as e:
5424                index = e.beyond
5425            try:
5426                return consequencePart(part['failure'], index)
5427            except IndexTooFarError as e:
5428                index = e.beyond
5429        elif 'condition' in part:  # it's a Condition
5430            part = cast(Condition, part)
5431            try:
5432                return consequencePart(part['consequence'], index)
5433            except IndexTooFarError as e:
5434                index = e.beyond
5435            try:
5436                return consequencePart(part['alternative'], index)
5437            except IndexTooFarError as e:
5438                index = e.beyond
5439        elif 'value' in part:  # it's an Effect
5440            pass  # if index was 0, we would have returned this part already
5441        else:  # bad dict
5442            raise TypeError(
5443                f"Invalid consequence: items in the list must be"
5444                f" Effects, Challenges, or Conditions (got a dictionary"
5445                f" without 'skills', 'value', or 'condition' keys)."
5446                f"\nGot part: {repr(part)}"
5447            )
5448
5449    raise IndexTooFarError(
5450        "Part index beyond end of consequence.",
5451        index
5452    )

Given a Consequence, returns the part at the specified index, in depth-first traversal order, including the consequence itself at index 0. Raises an IndexTooFarError if the index is beyond the end of the tree; the 'beyond' value of the error will indicate how many indices beyond the end it was, with 0 for an index that's just beyond the end.

For example:

>>> c = []
>>> consequencePart(c, 0) is c
True
>>> try:
...     consequencePart(c, 1)
... except IndexTooFarError as e:
...     e.beyond
0
>>> try:
...     consequencePart(c, 2)
... except IndexTooFarError as e:
...     e.beyond
1
>>> c = [effect(gain='jump'), effect(lose='jump')]
>>> consequencePart(c, 0) is c
True
>>> consequencePart(c, 1) is c[0]
True
>>> consequencePart(c, 2) is c[1]
True
>>> try:
...     consequencePart(c, 3)
... except IndexTooFarError as e:
...     e.beyond
0
>>> try:
...     consequencePart(c, 4)
... except IndexTooFarError as e:
...     e.beyond
1
>>> c = [
...     challenge(
...         skills=BestSkill('skill'),
...         level=4,
...         success=[],
...         failure=[effect(lose=('money', 10))],
...         outcome=True
...    ),
...    condition(ReqCapability('jump'), [], [effect(gain='jump')]),
...    effect(set=('door', 'open'))
... ]
>>> consequencePart(c, 0) is c
True
>>> consequencePart(c, 1) is c[0]
True
>>> consequencePart(c, 2) is c[0]['success']
True
>>> consequencePart(c, 3) is c[0]['failure']
True
>>> consequencePart(c, 4) is c[0]['failure'][0]
True
>>> consequencePart(c, 5) is c[1]
True
>>> consequencePart(c, 6) is c[1]['consequence']
True
>>> consequencePart(c, 7) is c[1]['alternative']
True
>>> consequencePart(c, 8) is c[1]['alternative'][0]
True
>>> consequencePart(c, 9) is c[2]
True
>>> consequencePart(c, 10)
Traceback (most recent call last):
...
IndexTooFarError...
>>> try:
...     consequencePart(c, 10)
... except IndexTooFarError as e:
...     e.beyond
0
>>> try:
...     consequencePart(c, 11)
... except IndexTooFarError as e:
...     e.beyond
1
>>> try:
...     consequencePart(c, 14)
... except IndexTooFarError as e:
...     e.beyond
4
def lookupEffect( situation: Situation, effect: Tuple[int, str, int]) -> Effect:
5455def lookupEffect(
5456    situation: Situation,
5457    effect: EffectSpecifier
5458) -> Effect:
5459    """
5460    Looks up an effect within a situation.
5461    """
5462    graph = situation.graph
5463    root = graph.getConsequence(effect[0], effect[1])
5464    try:
5465        result = consequencePart(root, effect[2])
5466    except IndexTooFarError:
5467        raise IndexError(
5468            f"Invalid effect specifier (consequence has too few parts):"
5469            f" {effect}"
5470        )
5471
5472    if not isinstance(result, dict) or 'value' not in result:
5473        raise IndexError(
5474            f"Invalid effect specifier (part is not an Effect):"
5475            f" {effect}\nGot a/an {type(result)}:"
5476            f"\n  {result}"
5477        )
5478
5479    return cast(Effect, result)

Looks up an effect within a situation.

def triggerCount( situation: Situation, effect: Tuple[int, str, int]) -> int:
5482def triggerCount(
5483    situation: Situation,
5484    effect: EffectSpecifier
5485) -> int:
5486    """
5487    Looks up the trigger count for the specified effect in the given
5488    situation. This includes times the effect has been triggered but
5489    didn't actually do anything because of its delay and/or charges
5490    values.
5491    """
5492    return situation.state['effectCounts'].get(effect, 0)

Looks up the trigger count for the specified effect in the given situation. This includes times the effect has been triggered but didn't actually do anything because of its delay and/or charges values.

def incrementTriggerCount( situation: Situation, effect: Tuple[int, str, int], add: int = 1) -> None:
5495def incrementTriggerCount(
5496    situation: Situation,
5497    effect: EffectSpecifier,
5498    add: int = 1
5499) -> None:
5500    """
5501    Adds one (or the specified `add` value) to the trigger count for the
5502    specified effect in the given situation.
5503    """
5504    counts = situation.state['effectCounts']
5505    if effect in counts:
5506        counts[effect] += add
5507    else:
5508        counts[effect] = add

Adds one (or the specified add value) to the trigger count for the specified effect in the given situation.

def doTriggerEffect( situation: Situation, effect: Tuple[int, str, int]) -> Tuple[Effect, Optional[int]]:
5511def doTriggerEffect(
5512    situation: Situation,
5513    effect: EffectSpecifier
5514) -> Tuple[Effect, Optional[int]]:
5515    """
5516    Looks up the trigger count for the given effect, adds one, and then
5517    returns a tuple with the effect, plus the effective trigger count or
5518    `None`, returning `None` if the effect's charges or delay values
5519    indicate that based on its new trigger count, it should not actually
5520    fire, and otherwise returning a modified trigger count that takes
5521    delay into account.
5522
5523    For example, if an effect has 2 delay and 3 charges and has been
5524    activated once, it will not actually trigger (since its delay value
5525    is still playing out). Once it hits the third attempted trigger, it
5526    will activate with an effective activation count of 1, since that's
5527    the first time it actually applies. Of course, on the 6th and
5528    subsequent activation attempts, it will once more cease to trigger
5529    because it will be out of charges.
5530    """
5531    counts = situation.state['effectCounts']
5532    thisCount = counts.get(effect, 0)
5533    counts[effect] = thisCount + 1  # increment the total count
5534
5535    # Get charges and delay values
5536    effectDetails = lookupEffect(situation, effect)
5537    delay = effectDetails['delay'] or 0
5538    charges = effectDetails['charges']
5539
5540    delayRemaining = delay - thisCount
5541    if delayRemaining > 0:
5542        return (effectDetails, None)
5543    else:
5544        thisCount -= delay
5545
5546    if charges is None:
5547        return (effectDetails, thisCount)
5548    else:
5549        chargesRemaining = charges - thisCount
5550        if chargesRemaining >= 0:
5551            return (effectDetails, thisCount)
5552        else:
5553            return (effectDetails, None)

Looks up the trigger count for the given effect, adds one, and then returns a tuple with the effect, plus the effective trigger count or None, returning None if the effect's charges or delay values indicate that based on its new trigger count, it should not actually fire, and otherwise returning a modified trigger count that takes delay into account.

For example, if an effect has 2 delay and 3 charges and has been activated once, it will not actually trigger (since its delay value is still playing out). Once it hits the third attempted trigger, it will activate with an effective activation count of 1, since that's the first time it actually applies. Of course, on the 6th and subsequent activation attempts, it will once more cease to trigger because it will be out of charges.

def resolvePosition( situation: Situation, posSpec: Union[Tuple[Literal['common', 'active'], str], Tuple[Literal['common', 'active'], str, str]]) -> Optional[int]:
5560def resolvePosition(
5561    situation: Situation,
5562    posSpec: Union[Tuple[ContextSpecifier, Domain], FocalPointSpecifier]
5563) -> Optional[DecisionID]:
5564    """
5565    Given a tuple containing either a specific context plus a specific
5566    domain (which must be singular-focalized) or a full
5567    `FocalPointSpecifier`, this function returns the decision ID implied
5568    by the given specifier within the given situation, or `None` if the
5569    specifier is valid but the position for that specifier is `None`
5570    (including when the domain is not-yet-encountered). For
5571    singular-focalized domains, this is just the position value for that
5572    domain. For plural-focalized domains, you need to provide a
5573    `FocalPointSpecifier` and it's the position of that focal point.
5574    """
5575    fpName: Optional[FocalPointName] = None
5576    if len(posSpec) == 2:
5577        posSpec = cast(Tuple[ContextSpecifier, Domain], posSpec)
5578        whichContext, domain = posSpec
5579    elif len(posSpec) == 3:
5580        posSpec = cast(FocalPointSpecifier, posSpec)
5581        whichContext, domain, fpName = posSpec
5582    else:
5583        raise ValueError(
5584            f"Invalid position specifier {repr(posSpec)}. Must be a"
5585            f" length-2 or length-3 tuple."
5586        )
5587
5588    state = situation.state
5589    if whichContext == 'common':
5590        targetContext = state['common']
5591    else:
5592        targetContext = state['contexts'][state['activeContext']]
5593    focalization = getDomainFocalization(targetContext, domain)
5594
5595    if fpName is None:
5596        if focalization != 'singular':
5597            raise ValueError(
5598                f"Cannot resolve position {repr(posSpec)} because the"
5599                f" domain {repr(domain)} is not singular-focalized."
5600            )
5601        result = targetContext['activeDecisions'].get(domain)
5602        assert isinstance(result, DecisionID)
5603        return result
5604    else:
5605        if focalization != 'plural':
5606            raise ValueError(
5607                f"Cannot resolve position {repr(posSpec)} because a"
5608                f" focal point name was specified but the domain"
5609                f" {repr(domain)} is not plural-focalized."
5610            )
5611        fpMap = targetContext['activeDecisions'].get(domain, {})
5612        #  Double-check types for map itself and at least one entry
5613        assert isinstance(fpMap, dict)
5614        if len(fpMap) > 0:
5615            exKey = next(iter(fpMap))
5616            exVal = fpMap[exKey]
5617            assert isinstance(exKey, FocalPointName)
5618            assert exVal is None or isinstance(exVal, DecisionID)
5619        if fpName not in fpMap:
5620            raise ValueError(
5621                f"Cannot resolve position {repr(posSpec)} because no"
5622                f" focal point with name {repr(fpName)} exists in"
5623                f" domain {repr(domain)} for the {whichContext}"
5624                f" context."
5625            )
5626        return fpMap[fpName]

Given a tuple containing either a specific context plus a specific domain (which must be singular-focalized) or a full FocalPointSpecifier, this function returns the decision ID implied by the given specifier within the given situation, or None if the specifier is valid but the position for that specifier is None (including when the domain is not-yet-encountered). For singular-focalized domains, this is just the position value for that domain. For plural-focalized domains, you need to provide a FocalPointSpecifier and it's the position of that focal point.

def updatePosition( situation: Situation, newPosition: int, inCommon: Literal['common', 'active'] = 'active', moveWhich: Optional[str] = None) -> None:
5629def updatePosition(
5630    situation: Situation,
5631    newPosition: DecisionID,
5632    inCommon: ContextSpecifier = "active",
5633    moveWhich: Optional[FocalPointName] = None
5634) -> None:
5635    """
5636    Given a Situation, updates the position information in that
5637    situation to represent updated player focalization. This can be as
5638    simple as a move from one virtual decision to an adjacent one, or as
5639    complicated as a cross-domain move where the previous decision point
5640    remains active and a specific focal point among a plural-focalized
5641    domain gets updated.
5642
5643    The exploration status of the destination will be set to 'exploring'
5644    if it had been an unexplored status, and the 'visiting' tag in the
5645    `DecisionGraph` will be added (set to 1).
5646
5647    TODO: Examples
5648    """
5649    graph = situation.graph
5650    state = situation.state
5651    destDomain = graph.domainFor(newPosition)
5652
5653    # Set the primary decision of the state
5654    state['primaryDecision'] = newPosition
5655
5656    if inCommon == 'common':
5657        targetContext = state['common']
5658    else:
5659        targetContext = state['contexts'][state['activeContext']]
5660
5661    # Figure out focalization type and active decision(s)
5662    fType = getDomainFocalization(targetContext, destDomain)
5663    domainActiveMap = targetContext['activeDecisions']
5664    if destDomain in domainActiveMap:
5665        active = domainActiveMap[destDomain]
5666    else:
5667        if fType == 'singular':
5668            active = domainActiveMap.setdefault(destDomain, None)
5669        elif fType == 'plural':
5670            active = domainActiveMap.setdefault(destDomain, {})
5671        else:
5672            assert fType == 'spreading'
5673            active = domainActiveMap.setdefault(destDomain, set())
5674
5675    if fType == 'plural':
5676        assert isinstance(active, dict)
5677        if len(active) > 0:
5678            exKey = next(iter(active))
5679            exVal = active[exKey]
5680            assert isinstance(exKey, FocalPointName)
5681            assert exVal is None or isinstance(exVal, DecisionID)
5682        if moveWhich is None and len(active) > 1:
5683            raise ValueError(
5684                f"Invalid position update: move is going to decision"
5685                f" {graph.identityOf(newPosition)} in domain"
5686                f" {repr(destDomain)}, but it did not specify which"
5687                f" focal point to move, and that domain has plural"
5688                f" focalization with more than one focal point."
5689            )
5690        elif moveWhich is None:
5691            moveWhich = list(active)[0]
5692
5693        # Actually move the specified focal point
5694        active[moveWhich] = newPosition
5695
5696    elif moveWhich is not None:
5697        raise ValueError(
5698            f"Invalid position update: move going to decision"
5699            f" {graph.identityOf(newPosition)} in domain"
5700            f" {repr(destDomain)}, specified that focal point"
5701            f" {repr(moveWhich)} should be moved, but that domain does"
5702            f" not have plural focalization, so it does not have"
5703            f" multiple focal points to move."
5704        )
5705
5706    elif fType == 'singular':
5707        # Update the single position:
5708        domainActiveMap[destDomain] = newPosition
5709
5710    elif fType == 'spreading':
5711        # Add the new position:
5712        assert isinstance(active, set)
5713        active.add(newPosition)
5714
5715    else:
5716        raise ValueError(f"Invalid focalization value: {repr(fType)}")
5717
5718    graph.untagDecision(newPosition, 'unconfirmed')
5719    if not hasBeenVisited(situation, newPosition):
5720        setExplorationStatus(
5721            situation,
5722            newPosition,
5723            'exploring',
5724            upgradeOnly=True
5725        )

Given a Situation, updates the position information in that situation to represent updated player focalization. This can be as simple as a move from one virtual decision to an adjacent one, or as complicated as a cross-domain move where the previous decision point remains active and a specific focal point among a plural-focalized domain gets updated.

The exploration status of the destination will be set to 'exploring' if it had been an unexplored status, and the 'visiting' tag in the DecisionGraph will be added (set to 1).

TODO: Examples

LayoutPosition: TypeAlias = Tuple[float, float]

An (x, y) pair in unspecified coordinates.

Layout: TypeAlias = Dict[int, Tuple[float, float]]

Maps one or more decision IDs to LayoutPositions for those decisions.

PointID: TypeAlias = int
Coords: TypeAlias = Sequence[float]
AnyPoint: TypeAlias = Union[int, Sequence[float]]
Feature: TypeAlias = str

Each feature in a FeatureGraph gets a globally unique id, but also has an explorer-assigned name. These names may repeat themselves (especially in different regions) so a region-based address, possibly with a creation-order numeral, can be used to specify a feature exactly even without using its ID. Any string can be used, but for ease of parsing and conversion between formats, sticking to alphanumerics plus underscores is usually desirable.

FeatureID: TypeAlias = int

Features in a feature graph have unique integer identifiers that are assigned automatically in order of creation.

Part: TypeAlias = str

Parts of a feature are identified using strings. Standard part names include 'middle', compass directions, and top/bottom. To include both a compass direction and a vertical position, put the vertical position first and separate with a dash, like 'top-north'. Temporal positions like start/end may also apply in some cases.

class FeatureSpecifier(typing.NamedTuple):
5780class FeatureSpecifier(NamedTuple):
5781    """
5782    There are several ways to specify a feature within a `FeatureGraph`:
5783    Simplest is to just include the `FeatureID` directly (in that case
5784    the domain must be `None` and the 'within' sequence must be empty).
5785    A specific domain and/or a sequence of containing features (starting
5786    from most-external to most-internal) may also be specified when a
5787    string is used as the feature itself, to help disambiguate (when an
5788    ambiguous `FeatureSpecifier` is used,
5789    `AmbiguousFeatureSpecifierError` may arise in some cases). For any
5790    feature, a part may also be specified indicating which part of the
5791    feature is being referred to; this can be `None` when not referring
5792    to any specific sub-part.
5793    """
5794    domain: Optional[Domain]
5795    within: Sequence[Feature]
5796    feature: Union[Feature, FeatureID]
5797    part: Optional[Part]

There are several ways to specify a feature within a FeatureGraph: Simplest is to just include the FeatureID directly (in that case the domain must be None and the 'within' sequence must be empty). A specific domain and/or a sequence of containing features (starting from most-external to most-internal) may also be specified when a string is used as the feature itself, to help disambiguate (when an ambiguous FeatureSpecifier is used, AmbiguousFeatureSpecifierError may arise in some cases). For any feature, a part may also be specified indicating which part of the feature is being referred to; this can be None when not referring to any specific sub-part.

FeatureSpecifier( domain: Optional[str], within: Sequence[str], feature: Union[str, int], part: Optional[str])

Create new instance of FeatureSpecifier(domain, within, feature, part)

domain: Optional[str]

Alias for field number 0

within: Sequence[str]

Alias for field number 1

feature: Union[str, int]

Alias for field number 2

part: Optional[str]

Alias for field number 3

Inherited Members
builtins.tuple
index
count
def feature( name: str, part: Optional[str] = None, domain: Optional[str] = None, within: Optional[Sequence[str]] = None) -> FeatureSpecifier:
5800def feature(
5801    name: Feature,
5802    part: Optional[Part] = None,
5803    domain: Optional[Domain] = None,
5804    within: Optional[Sequence[Feature]] = None
5805) -> FeatureSpecifier:
5806    """
5807    Builds a `FeatureSpecifier` with some defaults. The default domain
5808    is `None`, and by default the feature has an empty 'within' field and
5809    its part field is `None`.
5810    """
5811    if within is None:
5812        within = []
5813    return FeatureSpecifier(
5814        domain=domain,
5815        within=within,
5816        feature=name,
5817        part=part
5818    )

Builds a FeatureSpecifier with some defaults. The default domain is None, and by default the feature has an empty 'within' field and its part field is None.

AnyFeatureSpecifier: TypeAlias = Union[int, str, FeatureSpecifier]

A type for locations where a feature may be specified multiple different ways: directly by ID, by full feature specifier, or by a string identifying a feature name. You can use normalizeFeatureSpecifier to convert one of these to a FeatureSpecifier.

def normalizeFeatureSpecifier( spec: Union[int, str, FeatureSpecifier]) -> FeatureSpecifier:
5834def normalizeFeatureSpecifier(spec: AnyFeatureSpecifier) -> FeatureSpecifier:
5835    """
5836    Turns an `AnyFeatureSpecifier` into a `FeatureSpecifier`. Note that
5837    it does not do parsing from a complex string. Use
5838    `parsing.ParseFormat.parseFeatureSpecifier` for that.
5839
5840    It will turn a feature specifier with an int-convertible feature name
5841    into a feature-ID-based specifier, discarding any domain and/or zone
5842    parts.
5843
5844    TODO: Issue a warning if parts are discarded?
5845    """
5846    if isinstance(spec, (FeatureID, Feature)):
5847        return FeatureSpecifier(
5848            domain=None,
5849            within=[],
5850            feature=spec,
5851            part=None
5852        )
5853    elif isinstance(spec, FeatureSpecifier):
5854        try:
5855            fID = int(spec.feature)
5856            return FeatureSpecifier(None, [], fID, spec.part)
5857        except ValueError:
5858            return spec
5859    else:
5860        raise TypeError(
5861            f"Invalid feature specifier type: {type(spec)}"
5862        )

Turns an AnyFeatureSpecifier into a FeatureSpecifier. Note that it does not do parsing from a complex string. Use parsing.ParseFormat.parseFeatureSpecifier for that.

It will turn a feature specifier with an int-convertible feature name into a feature-ID-based specifier, discarding any domain and/or zone parts.

TODO: Issue a warning if parts are discarded?

class MetricSpace:
5865class MetricSpace:
5866    """
5867    TODO
5868    Represents a variable-dimensional coordinate system within which
5869    locations can be identified by coordinates. May (or may not) include
5870    a reference to one or more images which are visual representation(s)
5871    of the space.
5872    """
5873    def __init__(self, name: str):
5874        self.name = name
5875
5876        self.points: Dict[PointID, Coords] = {}
5877        # Holds all IDs and their corresponding coordinates as key/value
5878        # pairs
5879
5880        self.nextID: PointID = 0
5881        # ID numbers should not be repeated or reused
5882
5883    def addPoint(self, coords: Coords) -> PointID:
5884        """
5885        Given a sequence (list/array/etc) of int coordinates, creates a
5886        point and adds it to the metric space object
5887
5888        >>> ms = MetricSpace("test")
5889        >>> ms.addPoint([2, 3])
5890        0
5891        >>> #expected result
5892        >>> ms.addPoint([2, 7, 0])
5893        1
5894        """
5895        thisID = self.nextID
5896
5897        self.nextID += 1
5898
5899        self.points[thisID] = coords  # creates key value pair
5900
5901        return thisID
5902
5903        # How do we "add" things to the metric space? What data structure
5904        # is it? dictionary
5905
5906    def removePoint(self, thisID: PointID) -> None:
5907        """
5908        Given the ID of a point/coord, checks the dictionary
5909        (points) for that key and removes the key/value pair from
5910        it.
5911
5912        >>> ms = MetricSpace("test")
5913        >>> ms.addPoint([2, 3])
5914        0
5915        >>> ms.removePoint(0)
5916        >>> ms.removePoint(0)
5917        Traceback (most recent call last):
5918        ...
5919        KeyError...
5920        >>> #expected result should be a caught KeyNotFound exception
5921        """
5922        self.points.pop(thisID)
5923
5924    def distance(self, origin: AnyPoint, dest: AnyPoint) -> float:
5925        """
5926        Given an orgin point and destination point, returns the
5927        distance between the two points as a float.
5928
5929        >>> ms = MetricSpace("test")
5930        >>> ms.addPoint([4, 0])
5931        0
5932        >>> ms.addPoint([1, 0])
5933        1
5934        >>> ms.distance(0, 1)
5935        3.0
5936        >>> p1 = ms.addPoint([4, 3])
5937        >>> p2 = ms.addPoint([4, 9])
5938        >>> ms.distance(p1, p2)
5939        6.0
5940        >>> ms.distance([8, 6], [4, 6])
5941        4.0
5942        >>> ms.distance([1, 1], [1, 1])
5943        0.0
5944        >>> ms.distance([-2, -3], [-5, -7])
5945        5.0
5946        >>> ms.distance([2.5, 3.7], [4.9, 6.1])
5947        3.394112549695428
5948        """
5949        if isinstance(origin, PointID):
5950            coord1 = self.points[origin]
5951        else:
5952            coord1 = origin
5953
5954        if isinstance(dest, PointID):
5955            coord2 = self.points[dest]
5956        else:
5957            coord2 = dest
5958
5959        inside = 0.0
5960
5961        for dim in range(max(len(coord1), len(coord2))):
5962            if dim < len(coord1):
5963                val1 = coord1[dim]
5964            else:
5965                val1 = 0
5966            if dim < len(coord2):
5967                val2 = coord2[dim]
5968            else:
5969                val2 = 0
5970
5971            inside += (val2 - val1)**2
5972
5973        result = math.sqrt(inside)
5974        return result
5975
5976    def NDCoords(
5977        self,
5978        point: AnyPoint,
5979        numDimension: int
5980    ) -> Coords:
5981        """
5982        Given a 2D set of coordinates (x, y), converts them to the desired
5983        dimension
5984
5985        >>> ms = MetricSpace("test")
5986        >>> ms.NDCoords([5, 9], 3)
5987        [5, 9, 0]
5988        >>> ms.NDCoords([3, 1], 1)
5989        [3]
5990        """
5991        if isinstance(point, PointID):
5992            coords = self.points[point]
5993        else:
5994            coords = point
5995
5996        seqLength = len(coords)
5997
5998        if seqLength != numDimension:
5999
6000            newCoords: Coords
6001
6002            if seqLength < numDimension:
6003
6004                newCoords = [item for item in coords]
6005
6006                for i in range(numDimension - seqLength):
6007                    newCoords.append(0)
6008
6009            else:
6010                newCoords = coords[:numDimension]
6011
6012        return newCoords
6013
6014    def lastID(self) -> PointID:
6015        """
6016        Returns the most updated ID of the metricSpace instance. The nextID
6017        field is always 1 more than the last assigned ID. Assumes that there
6018        has at least been one ID assigned to a point as a key value pair
6019        in the dictionary. Returns 0 if that is not the case. Does not
6020        consider possible counting errors if a point has been removed from
6021        the dictionary. The last ID does not neccessarily equal the number
6022        of points in the metricSpace (or in the dictionary).
6023
6024        >>> ms = MetricSpace("test")
6025        >>> ms.lastID()
6026        0
6027        >>> ms.addPoint([2, 3])
6028        0
6029        >>> ms.addPoint([2, 7, 0])
6030        1
6031        >>> ms.addPoint([2, 7])
6032        2
6033        >>> ms.lastID()
6034        2
6035        >>> ms.removePoint(2)
6036        >>> ms.lastID()
6037        2
6038        """
6039        if self.nextID < 1:
6040            return self.nextID
6041        return self.nextID - 1

TODO Represents a variable-dimensional coordinate system within which locations can be identified by coordinates. May (or may not) include a reference to one or more images which are visual representation(s) of the space.

MetricSpace(name: str)
5873    def __init__(self, name: str):
5874        self.name = name
5875
5876        self.points: Dict[PointID, Coords] = {}
5877        # Holds all IDs and their corresponding coordinates as key/value
5878        # pairs
5879
5880        self.nextID: PointID = 0
5881        # ID numbers should not be repeated or reused
name
points: Dict[int, Sequence[float]]
nextID: int
def addPoint(self, coords: Sequence[float]) -> int:
5883    def addPoint(self, coords: Coords) -> PointID:
5884        """
5885        Given a sequence (list/array/etc) of int coordinates, creates a
5886        point and adds it to the metric space object
5887
5888        >>> ms = MetricSpace("test")
5889        >>> ms.addPoint([2, 3])
5890        0
5891        >>> #expected result
5892        >>> ms.addPoint([2, 7, 0])
5893        1
5894        """
5895        thisID = self.nextID
5896
5897        self.nextID += 1
5898
5899        self.points[thisID] = coords  # creates key value pair
5900
5901        return thisID
5902
5903        # How do we "add" things to the metric space? What data structure
5904        # is it? dictionary

Given a sequence (list/array/etc) of int coordinates, creates a point and adds it to the metric space object

>>> ms = MetricSpace("test")
>>> ms.addPoint([2, 3])
0
>>> #expected result
>>> ms.addPoint([2, 7, 0])
1
def removePoint(self, thisID: int) -> None:
5906    def removePoint(self, thisID: PointID) -> None:
5907        """
5908        Given the ID of a point/coord, checks the dictionary
5909        (points) for that key and removes the key/value pair from
5910        it.
5911
5912        >>> ms = MetricSpace("test")
5913        >>> ms.addPoint([2, 3])
5914        0
5915        >>> ms.removePoint(0)
5916        >>> ms.removePoint(0)
5917        Traceback (most recent call last):
5918        ...
5919        KeyError...
5920        >>> #expected result should be a caught KeyNotFound exception
5921        """
5922        self.points.pop(thisID)

Given the ID of a point/coord, checks the dictionary (points) for that key and removes the key/value pair from it.

>>> ms = MetricSpace("test")
>>> ms.addPoint([2, 3])
0
>>> ms.removePoint(0)
>>> ms.removePoint(0)
Traceback (most recent call last):
...
KeyError...
>>> #expected result should be a caught KeyNotFound exception
def distance( self, origin: Union[int, Sequence[float]], dest: Union[int, Sequence[float]]) -> float:
5924    def distance(self, origin: AnyPoint, dest: AnyPoint) -> float:
5925        """
5926        Given an orgin point and destination point, returns the
5927        distance between the two points as a float.
5928
5929        >>> ms = MetricSpace("test")
5930        >>> ms.addPoint([4, 0])
5931        0
5932        >>> ms.addPoint([1, 0])
5933        1
5934        >>> ms.distance(0, 1)
5935        3.0
5936        >>> p1 = ms.addPoint([4, 3])
5937        >>> p2 = ms.addPoint([4, 9])
5938        >>> ms.distance(p1, p2)
5939        6.0
5940        >>> ms.distance([8, 6], [4, 6])
5941        4.0
5942        >>> ms.distance([1, 1], [1, 1])
5943        0.0
5944        >>> ms.distance([-2, -3], [-5, -7])
5945        5.0
5946        >>> ms.distance([2.5, 3.7], [4.9, 6.1])
5947        3.394112549695428
5948        """
5949        if isinstance(origin, PointID):
5950            coord1 = self.points[origin]
5951        else:
5952            coord1 = origin
5953
5954        if isinstance(dest, PointID):
5955            coord2 = self.points[dest]
5956        else:
5957            coord2 = dest
5958
5959        inside = 0.0
5960
5961        for dim in range(max(len(coord1), len(coord2))):
5962            if dim < len(coord1):
5963                val1 = coord1[dim]
5964            else:
5965                val1 = 0
5966            if dim < len(coord2):
5967                val2 = coord2[dim]
5968            else:
5969                val2 = 0
5970
5971            inside += (val2 - val1)**2
5972
5973        result = math.sqrt(inside)
5974        return result

Given an orgin point and destination point, returns the distance between the two points as a float.

>>> ms = MetricSpace("test")
>>> ms.addPoint([4, 0])
0
>>> ms.addPoint([1, 0])
1
>>> ms.distance(0, 1)
3.0
>>> p1 = ms.addPoint([4, 3])
>>> p2 = ms.addPoint([4, 9])
>>> ms.distance(p1, p2)
6.0
>>> ms.distance([8, 6], [4, 6])
4.0
>>> ms.distance([1, 1], [1, 1])
0.0
>>> ms.distance([-2, -3], [-5, -7])
5.0
>>> ms.distance([2.5, 3.7], [4.9, 6.1])
3.394112549695428
def NDCoords( self, point: Union[int, Sequence[float]], numDimension: int) -> Sequence[float]:
5976    def NDCoords(
5977        self,
5978        point: AnyPoint,
5979        numDimension: int
5980    ) -> Coords:
5981        """
5982        Given a 2D set of coordinates (x, y), converts them to the desired
5983        dimension
5984
5985        >>> ms = MetricSpace("test")
5986        >>> ms.NDCoords([5, 9], 3)
5987        [5, 9, 0]
5988        >>> ms.NDCoords([3, 1], 1)
5989        [3]
5990        """
5991        if isinstance(point, PointID):
5992            coords = self.points[point]
5993        else:
5994            coords = point
5995
5996        seqLength = len(coords)
5997
5998        if seqLength != numDimension:
5999
6000            newCoords: Coords
6001
6002            if seqLength < numDimension:
6003
6004                newCoords = [item for item in coords]
6005
6006                for i in range(numDimension - seqLength):
6007                    newCoords.append(0)
6008
6009            else:
6010                newCoords = coords[:numDimension]
6011
6012        return newCoords

Given a 2D set of coordinates (x, y), converts them to the desired dimension

>>> ms = MetricSpace("test")
>>> ms.NDCoords([5, 9], 3)
[5, 9, 0]
>>> ms.NDCoords([3, 1], 1)
[3]
def lastID(self) -> int:
6014    def lastID(self) -> PointID:
6015        """
6016        Returns the most updated ID of the metricSpace instance. The nextID
6017        field is always 1 more than the last assigned ID. Assumes that there
6018        has at least been one ID assigned to a point as a key value pair
6019        in the dictionary. Returns 0 if that is not the case. Does not
6020        consider possible counting errors if a point has been removed from
6021        the dictionary. The last ID does not neccessarily equal the number
6022        of points in the metricSpace (or in the dictionary).
6023
6024        >>> ms = MetricSpace("test")
6025        >>> ms.lastID()
6026        0
6027        >>> ms.addPoint([2, 3])
6028        0
6029        >>> ms.addPoint([2, 7, 0])
6030        1
6031        >>> ms.addPoint([2, 7])
6032        2
6033        >>> ms.lastID()
6034        2
6035        >>> ms.removePoint(2)
6036        >>> ms.lastID()
6037        2
6038        """
6039        if self.nextID < 1:
6040            return self.nextID
6041        return self.nextID - 1

Returns the most updated ID of the metricSpace instance. The nextID field is always 1 more than the last assigned ID. Assumes that there has at least been one ID assigned to a point as a key value pair in the dictionary. Returns 0 if that is not the case. Does not consider possible counting errors if a point has been removed from the dictionary. The last ID does not neccessarily equal the number of points in the metricSpace (or in the dictionary).

>>> ms = MetricSpace("test")
>>> ms.lastID()
0
>>> ms.addPoint([2, 3])
0
>>> ms.addPoint([2, 7, 0])
1
>>> ms.addPoint([2, 7])
2
>>> ms.lastID()
2
>>> ms.removePoint(2)
>>> ms.lastID()
2
def featurePart( spec: Union[int, str, FeatureSpecifier], part: str) -> FeatureSpecifier:
6044def featurePart(spec: AnyFeatureSpecifier, part: Part) -> FeatureSpecifier:
6045    """
6046    Returns a new feature specifier (and/or normalizes to one) that
6047    contains the specified part in the 'part' slot. If the provided
6048    feature specifier already contains a 'part', that will be replaced.
6049
6050    For example:
6051
6052    >>> featurePart('town', 'north')
6053    FeatureSpecifier(domain=None, within=[], feature='town', part='north')
6054    >>> featurePart(5, 'top')
6055    FeatureSpecifier(domain=None, within=[], feature=5, part='top')
6056    >>> featurePart(
6057    ...     FeatureSpecifier('dom', ['one', 'two'], 'three', 'middle'),
6058    ...     'top'
6059    ... )
6060    FeatureSpecifier(domain='dom', within=['one', 'two'], feature='three',\
6061 part='top')
6062    >>> featurePart(FeatureSpecifier(None, ['region'], 'place', None), 'top')
6063    FeatureSpecifier(domain=None, within=['region'], feature='place',\
6064 part='top')
6065    """
6066    spec = normalizeFeatureSpecifier(spec)
6067    return FeatureSpecifier(spec.domain, spec.within, spec.feature, part)

Returns a new feature specifier (and/or normalizes to one) that contains the specified part in the 'part' slot. If the provided feature specifier already contains a 'part', that will be replaced.

For example:

>>> featurePart('town', 'north')
FeatureSpecifier(domain=None, within=[], feature='town', part='north')
>>> featurePart(5, 'top')
FeatureSpecifier(domain=None, within=[], feature=5, part='top')
>>> featurePart(
...     FeatureSpecifier('dom', ['one', 'two'], 'three', 'middle'),
...     'top'
... )
FeatureSpecifier(domain='dom', within=['one', 'two'], feature='three', part='top')
>>> featurePart(FeatureSpecifier(None, ['region'], 'place', None), 'top')
FeatureSpecifier(domain=None, within=['region'], feature='place', part='top')
FeatureType = typing.Literal['node', 'path', 'edge', 'region', 'landmark', 'affordance', 'entity']

The different types of features that a FeatureGraph can have:

  1. Nodes, representing destinations, and/or intersections. A node is something that one can be "at" and possibly "in."
  2. Paths, connecting nodes and/or other elements. Also used to represent access points (like doorways between regions) even when they don't have length.
  3. Edges, separating regions and/or impeding movement (but a door is also a kind of edge).
  4. Regions, enclosing other elements and/or regions. Besides via containment, region-region connections are mediated by nodes, paths, and/or edges.
  5. Landmarks, which are recognizable and possibly visible from afar.
  6. Affordances, which are exploration-relevant location-specific actions that can be taken, such as a lever that can be pulled. Affordances may require positioning within multiple domains, but should only be marked in the most-relevant domain, with cross-domain linkages for things like observability. Note that the other spatial object types have their own natural affordances; this is used to mark affordances beyond those. Each affordance can have a list of Consequences to indicate what happens when it is activated.
  7. Entities, which can be interacted with, such as an NPC which can be talked to. Can also be used to represent the player's avatar in a particular domain. Can have adjacent (touching) affordances to represent specific interaction options, and may have nodes which represent options for deeper interaction, but has a generic 'interact' affordance as well. In general, adjacent affordances should be used to represent options for interaction that are triggerable directly within the explorable space, such as the fact that an NPC can be pushed or picked up or the like. In contrast, interaction options accessible via opening an interaction menu should be represented by a 'hasOptions' link to a node (typically in a separate domain) which has some combination of affordances and/or further interior nodes representing sub-menus. Sub-menus gated on some kind of requirement can list those requirements for entry.
FeatureRelationshipType = typing.Literal['contains', 'within', 'touches', 'observable', 'positioned', 'entranceFor', 'enterTo', 'optionsFor', 'hasOptions', 'interacting', 'triggeredBy', 'triggers']

The possible relationships between features in a FeatureGraph:

  • 'contains', specifies that one element contains another. Regions can contain other elements, including other regions, and nodes can contain regions (but only indirectly other elements). A region contained by a node represents some kind of interior space for that node, and this can be used for fractal detail levels (e.g., a town is a node on the overworld but when you enter it it's a full region inside, to contrast with a town as a sub-region of the overworld with no special enter/exit mechanics). The opposite relation is 'within'.
  • 'touches' specifies that two elements touch each other. Not used for regions directly (an edge, path, or node should intercede). The relationship is reciprocal, but not transitive.
  • 'observable' specifies that the target element is observable (visible or otherwise perceivable) from the source element. Can be tagged to indicate things like partial observability and/or difficult-to-observe elements. By default, things that touch each other are considered mutually observable, even without an 'observable' relation being added.
  • 'positioned' to indicate a specific relative position of two objects, with tags on the edge used to indicate what the relationship is. E.g., "the table is 10 feet northwest of the chair" has multiple possible representations, one of which is a 'positioned' relation from the table to the chair, with the 'direction' tag set to 'southeast' and the 'distance' tag set to '10 feet'. Note that a MetricSpace may also be used to track coordinate positions of things; annotating every possible position relationship is not expected.
  • 'entranceFor' to indicate which feature contained inside of a node is enterable from outside the node (possibly from a specific part of the outside of the node). 'enterTo' is the reciprocal relationship. 'entranceFor' applies from the interior region to the exterior node, while 'enterTo' goes the other way. Note that you cannot use two different part specifiers to mark the same region as enter-able from two parts of the same node: each pair of nodes can only have one 'enteranceFor'/'enterTo' connection between them.
  • 'optionsFor' to indicate which node associated with an entity holds the set of options for interaction with that entity. Such nodes are typically placed within a separate domain from the main exploration space. The same node could be the options for multiple entities. The reciprocal is 'hasOptions'. In both cases, a part specifier may be used to indicate more specifically how the interaction is initiated, but note that a given pair of entities cannot have multiple 'optionsFor'/'hasOption' links between them. You could have multiple separate nodes that are 'optionsFor' the same entity with different parts (or even with no part specified for either, although that would create ambiguity in terms of action outcomes).
  • 'interacting' to indicate when one feature is taking action relative to another. This relationship will have an 'action' tag which will contain a FeatureAction dictionary that specifies the relationship target as its 'subject'. This does not have a reciprocal, and is normal ephemeral.
  • 'triggeredBy' to indicate when some kind of action with a feature triggers an affordance. The reciprocal is 'triggers'. The link tag 'triggerInfo' will specify:
    • 'action': the action whose use trips the trigger (one of the FeatureAffordances)
    • 'directions' (optional): A set of directions, one of which must match the specified direction of a FeatureAction for the trigger to trip. When this key is not present, no direction filtering is applied.
    • 'parts' (optional): A set of part specifiers, one of which must match the specified action part for the trigger to trip. When this key is not present, no part filtering is applied.
    • 'entityTags' (optional): A set of entity tags, any of which must match a tag on an interacting entity for the trigger to trip. Items in the set may also be tuples of multiple tags, in which case all items in the tuple must match for the entity to qualify.

Note that any of these relationships can be tagged as 'temporary' to imply malleability. For example, a bus node could be temporarily 'at' a bus stop node and 'within' a corresponding region, but then those relationships could change when it moves on.

FREL_RECIPROCALS: Dict[Literal['contains', 'within', 'touches', 'observable', 'positioned', 'entranceFor', 'enterTo', 'optionsFor', 'hasOptions', 'interacting', 'triggeredBy', 'triggers'], Literal['contains', 'within', 'touches', 'observable', 'positioned', 'entranceFor', 'enterTo', 'optionsFor', 'hasOptions', 'interacting', 'triggeredBy', 'triggers']] = {'contains': 'within', 'within': 'contains', 'touches': 'touches', 'entranceFor': 'enterTo', 'enterTo': 'entranceFor', 'optionsFor': 'hasOptions', 'hasOptions': 'optionsFor', 'triggeredBy': 'triggers', 'triggers': 'triggeredBy'}

The reciprocal feature relation types for each FeatureRelationshipType which has a required reciprocal.

class FeatureDecision(typing.TypedDict):
6228class FeatureDecision(TypedDict):
6229    """
6230    Represents a decision made during exploration, including the
6231    position(s) at which the explorer made the decision, which
6232    feature(s) were most relevant to the decision and what course of
6233    action was decided upon (see `FeatureAction`). Has the following
6234    slots:
6235
6236    - 'type': The type of decision (see `exploration.core.DecisionType`).
6237    - 'domains': A set of domains which are active during the decision,
6238        as opposed to domains which may be unfocused or otherwise
6239        inactive.
6240    - 'focus': An optional single `FeatureSpecifier` which represents the
6241        focal character or object for a decision. May be `None` e.g. in
6242        cases where a menu is in focus. Note that the 'positions' slot
6243        determines which positions are relevant to the decision,
6244        potentially separately from the focus but usually overlapping it.
6245    - 'positions': A dictionary mapping `core.Domain`s to sets of
6246        `FeatureSpecifier`s representing the player's position(s) in
6247        each domain. Some domains may function like tech trees, where
6248        the set of positions only expands over time. Others may function
6249        like a single avatar in a virtual world, where there is only one
6250        position. Still others might function like a group of virtual
6251        avatars, with multiple positions that can be updated
6252        independently.
6253    - 'intention': A `FeatureAction` indicating the action taken or
6254        attempted next as a result of the decision.
6255    """
6256    # TODO: HERE
6257    pass

Represents a decision made during exploration, including the position(s) at which the explorer made the decision, which feature(s) were most relevant to the decision and what course of action was decided upon (see FeatureAction). Has the following slots:

  • 'type': The type of decision (see DecisionType).
  • 'domains': A set of domains which are active during the decision, as opposed to domains which may be unfocused or otherwise inactive.
  • 'focus': An optional single FeatureSpecifier which represents the focal character or object for a decision. May be None e.g. in cases where a menu is in focus. Note that the 'positions' slot determines which positions are relevant to the decision, potentially separately from the focus but usually overlapping it.
  • 'positions': A dictionary mapping core.Domains to sets of FeatureSpecifiers representing the player's position(s) in each domain. Some domains may function like tech trees, where the set of positions only expands over time. Others may function like a single avatar in a virtual world, where there is only one position. Still others might function like a group of virtual avatars, with multiple positions that can be updated independently.
  • 'intention': A FeatureAction indicating the action taken or attempted next as a result of the decision.
FeatureAffordance = typing.Literal['approach', 'recede', 'follow', 'cross', 'enter', 'exit', 'explore', 'scrutinize', 'do', 'interact', 'focus']

The list of verbs that can be used to express actions taken in relation to features in a feature graph:

  • 'approach' and 'recede' apply to nodes, paths, edges, regions, and landmarks, and indicate movement towards or away from the feature.
  • 'follow' applies to paths and edges, and indicates travelling along. May be bundled with a direction indicator, although this can sometimes be inferred (e.g., if you're starting at a node that's touching one end of a path). For edges, a side-indicator may also be included. A destination-indicator can be used to indicate where along the item you end up (according to which other feature touching it you arrive at).
  • 'cross' applies to nodes, paths, edges, and regions, and may include a destination indicator when there are multiple possible destinations on the other side of the target from the current position.
  • 'enter' and 'exit' apply to regions and nodes, and indicate going inside of or coming out of the feature. The 'entranceFor' and 'enterTo' relations are used to indicate where you'll end up when entering a node, note that there can be multiple of these attached to different parts of the node. A destination indicator can also be specified on the action.
  • 'explore' applies to regions, nodes, and paths, and edges, and indicates a general lower-fractal-level series of actions taken to gain more complete knowledge about the target.
  • 'scrutinize' applies to any feature and indicates carefully probing the details of the feature to learn more about it (e.g., to look for a secret).
  • 'do' applies to affordances, and indicates performing whatever special action they represent.
  • 'interact' applies to entities, and indicates some kind of generic interaction with the entity. For more specific interactions, you can do one of two things:
    1. Place affordances touching or within the entity.
    2. Use an 'optionsFor' link to indicate which node (typically in a separate domain) represents the options made available by an interaction.
  • 'focus' applies to any kind of node, but usually entities. It represents changing the focal object/character for the player. However, note that focus shifts often happen without this affordance being involved, such as when entering a menu.
FEATURE_TYPE_AFFORDANCES: Dict[Literal['approach', 'recede', 'follow', 'cross', 'enter', 'exit', 'explore', 'scrutinize', 'do', 'interact', 'focus'], Set[Literal['node', 'path', 'edge', 'region', 'landmark', 'affordance', 'entity']]] = {'approach': {'node', 'region', 'entity', 'edge', 'landmark', 'path'}, 'recede': {'node', 'region', 'entity', 'edge', 'landmark', 'path'}, 'follow': {'path', 'entity', 'edge'}, 'cross': {'path', 'node', 'region', 'edge'}, 'enter': {'node', 'region'}, 'exit': {'node', 'region'}, 'explore': {'path', 'node', 'region', 'edge'}, 'scrutinize': {'node', 'region', 'entity', 'edge', 'landmark', 'affordance', 'path'}, 'do': {'affordance'}, 'interact': {'node', 'entity'}}

The mapping from affordances to the sets of feature types those affordances apply to.

class FeatureEffect(typing.TypedDict):
6337class FeatureEffect(TypedDict):
6338    """
6339    Similar to `Effect` but with more options for how to manipulate the
6340    game state. This represents a single concrete change to either
6341    internal game state, or to the feature graph. Multiple changes
6342    (possibly with random factors involved) can be represented by a
6343    `Consequence`; a `FeatureEffect` is used as a leaf in a `Consequence`
6344    tree.
6345    """
6346    type: Literal[
6347        'gain',
6348        'lose',
6349        'toggle',
6350        'deactivate',
6351        'move',
6352        'focus',
6353        'initiate'
6354        'foreground',
6355        'background',
6356    ]
6357    value: Union[
6358        Capability,
6359        Tuple[Token, int],
6360        List[Capability],
6361        None
6362    ]
6363    charges: Optional[int]
6364    delay: Optional[int]

Similar to Effect but with more options for how to manipulate the game state. This represents a single concrete change to either internal game state, or to the feature graph. Multiple changes (possibly with random factors involved) can be represented by a Consequence; a FeatureEffect is used as a leaf in a Consequence tree.

type: Literal['gain', 'lose', 'toggle', 'deactivate', 'move', 'focus', 'initiateforeground', 'background']
value: Union[str, Tuple[str, int], List[str], NoneType]
charges: Optional[int]
delay: Optional[int]
def featureEffect(**kwargs):
6367def featureEffect(
6368    #applyTo: ContextSpecifier = 'active',
6369    #gain: Optional[Union[
6370    #    Capability,
6371    #    Tuple[Token, TokenCount],
6372    #    Tuple[Literal['skill'], Skill, Level]
6373    #]] = None,
6374    #lose: Optional[Union[
6375    #    Capability,
6376    #    Tuple[Token, TokenCount],
6377    #    Tuple[Literal['skill'], Skill, Level]
6378    #]] = None,
6379    #set: Optional[Union[
6380    #    Tuple[Token, TokenCount],
6381    #    Tuple[AnyMechanismSpecifier, MechanismState],
6382    #    Tuple[Literal['skill'], Skill, Level]
6383    #]] = None,
6384    #toggle: Optional[Union[
6385    #    Tuple[AnyMechanismSpecifier, List[MechanismState]],
6386    #    List[Capability]
6387    #]] = None,
6388    #deactivate: Optional[bool] = None,
6389    #edit: Optional[List[List[commands.Command]]] = None,
6390    #goto: Optional[Union[
6391    #    AnyDecisionSpecifier,
6392    #    Tuple[AnyDecisionSpecifier, FocalPointName]
6393    #]] = None,
6394    #bounce: Optional[bool] = None,
6395    #delay: Optional[int] = None,
6396    #charges: Optional[int] = None,
6397    **kwargs
6398):
6399    # TODO: HERE
6400    return effect(**kwargs)
class FeatureAction(typing.TypedDict):
6405class FeatureAction(TypedDict):
6406    """
6407    Indicates an action decided on by a `FeatureDecision`. Has the
6408    following slots:
6409
6410    - 'subject': the main feature (an `AnyFeatureSpecifier`) that
6411        performs the action (usually an 'entity').
6412    - 'object': the main feature (an `AnyFeatureSpecifier`) with which
6413        the affordance is performed.
6414    - 'affordance': the specific `FeatureAffordance` indicating the type
6415        of action.
6416    - 'direction': The general direction of movement (especially when
6417        the affordance is `follow`). This can be either a direction in
6418        an associated `MetricSpace`, or it can be defined towards or
6419        away from the destination specified. If a destination but no
6420        direction is provided, the direction is assumed to be towards
6421        that destination.
6422    - 'part': The part within/along a feature for movement (e.g., which
6423        side of an edge are you on, or which part of a region are you
6424        traveling through).
6425    - 'destination': The destination of the action (when known ahead of
6426        time). For example, moving along a path towards a particular
6427        feature touching that path, or entering a node into a particular
6428        feature within that node. Note that entering of regions can be
6429        left implicit: if you enter a region to get to a landmark within
6430        it, noting that as approaching the landmark is more appropriate
6431        than noting that as entering the region with the landmark as the
6432        destination. The system can infer what regions you're in by
6433        which feature you're at.
6434    - 'outcome': A `Consequence` list/tree indicating one or more
6435        outcomes, possibly involving challenges. Note that the actual
6436        outcomes of an action may be altered by triggers; the outcomes
6437        listed here are the default outcomes if no triggers are tripped.
6438
6439    The 'direction', 'part', and/or 'destination' may each be None,
6440    depending on the type of affordance and/or amount of detail desired.
6441    """
6442    subject: AnyFeatureSpecifier
6443    object: AnyFeatureSpecifier
6444    affordance: FeatureAffordance
6445    direction: Optional[Part]
6446    part: Optional[Part]
6447    destination: Optional[AnyFeatureSpecifier]
6448    outcome: Consequence

Indicates an action decided on by a FeatureDecision. Has the following slots:

  • 'subject': the main feature (an AnyFeatureSpecifier) that performs the action (usually an 'entity').
  • 'object': the main feature (an AnyFeatureSpecifier) with which the affordance is performed.
  • 'affordance': the specific FeatureAffordance indicating the type of action.
  • 'direction': The general direction of movement (especially when the affordance is follow). This can be either a direction in an associated MetricSpace, or it can be defined towards or away from the destination specified. If a destination but no direction is provided, the direction is assumed to be towards that destination.
  • 'part': The part within/along a feature for movement (e.g., which side of an edge are you on, or which part of a region are you traveling through).
  • 'destination': The destination of the action (when known ahead of time). For example, moving along a path towards a particular feature touching that path, or entering a node into a particular feature within that node. Note that entering of regions can be left implicit: if you enter a region to get to a landmark within it, noting that as approaching the landmark is more appropriate than noting that as entering the region with the landmark as the destination. The system can infer what regions you're in by which feature you're at.
  • 'outcome': A Consequence list/tree indicating one or more outcomes, possibly involving challenges. Note that the actual outcomes of an action may be altered by triggers; the outcomes listed here are the default outcomes if no triggers are tripped.

The 'direction', 'part', and/or 'destination' may each be None, depending on the type of affordance and/or amount of detail desired.

subject: Union[int, str, FeatureSpecifier]
object: Union[int, str, FeatureSpecifier]
affordance: Literal['approach', 'recede', 'follow', 'cross', 'enter', 'exit', 'explore', 'scrutinize', 'do', 'interact', 'focus']
direction: Optional[str]
part: Optional[str]
destination: Union[int, str, FeatureSpecifier, NoneType]
outcome: List[Union[Challenge, Effect, Condition]]