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

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):
205class DecisionSpecifier(NamedTuple):
206    """
207    A decision specifier attempts to uniquely identify a decision by
208    name, rather than by ID. See `AnyDecisionSpecifier` for a type which
209    can also be an ID.
210
211    Ambiguity is possible if two decisions share the same name; the
212    decision specifier provides two means of disambiguation: a domain
213    may be specified, and a zone may be specified; if either is
214    specified only decisions within that domain and/or zone will match,
215    but of course there could still be multiple decisions that match
216    those criteria that still share names, in which case many operations
217    will end up raising an `AmbiguousDecisionSpecifierError`.
218    """
219    domain: Optional[Domain]
220    zone: Optional[Zone]
221    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):
233class InvalidDecisionSpecifierError(ValueError):
234    """
235    An error used when a decision specifier is in the wrong format.
236    """

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):
239class InvalidMechanismSpecifierError(ValueError):
240    """
241    An error used when a mechanism specifier is invalid.
242    """

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]]:
275def nameAndOutcomes(transition: AnyTransition) -> TransitionWithOutcomes:
276    """
277    Returns a `TransitionWithOutcomes` when given either one of those
278    already or a base `Transition`. Outcomes will be an empty list when
279    given a transition alone. Checks that the type actually matches.
280    """
281    if isinstance(transition, Transition):
282        return (transition, [])
283    else:
284        if not isinstance(transition, tuple) or len(transition) != 2:
285            raise TypeError(
286                f"Transition with outcomes must be a length-2 tuple."
287                f" Got: {transition!r}"
288            )
289        name, outcomes = transition
290        if not isinstance(name, Transition):
291            raise TypeError(
292                f"Transition name must be a string."
293                f" Got: {name!r}"
294            )
295        if (
296            not isinstance(outcomes, list)
297         or not all(isinstance(x, bool) for x in outcomes)
298        ):
299            raise TypeError(
300                f"Transition outcomes must be a list of booleans."
301                f" Got: {outcomes!r}"
302            )
303        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):
413class MechanismSpecifier(NamedTuple):
414    """
415    Specifies a mechanism either just by name, or with domain and/or
416    zone and/or decision name hints.
417    """
418    domain: Optional[Domain]
419    zone: Optional[Zone]
420    decision: Optional[DecisionName]
421    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:
424def mechanismAt(
425    name: MechanismName,
426    domain: Optional[Domain] = None,
427    zone: Optional[Zone] = None,
428    decision: Optional[DecisionName] = None
429) -> MechanismSpecifier:
430    """
431    Builds a `MechanismSpecifier` using `None` default hints but
432    accepting `domain`, `zone`, and/or `decision` hints.
433    """
434    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):
469class CapabilitySet(TypedDict):
470    """
471    Represents a set of capabilities, including boolean on/off
472    `Capability` names, countable `Token`s accumulated, and
473    integer-leveled skills. It has three slots:
474
475    - 'capabilities': A set representing which `Capability`s this
476        `CapabilitySet` includes.
477    - 'tokens': A dictionary mapping `Token` types to integers
478        representing how many of that token type this `CapabilitySet` has
479        accumulated.
480    - 'skills': A dictionary mapping `Skill` types to `Level` integers,
481        representing what skill levels this `CapabilitySet` has.
482    """
483    capabilities: Set[Capability]
484    tokens: Dict[Token, TokenCount]
485    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):
554class FocalContext(TypedDict):
555    """
556    Focal contexts identify an avatar or similar situation where the player
557    has certain capabilities available (a `CapabilitySet`) and may also have
558    position information in one or more `Domain`s (see `State` and
559    `DomainFocalization`). Normally, only a single `FocalContext` is needed,
560    but situations where the player swaps between capability sets and/or
561    positions sometimes call for more.
562
563    At each decision step, only a single `FocalContext` is active, and the
564    capabilities of that context (plus capabilities of the 'common'
565    context) determine what transitions are traversable. At the same time,
566    the set of reachable transitions is determined by the focal context's
567    per-domain position information, including its per-domain
568    `DomainFocalization` type.
569
570    The slots are:
571
572    - 'capabilities': A `CapabilitySet` representing what capabilities,
573        tokens, and skills this context has. Note that capabilities from
574        the common `FocalContext` are added to these to determine what
575        transition requirements are met in a given step.
576    - 'focalization': A mapping from `Domain`s to `DomainFocalization`
577        specifying how this context is focalized in each domain.
578    - 'activeDomains': A set of `Domain`s indicating which `Domain`(s) are
579        active for this focal context right now.
580    - 'activeDecisions': A mapping from `Domain`s to either single
581        `DecisionID`s, dictionaries mapping `FocalPointName`s to
582        optional `DecisionID`s, or sets of `DecisionID`s. Which one is
583        used depends on the `DomainFocalization` of this context for
584        that domain. May also be `None` for domains in which no
585        decisions are active (and in 'plural'-focalization lists,
586       individual entries may be `None`). Active decisions from the
587        common `FocalContext` are also considered active at each step.
588    """
589    capabilities: CapabilitySet
590    focalization: Dict[Domain, DomainFocalization]
591    activeDomains: Set[Domain]
592    activeDecisions: Dict[
593        Domain,
594        Union[
595            None,
596            DecisionID,
597            Dict[FocalPointName, Optional[DecisionID]],
598            Set[DecisionID]
599        ]
600    ]

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']:
611def getDomainFocalization(
612    context: FocalContext,
613    domain: Domain,
614    defaultFocalization: DomainFocalization = 'singular'
615) -> DomainFocalization:
616    """
617    Fetches the focalization value for the given domain in the given
618    focal context, setting it to the provided default first if that
619    focal context didn't have an entry for that domain yet.
620    """
621    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):
624class State(TypedDict):
625    """
626    Represents a game state, including certain exploration-relevant
627    information, plus possibly extra custom information. Has the
628    following slots:
629
630    - 'common': A single `FocalContext` containing capability and position
631        information which is always active in addition to the current
632        `FocalContext`'s information.
633    - 'contexts': A dictionary mapping strings to `FocalContext`s, which
634        store capability and position information.
635    - 'activeContext': A string naming the currently-active
636        `FocalContext` (a key of the 'contexts' slot).
637    - 'primaryDecision': A `DecisionID` (or `None`) indicating the
638        primary decision that is being considered in this state. Whereas
639        the focalization structures can and often will indicate multiple
640        active decisions, whichever decision the player just arrived at
641        via the transition selected in a previous state will be the most
642        relevant, and we track that here. Of course, for some states
643        (like a pre-starting initial state) there is no primary
644        decision.
645    - 'mechanisms': A dictionary mapping `Mechanism` IDs to
646        `MechanismState` strings.
647    - 'exploration': A dictionary mapping decision IDs to exploration
648        statuses, which tracks how much knowledge the player has of
649        different decisions.
650    - 'effectCounts': A dictionary mapping `EffectSpecifier`s to
651        integers specifying how many times that effect has been
652        triggered since the beginning of the exploration (including
653        times that the actual effect was not applied due to delays
654        and/or charges. This is used to figure out when effects with
655        charges and/or delays should be applied.
656    - 'deactivated':  A set of (`DecisionID`, `Transition`) tuples
657        specifying which transitions have been deactivated. This is used
658        in addition to transition requirements to figure out which
659        transitions are traversable.
660    - 'custom': An arbitrary sub-dictionary representing any kind of
661        custom game state. In most cases, things can be reasonably
662        approximated via capabilities and tokens and custom game state is
663        not needed.
664    """
665    common: FocalContext
666    contexts: Dict[FocalContextName, FocalContext]
667    activeContext: FocalContextName
668    primaryDecision: Optional[DecisionID]
669    mechanisms: Dict[MechanismID, MechanismState]
670    exploration: Dict[DecisionID, 'ExplorationStatus']
671    effectCounts: Dict[EffectSpecifier, int]
672    deactivated: Set[Tuple[DecisionID, Transition]]
673    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]:
680def idOrDecisionSpecifier(
681    ds: DecisionSpecifier
682) -> Union[DecisionSpecifier, int]:
683    """
684    Given a decision specifier which might use a name that's convertible
685    to an integer ID, returns the appropriate ID if so, and the original
686    decision specifier if not, raising an
687    `InvalidDecisionSpecifierError` if given a specifier with a
688    convertible name that also has other parts.
689    """
690    try:
691        dID = int(ds.name)
692    except ValueError:
693        return ds
694
695    if ds.domain is None and ds.zone is None:
696        return dID
697    else:
698        raise InvalidDecisionSpecifierError(
699            f"Specifier {ds} has an ID name but also includes"
700            f" domain and/or zone information."
701        )

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:
704def spliceDecisionSpecifiers(
705    base: DecisionSpecifier,
706    default: DecisionSpecifier
707) -> DecisionSpecifier:
708    """
709    Copies domain and/or zone info from the `default` specifier into the
710    `base` specifier, returning a new `DecisionSpecifier` without
711    modifying either argument. Info is only copied where the `base`
712    specifier has a missing value, although if the base specifier has a
713    domain but no zone and the domain is different from that of the
714    default specifier, no zone info is copied.
715
716    For example:
717
718    >>> d1 = DecisionSpecifier('main', 'zone', 'name')
719    >>> d2 = DecisionSpecifier('niam', 'enoz', 'eman')
720    >>> spliceDecisionSpecifiers(d1, d2)
721    DecisionSpecifier(domain='main', zone='zone', name='name')
722    >>> spliceDecisionSpecifiers(d2, d1)
723    DecisionSpecifier(domain='niam', zone='enoz', name='eman')
724    >>> d3 = DecisionSpecifier(None, None, 'three')
725    >>> spliceDecisionSpecifiers(d3, d1)
726    DecisionSpecifier(domain='main', zone='zone', name='three')
727    >>> spliceDecisionSpecifiers(d3, d2)
728    DecisionSpecifier(domain='niam', zone='enoz', name='three')
729    >>> d4 = DecisionSpecifier('niam', None, 'four')
730    >>> spliceDecisionSpecifiers(d4, d1)  # diff domain -> no zone
731    DecisionSpecifier(domain='niam', zone=None, name='four')
732    >>> spliceDecisionSpecifiers(d4, d2)  # same domian -> copy zone
733    DecisionSpecifier(domain='niam', zone='enoz', name='four')
734    >>> d5 = DecisionSpecifier(None, 'cone', 'five')
735    >>> spliceDecisionSpecifiers(d4, d5)  # None domain -> copy zone
736    DecisionSpecifier(domain='niam', zone='cone', name='four')
737    """
738    newDomain = base.domain
739    if newDomain is None:
740        newDomain = default.domain
741    newZone = base.zone
742    if (
743        newZone is None
744    and (newDomain == default.domain or default.domain is None)
745    ):
746        newZone = default.zone
747
748    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:
751def mergeCapabilitySets(A: CapabilitySet, B: CapabilitySet) -> CapabilitySet:
752    """
753    Merges two capability sets into a new one, where all capabilities in
754    either original set are active, and token counts and skill levels are
755    summed.
756
757    Example:
758
759    >>> cs1 = {
760    ...    'capabilities': {'fly', 'whistle'},
761    ...    'tokens': {'subway': 3},
762    ...    'skills': {'agility': 1, 'puzzling': 3},
763    ... }
764    >>> cs2 = {
765    ...    'capabilities': {'dig', 'fly'},
766    ...    'tokens': {'subway': 1, 'goat': 2},
767    ...    'skills': {'agility': -1},
768    ... }
769    >>> ms = mergeCapabilitySets(cs1, cs2)
770    >>> ms['capabilities'] == {'fly', 'whistle', 'dig'}
771    True
772    >>> ms['tokens'] == {'subway': 4, 'goat': 2}
773    True
774    >>> ms['skills'] == {'agility': 0, 'puzzling': 3}
775    True
776    """
777    # Set up our result
778    result: CapabilitySet = {
779        'capabilities': set(),
780        'tokens': {},
781        'skills': {}
782    }
783
784    # Merge capabilities
785    result['capabilities'].update(A['capabilities'])
786    result['capabilities'].update(B['capabilities'])
787
788    # Merge tokens
789    tokensA = A['tokens']
790    tokensB = B['tokens']
791    resultTokens = result['tokens']
792    for tType, val in tokensA.items():
793        if tType not in resultTokens:
794            resultTokens[tType] = val
795        else:
796            resultTokens[tType] += val
797    for tType, val in tokensB.items():
798        if tType not in resultTokens:
799            resultTokens[tType] = val
800        else:
801            resultTokens[tType] += val
802
803    # Merge skills
804    skillsA = A['skills']
805    skillsB = B['skills']
806    resultSkills = result['skills']
807    for skill, level in skillsA.items():
808        if skill not in resultSkills:
809            resultSkills[skill] = level
810        else:
811            resultSkills[skill] += level
812    for skill, level in skillsB.items():
813        if skill not in resultSkills:
814            resultSkills[skill] = level
815        else:
816            resultSkills[skill] += level
817
818    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:
821def emptyFocalContext() -> FocalContext:
822    """
823    Returns a completely empty focal context, which has no capabilities
824    and which has no associated domains.
825    """
826    return {
827        'capabilities': {
828            'capabilities': set(),
829            'tokens': {},
830            'skills': {}
831        },
832        'focalization': {},
833        'activeDomains': set(),
834        'activeDecisions': {}
835    }

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

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

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

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

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]:
930def combinedDecisionSet(state: State) -> Set[DecisionID]:
931    """
932    Given a `State` object, computes the active decision set for that
933    state, which is the set of decisions at which the player can make an
934    immediate decision. This depends on the 'common' `FocalContext` as
935    well as the active focal context, and of course each `FocalContext`
936    may specify separate active decisions for different domains, separate
937    sets of active domains, etc. See `FocalContext` and
938    `DomainFocalization` for more details, as well as `activeDecisionSet`.
939
940    Returns a set of `DecisionID`s.
941    """
942    commonContext = state['common']
943    activeContext = state['contexts'][state['activeContext']]
944    result = set()
945    for ctx in (commonContext, activeContext):
946        result |= activeDecisionSet(ctx)
947
948    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]:
 951def activeDecisionSet(context: FocalContext) -> Set[DecisionID]:
 952    """
 953    Given a `FocalContext`, returns the set of all `DecisionID`s which
 954    are active in that focal context. This includes only decisions which
 955    are in active domains.
 956
 957    For example:
 958
 959    >>> fc = emptyFocalContext()
 960    >>> activeDecisionSet(fc)
 961    set()
 962    >>> fc['focalization'] = {
 963    ...     'Si': 'singular',
 964    ...     'Pl': 'plural',
 965    ...     'Sp': 'spreading'
 966    ... }
 967    >>> fc['activeDomains'] = {'Si'}
 968    >>> fc['activeDecisions'] = {
 969    ...     'Si': 0,
 970    ...     'Pl': {'one': 1, 'two': 2},
 971    ...     'Sp': {3, 4}
 972    ... }
 973    >>> activeDecisionSet(fc)
 974    {0}
 975    >>> fc['activeDomains'] = {'Si', 'Pl'}
 976    >>> sorted(activeDecisionSet(fc))
 977    [0, 1, 2]
 978    >>> fc['activeDomains'] = {'Pl'}
 979    >>> sorted(activeDecisionSet(fc))
 980    [1, 2]
 981    >>> fc['activeDomains'] = {'Sp'}
 982    >>> sorted(activeDecisionSet(fc))
 983    [3, 4]
 984    >>> fc['activeDomains'] = {'Si', 'Pl', 'Sp'}
 985    >>> sorted(activeDecisionSet(fc))
 986    [0, 1, 2, 3, 4]
 987    """
 988    result = set()
 989    decisionsMap = context['activeDecisions']
 990    for domain in context['activeDomains']:
 991        activeGroup = decisionsMap[domain]
 992        if activeGroup is None:
 993            pass
 994        elif isinstance(activeGroup, DecisionID):
 995            result.add(activeGroup)
 996        elif isinstance(activeGroup, dict):
 997            for x in activeGroup.values():
 998                if x is not None:
 999                    result.add(x)
1000        elif isinstance(activeGroup, set):
1001            result.update(activeGroup)
1002        else:
1003            raise TypeError(
1004                f"The FocalContext {repr(context)} has an invalid"
1005                f" active group for domain {repr(domain)}."
1006                f"\nGroup is: {repr(activeGroup)}"
1007            )
1008
1009    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']:
1071def moreExplored(
1072    a: ExplorationStatus,
1073    b: ExplorationStatus
1074) -> ExplorationStatus:
1075    """
1076    Returns whichever of the two exploration statuses counts as 'more
1077    explored'.
1078    """
1079    eArgs = get_args(ExplorationStatus)
1080    try:
1081        aIndex = eArgs.index(a)
1082    except ValueError:
1083        raise ValueError(
1084            f"Status {a!r} is not a valid exploration status. Must be"
1085            f" one of: {eArgs!r}"
1086        )
1087    try:
1088        bIndex = eArgs.index(b)
1089    except ValueError:
1090        raise ValueError(
1091            f"Status {b!r} is not a valid exploration status. Must be"
1092            f" one of: {eArgs!r}"
1093        )
1094    if aIndex > bIndex:
1095        return a
1096    else:
1097        return b

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

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

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):
1552class Effect(TypedDict):
1553    """
1554    Represents one effect of a transition on the decision graph and/or
1555    game state. The `type` slot is an `EffectType` that indicates what
1556    type of effect it is, and determines what the `value` slot will hold.
1557    The `charges` slot is normally `None`, but when set to an integer,
1558    the effect will only trigger that many times, subtracting one charge
1559    each time until it reaches 0, after which the effect will remain but
1560    be ignored. The `delay` slot is also normally `None`, but when set to
1561    an integer, the effect won't trigger but will instead subtract one
1562    from the delay until it reaches zero, at which point it will start to
1563    trigger (and use up charges if there are any). The 'applyTo' slot
1564    should be either 'common' or 'active' (a `ContextSpecifier`) and
1565    determines which focal context the effect applies to.
1566
1567    The `value` values for each `type` are:
1568
1569    - `'gain'`: A `Capability`, (`Token`, `TokenCount`) pair, or
1570        ('skill', `Skill`, `Level`) triple indicating a capability
1571        gained, some tokens acquired, or skill levels gained.
1572    - `'lose'`: A `Capability`, (`Token`, `TokenCount`) pair, or
1573        ('skill', `Skill`, `Level`) triple indicating a capability lost,
1574        some tokens spent, or skill levels lost. Note that the literal
1575        string 'skill' is added to disambiguate skills from tokens.
1576    - `'set'`: A (`Token`, `TokenCount`) pair, a (`MechanismSpecifier`,
1577        `MechanismState`) pair, or a ('skill', `Skill`, `Level`) triple
1578        indicating the new number of tokens, new mechanism state, or new
1579        skill level to establish. Ignores the old count/level, unlike
1580        'gain' and 'lose.'
1581    - `'toggle'`: A list of capabilities which will be toggled on one
1582        after the other, toggling the rest off, OR, a tuple containing a
1583        mechanism name followed by a list of states to be set one after
1584        the other. Does not work for tokens or skills. If a `Capability`
1585        list only has one item, it will be toggled on or off depending
1586        on whether the player currently has that capability or not,
1587        otherwise, whichever capability in the toggle list is currently
1588        active will determine which one gets activated next (the
1589        subsequent one in the list, wrapping from the end to the start).
1590        Note that equivalences are NOT considered when determining which
1591        capability to turn on, and ALL capabilities in the toggle list
1592        except the next one to turn on are turned off. Also, if none of
1593        the capabilities in the list is currently activated, the first
1594        one will be. For mechanisms, `DEFAULT_MECHANISM_STATE` will be
1595        used as the default state if only one state is provided, since
1596        mechanisms can't be "not in a state." `Mechanism` toggles
1597        function based on the current mechanism state; if it's not in
1598        the list they set the first given state.
1599    - `'deactivate'`: `None`. When the effect is activated, the
1600        transition it applies on will be added to the deactivated set in
1601        the current state. This effect type ignores the 'applyTo' value
1602        since it does not make changes to a `FocalContext`.
1603    - `'edit'`: A list of lists of `Command`s, with each list to be
1604        applied in succession on every subsequent activation of the
1605        transition (like toggle). These can use extra variables '$@' to
1606        refer to the source decision of the transition the edit effect is
1607        attached to, '$@d' to refer to the destination decision, '$@t' to
1608        refer to the transition, and '$@r' to refer to its reciprocal.
1609        Commands are powerful and might edit more than just the
1610        specified focal context.
1611        TODO: How to handle list-of-lists format?
1612    - `'goto'`: Either an `AnyDecisionSpecifier` specifying where the
1613        player should end up, or an (`AnyDecisionSpecifier`,
1614        `FocalPointName`) specifying both where they should end up and
1615        which focal point in the relevant domain should be moved. If
1616        multiple 'goto' values are present on different effects of a
1617        transition, they each trigger in turn (and e.g., might activate
1618        multiple decision points in a spreading-focalized domain). Every
1619        transition has a destination, so 'goto' is not necessary: use it
1620        only when an attempt to take a transition is diverted (and
1621        normally, in conjunction with 'charges', 'delay', and/or as an
1622        effect that's behind a `Challenge` or `Conditional`). If a goto
1623        specifies a destination in a plural-focalized domain, but does
1624        not include a focal point name, then the focal point which was
1625        taking the transition will be the one to move. If that
1626        information is not available, the first focal point created in
1627        that domain will be moved by default. Note that when using
1628        something other than a destination ID as the
1629        `AnyDecisionSpecifier`, it's up to you to ensure that the
1630        specifier is not ambiguous, otherwise taking the transition will
1631        crash the program.
1632    - `'bounce'`: Value will be `None`. Prevents the normal position
1633        update associated with a transition that this effect applies to.
1634        Normally, a transition should be marked with an appropriate
1635        requirement to prevent access, even in cases where access seems
1636        possible until tested (just add the requirement on a step after
1637        the transition is observed where relevant). However, 'bounce' can
1638        be used in cases where there's a challenge to fail, for example.
1639        `bounce` is redundant with `goto`: if a `goto` effect applies on
1640        a certain transition, the presence or absence of `bounce` on the
1641        same transition is ignored, since the new position will be
1642        specified by the `goto` value anyways.
1643    - `'follow'`: Value will be a `Transition` name. A transition with
1644        that name must exist at the destination of the action, and when
1645        the follow effect triggers, the player will immediately take
1646        that transition (triggering any consequences it has) after
1647        arriving at their normal destination (so the exploration status
1648        of the normal destination will also be updated). This can result
1649        in an infinite loop if two 'follow' effects imply transitions
1650        which trigger each other, so don't do that.
1651    - `'save'`: Value will be a string indicating a save-slot name.
1652        Indicates a save point, which can be returned to using a
1653        'revertTo' `ExplorationAction`. The entire game state and current
1654        graph is recorded, including effects of the current consequence
1655        before, but not after, the 'save' effect. However, the graph
1656        configuration is not restored by default (see 'revert'). A revert
1657        effect may specify only parts of the state to revert.
1658
1659    TODO:
1660        'focus',
1661        'foreground',
1662        'background',
1663    """
1664    type: EffectType
1665    applyTo: ContextSpecifier
1666    value: AnyEffectValue
1667    charges: Optional[int]
1668    delay: Optional[int]
1669    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:
1672def effect(
1673    *,
1674    applyTo: ContextSpecifier = 'active',
1675    gain: Optional[Union[
1676        Capability,
1677        Tuple[Token, TokenCount],
1678        Tuple[Literal['skill'], Skill, Level]
1679    ]] = None,
1680    lose: Optional[Union[
1681        Capability,
1682        Tuple[Token, TokenCount],
1683        Tuple[Literal['skill'], Skill, Level]
1684    ]] = None,
1685    set: Optional[Union[
1686        Tuple[Token, TokenCount],
1687        Tuple[AnyMechanismSpecifier, MechanismState],
1688        Tuple[Literal['skill'], Skill, Level]
1689    ]] = None,
1690    toggle: Optional[Union[
1691        Tuple[AnyMechanismSpecifier, List[MechanismState]],
1692        List[Capability]
1693    ]] = None,
1694    deactivate: Optional[bool] = None,
1695    edit: Optional[List[List[commands.Command]]] = None,
1696    goto: Optional[Union[
1697        AnyDecisionSpecifier,
1698        Tuple[AnyDecisionSpecifier, FocalPointName]
1699    ]] = None,
1700    bounce: Optional[bool] = None,
1701    follow: Optional[Transition] = None,
1702    save: Optional[SaveSlot] = None,
1703    delay: Optional[int] = None,
1704    charges: Optional[int] = None,
1705    hidden: bool = False
1706) -> Effect:
1707    """
1708    Factory for a transition effect which includes default values so you
1709    can just specify effect types that are relevant to a particular
1710    situation. You may not supply values for more than one of
1711    gain/lose/set/toggle/deactivate/edit/goto/bounce, since which one
1712    you use determines the effect type.
1713    """
1714    tCount = len([
1715        x
1716        for x in (
1717            gain,
1718            lose,
1719            set,
1720            toggle,
1721            deactivate,
1722            edit,
1723            goto,
1724            bounce,
1725            follow,
1726            save
1727        )
1728        if x is not None
1729    ])
1730    if tCount == 0:
1731        raise ValueError(
1732            "You must specify one of gain, lose, set, toggle, deactivate,"
1733            " edit, goto, bounce, follow, or save."
1734        )
1735    elif tCount > 1:
1736        raise ValueError(
1737            f"You may only specify one of gain, lose, set, toggle,"
1738            f" deactivate, edit, goto, bounce, follow, or save"
1739            f" (you provided values for {tCount} of those)."
1740        )
1741
1742    result: Effect = {
1743        'type': 'edit',
1744        'applyTo': applyTo,
1745        'value': [],
1746        'delay': delay,
1747        'charges': charges,
1748        'hidden': hidden
1749    }
1750
1751    if gain is not None:
1752        result['type'] = 'gain'
1753        result['value'] = gain
1754    elif lose is not None:
1755        result['type'] = 'lose'
1756        result['value'] = lose
1757    elif set is not None:
1758        result['type'] = 'set'
1759        if (
1760            len(set) == 2
1761        and isinstance(set[0], MechanismName)
1762        and isinstance(set[1], MechanismState)
1763        ):
1764            result['value'] = (
1765                MechanismSpecifier(None, None, None, set[0]),
1766                set[1]
1767            )
1768        else:
1769            result['value'] = set
1770    elif toggle is not None:
1771        result['type'] = 'toggle'
1772        result['value'] = toggle
1773    elif deactivate is not None:
1774        result['type'] = 'deactivate'
1775        result['value'] = None
1776    elif edit is not None:
1777        result['type'] = 'edit'
1778        result['value'] = edit
1779    elif goto is not None:
1780        result['type'] = 'goto'
1781        result['value'] = goto
1782    elif bounce is not None:
1783        result['type'] = 'bounce'
1784        result['value'] = None
1785    elif follow is not None:
1786        result['type'] = 'follow'
1787        result['value'] = follow
1788    elif save is not None:
1789        result['type'] = 'save'
1790        result['value'] = save
1791    else:
1792        raise RuntimeError(
1793            "No effect specified in effect function & check failed."
1794        )
1795
1796    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:
1799class SkillCombination:
1800    """
1801    Represents which skill(s) are used for a `Challenge`, including under
1802    what circumstances different skills might apply using
1803    `Requirement`s. This is an abstract class, use the subclasses
1804    `BestSkill`, `WorstSkill`, `CombinedSkill`, `InverseSkill`, and/or
1805    `ConditionalSkill` to represent a specific situation. To represent a
1806    single required skill, use a `BestSkill` or `CombinedSkill` with
1807    that skill as the only skill.
1808
1809    Use `SkillCombination.effectiveLevel` to figure out the effective
1810    level of the entire requirement in a given situation. Note that
1811    levels from the common and active `FocalContext`s are added together
1812    whenever a specific skill level is referenced.
1813
1814    Some examples:
1815
1816    >>> from . import core
1817    >>> ctx = RequirementContext(emptyState(), core.DecisionGraph(), set())
1818    >>> ctx.state['common']['capabilities']['skills']['brawn'] = 1
1819    >>> ctx.state['common']['capabilities']['skills']['brains'] = 3
1820    >>> ctx.state['common']['capabilities']['skills']['luck'] = -1
1821
1822    1. To represent using just the 'brains' skill, you would use:
1823
1824        `BestSkill('brains')`
1825
1826        >>> sr = BestSkill('brains')
1827        >>> sr.effectiveLevel(ctx)
1828        3
1829
1830        If a skill isn't listed, its level counts as 0:
1831
1832        >>> sr = BestSkill('agility')
1833        >>> sr.effectiveLevel(ctx)
1834        0
1835
1836        To represent using the higher of 'brains' or 'brawn' you'd use:
1837
1838        `BestSkill('brains', 'brawn')`
1839
1840        >>> sr = BestSkill('brains', 'brawn')
1841        >>> sr.effectiveLevel(ctx)
1842        3
1843
1844        The zero default only applies if an unknown skill is in the mix:
1845
1846        >>> sr = BestSkill('luck')
1847        >>> sr.effectiveLevel(ctx)
1848        -1
1849        >>> sr = BestSkill('luck', 'agility')
1850        >>> sr.effectiveLevel(ctx)
1851        0
1852
1853    2. To represent using the lower of 'brains' or 'brawn' you'd use:
1854
1855        `WorstSkill('brains', 'brawn')`
1856
1857        >>> sr = WorstSkill('brains', 'brawn')
1858        >>> sr.effectiveLevel(ctx)
1859        1
1860
1861    3. To represent using 'brawn' if the focal context has the 'brawny'
1862        capability, but brains if not, use:
1863
1864        ```
1865        ConditionalSkill(
1866            ReqCapability('brawny'),
1867            'brawn',
1868            'brains'
1869        )
1870        ```
1871
1872        >>> sr = ConditionalSkill(
1873        ...     ReqCapability('brawny'),
1874        ...     'brawn',
1875        ...     'brains'
1876        ... )
1877        >>> sr.effectiveLevel(ctx)
1878        3
1879        >>> brawny = copy.deepcopy(ctx)
1880        >>> brawny.state['common']['capabilities']['capabilities'].add(
1881        ...     'brawny'
1882        ... )
1883        >>> sr.effectiveLevel(brawny)
1884        1
1885
1886        If the player can still choose to use 'brains' even when they
1887        have the 'brawny' capability, you would do:
1888
1889        >>> sr = ConditionalSkill(
1890        ...     ReqCapability('brawny'),
1891        ...     BestSkill('brawn', 'brains'),
1892        ...     'brains'
1893        ... )
1894        >>> sr.effectiveLevel(ctx)
1895        3
1896        >>> sr.effectiveLevel(brawny)  # can still use brains if better
1897        3
1898
1899    4. To represent using the combined level of the 'brains' and
1900        'brawn' skills, you would use:
1901
1902        `CombinedSkill('brains', 'brawn')`
1903
1904        >>> sr = CombinedSkill('brains', 'brawn')
1905        >>> sr.effectiveLevel(ctx)
1906        4
1907
1908    5. Skill names can be replaced by entire sub-`SkillCombination`s in
1909        any position, so more complex forms are possible:
1910
1911        >>> sr = BestSkill(CombinedSkill('brains', 'luck'), 'brawn')
1912        >>> sr.effectiveLevel(ctx)
1913        2
1914        >>> sr = BestSkill(
1915        ...     ConditionalSkill(
1916        ...         ReqCapability('brawny'),
1917        ...         'brawn',
1918        ...         'brains',
1919        ...     ),
1920        ...     CombinedSkill('brains', 'luck')
1921        ... )
1922        >>> sr.effectiveLevel(ctx)
1923        3
1924        >>> sr.effectiveLevel(brawny)
1925        2
1926    """
1927    def effectiveLevel(self, context: 'RequirementContext') -> Level:
1928        """
1929        Returns the effective `Level` of the skill combination, given
1930        the situation specified by the provided `RequirementContext`.
1931        """
1932        raise NotImplementedError(
1933            "SkillCombination is an abstract class. Use one of its"
1934            " subclsases instead."
1935        )
1936
1937    def __eq__(self, other: Any) -> bool:
1938        raise NotImplementedError(
1939            "SkillCombination is an abstract class and cannot be compared."
1940        )
1941
1942    def __hash__(self) -> int:
1943        raise NotImplementedError(
1944            "SkillCombination is an abstract class and cannot be hashed."
1945        )
1946
1947    def walk(self) -> Generator[
1948        Union['SkillCombination', Skill, Level],
1949        None,
1950        None
1951    ]:
1952        """
1953        Yields this combination and each sub-part in depth-first
1954        traversal order.
1955        """
1956        raise NotImplementedError(
1957            "SkillCombination is an abstract class and cannot be walked."
1958        )
1959
1960    def unparse(self) -> str:
1961        """
1962        Returns a string that `SkillCombination.parse` would turn back
1963        into a `SkillCombination` equivalent to this one. For example:
1964
1965        >>> BestSkill('brains').unparse()
1966        'best(brains)'
1967        >>> WorstSkill('brains', 'brawn').unparse()
1968        'worst(brains, brawn)'
1969        >>> CombinedSkill(
1970        ...     ConditionalSkill(ReqTokens('orb', 3), 'brains', 0),
1971        ...     InverseSkill('luck')
1972        ... ).unparse()
1973        'sum(if(orb*3, brains, 0), ~luck)'
1974        """
1975        raise NotImplementedError(
1976            "SkillCombination is an abstract class and cannot be"
1977            " unparsed."
1978        )

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:
1927    def effectiveLevel(self, context: 'RequirementContext') -> Level:
1928        """
1929        Returns the effective `Level` of the skill combination, given
1930        the situation specified by the provided `RequirementContext`.
1931        """
1932        raise NotImplementedError(
1933            "SkillCombination is an abstract class. Use one of its"
1934            " subclsases instead."
1935        )

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]:
1947    def walk(self) -> Generator[
1948        Union['SkillCombination', Skill, Level],
1949        None,
1950        None
1951    ]:
1952        """
1953        Yields this combination and each sub-part in depth-first
1954        traversal order.
1955        """
1956        raise NotImplementedError(
1957            "SkillCombination is an abstract class and cannot be walked."
1958        )

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

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

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

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

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

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

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

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

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

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

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

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

def effectiveLevel(self, ctx: RequirementContext) -> int:
2191    def effectiveLevel(self, ctx: 'RequirementContext') -> Level:
2192        """
2193        Determines the effective level of each sub-skill-combo and
2194        returns the sum of those, with 0 as a default.
2195        """
2196        result = 0
2197        level: Level
2198        if len(self.skills) == 0:
2199            raise RuntimeError(
2200                "Invalid CombinedSkill: has zero sub-skills."
2201            )
2202        for sk in self.skills:
2203            if isinstance(sk, Level):
2204                level = sk
2205            elif isinstance(sk, Skill):
2206                level = getSkillLevel(ctx.state, sk)
2207            elif isinstance(sk, SkillCombination):
2208                level = sk.effectiveLevel(ctx)
2209            else:
2210                raise RuntimeError(
2211                    f"Invalid CombinedSkill: found sub-skill '{repr(sk)}'"
2212                    f" which is not a skill name string, level integer,"
2213                    f" or SkillCombination."
2214                )
2215            result += level
2216
2217        assert result is not None
2218        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):
2220    def unparse(self):
2221        result = "sum("
2222        for sk in self.skills:
2223            if isinstance(sk, SkillCombination):
2224                result += sk.unparse()
2225            else:
2226                result += str(sk)
2227            result += ', '
2228        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):
2231class InverseSkill(SkillCombination):
2232    def __init__(
2233        self,
2234        invert: Union[SkillCombination, Skill, Level]
2235    ):
2236        """
2237        Represents the effective level of the given `SkillCombination`,
2238        the level of the given `Skill`, or just the provided specific
2239        `Level`, except inverted (multiplied by -1).
2240        """
2241        self.invert = invert
2242
2243    def __eq__(self, other: Any) -> bool:
2244        return (
2245            isinstance(other, InverseSkill)
2246        and other.invert == self.invert
2247        )
2248
2249    def __hash__(self) -> int:
2250        return 3193 + hash(self.invert)
2251
2252    def __repr__(self) -> str:
2253        return "InverseSkill(" + repr(self.invert) + ")"
2254
2255    def walk(self) -> Generator[
2256        Union[SkillCombination, Skill, Level],
2257        None,
2258        None
2259    ]:
2260        yield self
2261        if isinstance(self.invert, SkillCombination):
2262            yield from self.invert.walk()
2263        else:
2264            yield self.invert
2265
2266    def effectiveLevel(self, ctx: 'RequirementContext') -> Level:
2267        """
2268        Determines whether the requirement is satisfied or not and then
2269        returns the effective level of either the `ifSatisfied` or
2270        `ifNot` skill combination, as appropriate.
2271        """
2272        if isinstance(self.invert, Level):
2273            return -self.invert
2274        elif isinstance(self.invert, Skill):
2275            return -getSkillLevel(ctx.state, self.invert)
2276        elif isinstance(self.invert, SkillCombination):
2277            return -self.invert.effectiveLevel(ctx)
2278        else:
2279            raise RuntimeError(
2280                f"Invalid InverseSkill: invert value {repr(self.invert)}"
2281                f" The invert value must be a Level (int), a Skill"
2282                f" (str), or a SkillCombination."
2283            )
2284
2285    def unparse(self):
2286        # TODO: Move these to `parsing` to avoid hard-coded tokens here?
2287        if isinstance(self.invert, SkillCombination):
2288            return '~' + self.invert.unparse()
2289        else:
2290            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])
2232    def __init__(
2233        self,
2234        invert: Union[SkillCombination, Skill, Level]
2235    ):
2236        """
2237        Represents the effective level of the given `SkillCombination`,
2238        the level of the given `Skill`, or just the provided specific
2239        `Level`, except inverted (multiplied by -1).
2240        """
2241        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]:
2255    def walk(self) -> Generator[
2256        Union[SkillCombination, Skill, Level],
2257        None,
2258        None
2259    ]:
2260        yield self
2261        if isinstance(self.invert, SkillCombination):
2262            yield from self.invert.walk()
2263        else:
2264            yield self.invert

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

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

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

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

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

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):
2377    def unparse(self):
2378        result = f"if({self.requirement.unparse()}, "
2379        if isinstance(self.ifSatisfied, SkillCombination):
2380            result += self.ifSatisfied.unparse()
2381        else:
2382            result += str(self.ifSatisfied)
2383        result += ', '
2384        if isinstance(self.ifNot, SkillCombination):
2385            result += self.ifNot.unparse()
2386        else:
2387            result += str(self.ifNot)
2388        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):
2391class Challenge(TypedDict):
2392    """
2393    Represents a random binary decision between two possible outcomes,
2394    only one of which will actually occur. The 'outcome' can be set to
2395    `True` or `False` to represent that the outcome of the challenge has
2396    been observed, or to `None` (the default) to represent a pending
2397    challenge. The chance of 'success' is determined by the associated
2398    skill(s) and the challenge level, although one or both may be
2399    unknown in which case a variable is used in place of a concrete
2400    value. Probabilities that are of the form 1/2**n or (2**n - 1) /
2401    (2**n) can be represented, the specific formula for the chance of
2402    success is for a challenge with a single skill is:
2403
2404        s = interacting entity's skill level in associated skill
2405        c = challenge level
2406        P(success) = {
2407          1 - 1/2**(1 + s - c)    if s > c
2408          1/2                     if s == c
2409          1/2**(1 + c - s)        if c > s
2410        }
2411
2412    This probability formula is equivalent to the following procedure:
2413
2414    1. Flip one coin, plus one additional coin for each level difference
2415        between the skill and challenge levels.
2416    2. If the skill level is equal to or higher than the challenge
2417        level, the outcome is success if any single coin comes up heads.
2418    3. If the skill level is less than the challenge level, then the
2419        outcome is success only if *all* coins come up heads.
2420    4. If the outcome is not success, it is failure.
2421
2422    Multiple skills can be combined into a `SkillCombination`, which can
2423    use the max or min of several skills, add skill levels together,
2424    and/or have skills which are only relevant when a certain
2425    `Requirement` is satisfied. If a challenge has no skills associated
2426    with it, then the player's skill level counts as 0.
2427
2428    The slots are:
2429
2430    - 'skills': A `SkillCombination` that specifies the relevant
2431        skill(s).
2432    - 'level': An integer specifying the level of the challenge. Along
2433        with the appropriate skill level of the interacting entity, this
2434        determines the probability of success or failure.
2435    - 'success': A `Consequence` which will happen when the outcome is
2436        success. Note that since a `Consequence` can be a `Challenge`,
2437        multi-outcome challenges can be represented by chaining multiple
2438        challenges together.
2439    - 'failure': A `Consequence` which will happen when the outcome is
2440        failure.
2441    - 'outcome': The outcome of the challenge: `True` means success,
2442        `False` means failure, and `None` means "not known (yet)."
2443    """
2444    skills: SkillCombination
2445    level: Level
2446    success: 'Consequence'
2447    failure: 'Consequence'
2448    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):
2451def challenge(
2452    skills: Optional[SkillCombination] = None,
2453    level: Level = 0,
2454    success: Optional['Consequence'] = None,
2455    failure: Optional['Consequence'] = None,
2456    outcome: Optional[bool] = None
2457):
2458    """
2459    Factory for `Challenge`s, defaults to empty effects for both success
2460    and failure outcomes, so that you can just provide one or the other
2461    if you need to. Skills defaults to an empty list, the level defaults
2462    to 0 and the outcome defaults to `None` which means "not (yet)
2463    known."
2464    """
2465    if skills is None:
2466        skills = BestSkill(0)
2467    if success is None:
2468        success = []
2469    if failure is None:
2470        failure = []
2471    return {
2472        'skills': skills,
2473        'level': level,
2474        'success': success,
2475        'failure': failure,
2476        'outcome': outcome
2477    }

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):
2480class Condition(TypedDict):
2481    """
2482    Represents a condition over `Capability`, `Token`, and/or `Mechanism`
2483    states which applies to one or more `Effect`s or `Challenge`s as part
2484    of a `Consequence`. If the specified `Requirement` is satisfied, the
2485    included `Consequence` is treated as if it were part of the
2486    `Consequence` that the `Condition` is inside of, if the requirement
2487    is not satisfied, then the internal `Consequence` is skipped and the
2488    alternate consequence is used instead. Either sub-consequence may of
2489    course be an empty list.
2490    """
2491    condition: 'Requirement'
2492    consequence: 'Consequence'
2493    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):
2496def condition(
2497    condition: 'Requirement',
2498    consequence: 'Consequence',
2499    alternative: Optional['Consequence'] = None
2500):
2501    """
2502    Factory for conditions that just glues the given requirement,
2503    consequence, and alternative together. The alternative defaults to
2504    an empty list if not specified.
2505    """
2506    if alternative is None:
2507        alternative = []
2508    return {
2509        'condition': condition,
2510        'consequence': consequence,
2511        'alternative': alternative
2512    }

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

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]]:
2635def observeChallengeOutcomes(
2636    context: RequirementContext,
2637    consequence: Consequence,
2638    location: Optional[Set[DecisionID]] = None,
2639    policy: ChallengePolicy = 'random',
2640    knownOutcomes: Optional[List[bool]] = None,
2641    makeCopy: bool = False
2642) -> Consequence:
2643    """
2644    Given a `RequirementContext` (for `Capability`, `Token`, and `Skill`
2645    info as well as equivalences in the `DecisionGraph` and a
2646    search-from location for mechanism names) and a `Conseqeunce` to be
2647    observed, sets the 'outcome' value for each `Challenge` in it to
2648    either `True` or `False` by determining an outcome for each
2649    `Challenge` that's relevant (challenges locked behind unsatisfied
2650    `Condition`s or on untaken branches of other challenges are not
2651    given outcomes). `Challenge`s that already have assigned outcomes
2652    re-use those outcomes, call `resetChallengeOutcomes` beforehand if
2653    you want to re-decide each challenge with a new policy, and use the
2654    'specified' policy if you want to ensure only pre-specified outcomes
2655    are used.
2656
2657    Normally, the return value is just the original `consequence`
2658    object. However, if `makeCopy` is set to `True`, a deep copy is made
2659    and returned, so the original is not modified. One potential problem
2660    with this is that effects will be copied in this process, which
2661    means that if they are applied, things like delays and toggles won't
2662    update properly. `makeCopy` should thus normally not be used.
2663
2664    The 'policy' value can be one of the `ChallengePolicy` values. The
2665    default is 'random', in which case the `random.random` function is
2666    used to determine each outcome, based on the probability derived
2667    from the challenge level and the associated skill level. The other
2668    policies are:
2669
2670    - 'mostLikely': the result of each challenge will be whichever
2671        outcome is more likely, with success always happening instead of
2672        failure when the probabilities are 50/50.
2673    - 'fewestEffects`: whichever combination of outcomes leads to the
2674        fewest total number of effects will be chosen (modulo satisfying
2675        requirements of `Condition`s). Note that there's no estimation
2676        of the severity of effects, just the raw number. Ties in terms
2677        of number of effects are broken towards successes. This policy
2678        involves evaluating all possible outcome combinations to figure
2679        out which one has the fewest effects.
2680    - 'success' or 'failure': all outcomes will either succeed, or
2681        fail, as specified. Note that success/failure may cut off some
2682        challenges, so it's not the case that literally every challenge
2683        will succeed/fail; some may be skipped because of the
2684        specified success/failure of a prior challenge.
2685    - 'specified': all outcomes have already been specified, and those
2686        pre-specified outcomes should be used as-is.
2687
2688
2689    In call cases, outcomes specified via `knownOutcomes` take precedence
2690    over the challenge policy. The `knownOutcomes` list will be emptied
2691    out as this function works, but extra consequences beyond what's
2692    needed will be ignored (and left in the list).
2693
2694    Note that there are limits on the resolution of Python's random
2695    number generation; for challenges with extremely high or low levels
2696    relative to the associated skill(s) where the probability of success
2697    is very close to 1 or 0, there may not actually be any chance of
2698    success/failure at all. Typically you can ignore this, because such
2699    cases should not normally come up in practice, and because the odds
2700    of success/failure in those cases are such that to notice the
2701    missing possibility share you'd have to simulate outcomes a
2702    ridiculous number of times.
2703
2704    TODO: Location examples; move some of these to a separate testing
2705    file.
2706
2707    For example:
2708
2709    >>> random.seed(17)
2710    >>> warnings.filterwarnings('error')
2711    >>> from . import core
2712    >>> e = core.emptySituation()
2713    >>> c = challenge(
2714    ...     success=[effect(gain=('money', 12))],
2715    ...     failure=[effect(lose=('money', 10))]
2716    ... )  # skill defaults to 'luck', level to 0, and outcome to None
2717    >>> c['outcome'] is None  # default outcome is None
2718    True
2719    >>> r = observeChallengeOutcomes(e, [c])
2720    >>> r[0]['outcome']
2721    False
2722    >>> c['outcome']  # original outcome is changed from None
2723    False
2724    >>> all(
2725    ...     observeChallengeOutcomes(e, [c])[0]['outcome'] is False
2726    ...     for i in range(20)
2727    ... )  # no reset -> same outcome
2728    True
2729    >>> resetChallengeOutcomes([c])
2730    >>> observeChallengeOutcomes(e, [c])[0]['outcome']  # Random after reset
2731    False
2732    >>> resetChallengeOutcomes([c])
2733    >>> observeChallengeOutcomes(e, [c])[0]['outcome']  # Random after reset
2734    False
2735    >>> resetChallengeOutcomes([c])
2736    >>> observeChallengeOutcomes(e, [c])[0]['outcome'] # Random after reset
2737    True
2738    >>> observeChallengeOutcomes(e, c)  # Can't resolve just a Challenge
2739    Traceback (most recent call last):
2740    ...
2741    TypeError...
2742    >>> allSame = []
2743    >>> for i in range(20):
2744    ...    resetChallengeOutcomes([c])
2745    ...    obs = observeChallengeOutcomes(e, [c, c])
2746    ...    allSame.append(obs[0]['outcome'] == obs[1]['outcome'])
2747    >>> allSame == [True]*20
2748    True
2749    >>> different = []
2750    >>> for i in range(20):
2751    ...    resetChallengeOutcomes([c])
2752    ...    obs = observeChallengeOutcomes(e, [c, copy.deepcopy(c)])
2753    ...    different.append(obs[0]['outcome'] == obs[1]['outcome'])
2754    >>> False in different
2755    True
2756    >>> all(  # Tie breaks towards success
2757    ...     (
2758    ...         resetChallengeOutcomes([c]),
2759    ...         observeChallengeOutcomes(e, [c], policy='mostLikely')
2760    ...     )[1][0]['outcome'] is True
2761    ...     for i in range(20)
2762    ... )
2763    True
2764    >>> all(  # Tie breaks towards success
2765    ...     (
2766    ...         resetChallengeOutcomes([c]),
2767    ...         observeChallengeOutcomes(e, [c], policy='fewestEffects')
2768    ...     )[1][0]['outcome'] is True
2769    ...     for i in range(20)
2770    ... )
2771    True
2772    >>> all(
2773    ...     (
2774    ...         resetChallengeOutcomes([c]),
2775    ...         observeChallengeOutcomes(e, [c], policy='success')
2776    ...     )[1][0]['outcome'] is True
2777    ...     for i in range(20)
2778    ... )
2779    True
2780    >>> all(
2781    ...     (
2782    ...         resetChallengeOutcomes([c]),
2783    ...         observeChallengeOutcomes(e, [c], policy='failure')
2784    ...     )[1][0]['outcome'] is False
2785    ...     for i in range(20)
2786    ... )
2787    True
2788    >>> c['outcome'] = False  # Fix the outcome; now policy is ignored
2789    >>> observeChallengeOutcomes(e, [c], policy='success')[0]['outcome']
2790    False
2791    >>> c = challenge(
2792    ...     skills=BestSkill('charisma'),
2793    ...     level=8,
2794    ...     success=[
2795    ...         challenge(
2796    ...             skills=BestSkill('strength'),
2797    ...             success=[effect(gain='winner')]
2798    ...         )
2799    ...     ],  # level defaults to 0
2800    ...     failure=[
2801    ...         challenge(
2802    ...             skills=BestSkill('strength'),
2803    ...             failure=[effect(gain='loser')]
2804    ...         ),
2805    ...         effect(gain='sad')
2806    ...     ]
2807    ... )
2808    >>> r = observeChallengeOutcomes(e, [c])  # random
2809    >>> r[0]['outcome']
2810    False
2811    >>> r[0]['failure'][0]['outcome']  # also random
2812    True
2813    >>> r[0]['success'][0]['outcome'] is None  # skipped so not assigned
2814    True
2815    >>> resetChallengeOutcomes([c])
2816    >>> r2 = observeChallengeOutcomes(e, [c])  # random
2817    >>> r[0]['outcome']
2818    False
2819    >>> r[0]['success'][0]['outcome'] is None  # untaken branch no outcome
2820    True
2821    >>> r[0]['failure'][0]['outcome']  # also random
2822    False
2823    >>> def outcomeList(consequence):
2824    ...     'Lists outcomes from each challenge attempted.'
2825    ...     result = []
2826    ...     for item in consequence:
2827    ...         if 'skills' in item:
2828    ...             result.append(item['outcome'])
2829    ...             if item['outcome'] is True:
2830    ...                 result.extend(outcomeList(item['success']))
2831    ...             elif item['outcome'] is False:
2832    ...                 result.extend(outcomeList(item['failure']))
2833    ...             else:
2834    ...                 pass  # end here
2835    ...     return result
2836    >>> def skilled(**skills):
2837    ...     'Create a clone of our Situation with specific skills.'
2838    ...     r = copy.deepcopy(e)
2839    ...     r.state['common']['capabilities']['skills'].update(skills)
2840    ...     return r
2841    >>> resetChallengeOutcomes([c])
2842    >>> r = observeChallengeOutcomes(  # 'mostLikely' policy
2843    ...     skilled(charisma=9, strength=1),
2844    ...     [c],
2845    ...     policy='mostLikely'
2846    ... )
2847    >>> outcomeList(r)
2848    [True, True]
2849    >>> resetChallengeOutcomes([c])
2850    >>> outcomeList(observeChallengeOutcomes(
2851    ...     skilled(charisma=7, strength=-1),
2852    ...     [c],
2853    ...     policy='mostLikely'
2854    ... ))
2855    [False, False]
2856    >>> resetChallengeOutcomes([c])
2857    >>> outcomeList(observeChallengeOutcomes(
2858    ...     skilled(charisma=8, strength=-1),
2859    ...     [c],
2860    ...     policy='mostLikely'
2861    ... ))
2862    [True, False]
2863    >>> resetChallengeOutcomes([c])
2864    >>> outcomeList(observeChallengeOutcomes(
2865    ...     skilled(charisma=7, strength=0),
2866    ...     [c],
2867    ...     policy='mostLikely'
2868    ... ))
2869    [False, True]
2870    >>> resetChallengeOutcomes([c])
2871    >>> outcomeList(observeChallengeOutcomes(
2872    ...     skilled(charisma=20, strength=10),
2873    ...     [c],
2874    ...     policy='mostLikely'
2875    ... ))
2876    [True, True]
2877    >>> resetChallengeOutcomes([c])
2878    >>> outcomeList(observeChallengeOutcomes(
2879    ...     skilled(charisma=-10, strength=-10),
2880    ...     [c],
2881    ...     policy='mostLikely'
2882    ... ))
2883    [False, False]
2884    >>> resetChallengeOutcomes([c])
2885    >>> outcomeList(observeChallengeOutcomes(
2886    ...     e,
2887    ...     [c],
2888    ...     policy='fewestEffects'
2889    ... ))
2890    [True, False]
2891    >>> resetChallengeOutcomes([c])
2892    >>> outcomeList(observeChallengeOutcomes(
2893    ...     skilled(charisma=-100, strength=100),
2894    ...     [c],
2895    ...     policy='fewestEffects'
2896    ... ))  # unaffected by stats
2897    [True, False]
2898    >>> resetChallengeOutcomes([c])
2899    >>> outcomeList(observeChallengeOutcomes(e, [c], policy='success'))
2900    [True, True]
2901    >>> resetChallengeOutcomes([c])
2902    >>> outcomeList(observeChallengeOutcomes(e, [c], policy='failure'))
2903    [False, False]
2904    >>> cc = copy.deepcopy(c)
2905    >>> resetChallengeOutcomes([cc])
2906    >>> cc['outcome'] = False
2907    >>> outcomeList(observeChallengeOutcomes(
2908    ...     skilled(charisma=10, strength=10),
2909    ...     [cc],
2910    ...     policy='mostLikely'
2911    ... ))  # pre-observed outcome won't be changed
2912    [False, True]
2913    >>> resetChallengeOutcomes([cc])
2914    >>> cc['outcome'] = False
2915    >>> outcomeList(observeChallengeOutcomes(
2916    ...     e,
2917    ...     [cc],
2918    ...     policy='fewestEffects'
2919    ... ))  # pre-observed outcome won't be changed
2920    [False, True]
2921    >>> cc['success'][0]['outcome'] is None  # not assigned on other branch
2922    True
2923    >>> resetChallengeOutcomes([cc])
2924    >>> r = observeChallengeOutcomes(e, [cc], policy='fewestEffects')
2925    >>> r[0] is cc  # results are aliases, not clones
2926    True
2927    >>> outcomeList(r)
2928    [True, False]
2929    >>> cc['success'][0]['outcome']  # inner outcome now assigned
2930    False
2931    >>> cc['failure'][0]['outcome'] is None  # now this is other branch
2932    True
2933    >>> resetChallengeOutcomes([cc])
2934    >>> r = observeChallengeOutcomes(
2935    ...     e,
2936    ...     [cc],
2937    ...     policy='fewestEffects',
2938    ...     makeCopy=True
2939    ... )
2940    >>> r[0] is cc  # now result is a clone
2941    False
2942    >>> outcomeList(r)
2943    [True, False]
2944    >>> observedEffects(genericContextForSituation(e), r)
2945    []
2946    >>> r[0]['outcome']  # outcome was assigned
2947    True
2948    >>> cc['outcome'] is None  # only to the copy, not to the original
2949    True
2950    >>> cn = [
2951    ...     condition(
2952    ...         ReqCapability('boost'),
2953    ...         [
2954    ...             challenge(success=[effect(gain=('$', 1))]),
2955    ...             effect(gain=('$', 2))
2956    ...         ]
2957    ...     ),
2958    ...     challenge(failure=[effect(gain=('$', 4))])
2959    ... ]
2960    >>> o = observeChallengeOutcomes(e, cn, policy='fewestEffects')
2961    >>> # Without 'boost', inner challenge does not get an outcome
2962    >>> o[0]['consequence'][0]['outcome'] is None
2963    True
2964    >>> o[1]['outcome']  # avoids effect
2965    True
2966    >>> hasBoost = copy.deepcopy(e)
2967    >>> hasBoost.state['common']['capabilities']['capabilities'].add('boost')
2968    >>> resetChallengeOutcomes(cn)
2969    >>> o = observeChallengeOutcomes(hasBoost, cn, policy='fewestEffects')
2970    >>> o[0]['consequence'][0]['outcome']  # now assigned an outcome
2971    False
2972    >>> o[1]['outcome']  # avoids effect
2973    True
2974    >>> from . import core
2975    >>> e = core.emptySituation()
2976    >>> c = challenge(
2977    ...     skills=BestSkill('skill'),
2978    ...     level=4,  # very unlikely at level 0
2979    ...     success=[],
2980    ...     failure=[effect(lose=('money', 10))],
2981    ...     outcome=True
2982    ... )  # pre-assigned outcome
2983    >>> c['outcome']  # verify
2984    True
2985    >>> r = observeChallengeOutcomes(e, [c], policy='specified')
2986    >>> r[0]['outcome']
2987    True
2988    >>> c['outcome']  # original outcome is unchanged
2989    True
2990    >>> c['outcome'] = False  # the more likely outcome
2991    >>> r = observeChallengeOutcomes(e, [c], policy='specified')
2992    >>> r[0]['outcome']  # re-uses the new outcome
2993    False
2994    >>> c['outcome']  # outcome is unchanged
2995    False
2996    >>> c['outcome'] = True  # change it back
2997    >>> r = observeChallengeOutcomes(e, [c], policy='specified')
2998    >>> r[0]['outcome']  # re-use the outcome again
2999    True
3000    >>> c['outcome']  # outcome is unchanged
3001    True
3002    >>> c['outcome'] = None  # set it to no info; will crash
3003    >>> r = observeChallengeOutcomes(e, [c], policy='specified')
3004    Traceback (most recent call last):
3005    ...
3006    ValueError...
3007    >>> warnings.filterwarnings('default')
3008    >>> c['outcome'] is None  # same after crash
3009    True
3010    >>> r = observeChallengeOutcomes(
3011    ...     e,
3012    ...     [c],
3013    ...     policy='specified',
3014    ...     knownOutcomes=[True]
3015    ... )
3016    >>> r[0]['outcome']  # picked up known outcome
3017    True
3018    >>> c['outcome']  # outcome is changed
3019    True
3020    >>> resetChallengeOutcomes([c])
3021    >>> c['outcome'] is None  # has been reset
3022    True
3023    >>> r = observeChallengeOutcomes(
3024    ...     e,
3025    ...     [c],
3026    ...     policy='specified',
3027    ...     knownOutcomes=[True]
3028    ... )
3029    >>> c['outcome']  # from known outcomes
3030    True
3031    >>> ko = [False]
3032    >>> r = observeChallengeOutcomes(
3033    ...     e,
3034    ...     [c],
3035    ...     policy='specified',
3036    ...     knownOutcomes=ko
3037    ... )
3038    >>> c['outcome']  # from known outcomes
3039    False
3040    >>> ko  # known outcomes list gets used up
3041    []
3042    >>> ko = [False, False]
3043    >>> r = observeChallengeOutcomes(
3044    ...     e,
3045    ...     [c],
3046    ...     policy='specified',
3047    ...     knownOutcomes=ko
3048    ... )  # too many outcomes is an error
3049    >>> ko
3050    [False]
3051    """
3052    if not isinstance(consequence, list):
3053        raise TypeError(
3054            f"Invalid consequence: must be a list."
3055            f"\nGot: {repr(consequence)}"
3056        )
3057
3058    if knownOutcomes is None:
3059        knownOutcomes = []
3060
3061    if makeCopy:
3062        result = copy.deepcopy(consequence)
3063    else:
3064        result = consequence
3065
3066    for item in result:
3067        if not isinstance(item, dict):
3068            raise TypeError(
3069                f"Invalid consequence: items in the list must be"
3070                f" Effects, Challenges, or Conditions."
3071                f"\nGot item: {repr(item)}"
3072            )
3073        if 'skills' in item:  # must be a Challenge
3074            item = cast(Challenge, item)
3075            if len(knownOutcomes) > 0:
3076                item['outcome'] = knownOutcomes.pop(0)
3077            if item['outcome'] is not None:
3078                if item['outcome']:
3079                    observeChallengeOutcomes(
3080                        context,
3081                        item['success'],
3082                        location=location,
3083                        policy=policy,
3084                        knownOutcomes=knownOutcomes,
3085                        makeCopy=False
3086                    )
3087                else:
3088                    observeChallengeOutcomes(
3089                        context,
3090                        item['failure'],
3091                        location=location,
3092                        policy=policy,
3093                        knownOutcomes=knownOutcomes,
3094                        makeCopy=False
3095                    )
3096            else:  # need to assign an outcome
3097                if policy == 'specified':
3098                    raise ValueError(
3099                        f"Challenge has unspecified outcome so the"
3100                        f" 'specified' policy cannot be used when"
3101                        f" observing its outcomes:"
3102                        f"\n{item}"
3103                    )
3104                level = item['skills'].effectiveLevel(context)
3105                against = item['level']
3106                if level < against:
3107                    p = 1 / (2 ** (1 + against - level))
3108                else:
3109                    p = 1 - (1 / (2 ** (1 + level - against)))
3110                if policy == 'random':
3111                    if random.random() < p:  # success
3112                        item['outcome'] = True
3113                    else:
3114                        item['outcome'] = False
3115                elif policy == 'mostLikely':
3116                    if p >= 0.5:
3117                        item['outcome'] = True
3118                    else:
3119                        item['outcome'] = False
3120                elif policy == 'fewestEffects':
3121                    # Resolve copies so we don't affect original
3122                    subSuccess = observeChallengeOutcomes(
3123                        context,
3124                        item['success'],
3125                        location=location,
3126                        policy=policy,
3127                        knownOutcomes=knownOutcomes[:],
3128                        makeCopy=True
3129                    )
3130                    subFailure = observeChallengeOutcomes(
3131                        context,
3132                        item['failure'],
3133                        location=location,
3134                        policy=policy,
3135                        knownOutcomes=knownOutcomes[:],
3136                        makeCopy=True
3137                    )
3138                    if (
3139                        len(observedEffects(context, subSuccess))
3140                     <= len(observedEffects(context, subFailure))
3141                    ):
3142                        item['outcome'] = True
3143                    else:
3144                        item['outcome'] = False
3145                elif policy == 'success':
3146                    item['outcome'] = True
3147                elif policy == 'failure':
3148                    item['outcome'] = False
3149
3150                # Figure out outcomes for sub-consequence if we don't
3151                # already have them...
3152                if item['outcome'] not in (True, False):
3153                    raise TypeError(
3154                        f"Challenge has invalid outcome type"
3155                        f" {type(item['outcome'])} after observation."
3156                        f"\nOutcome value: {repr(item['outcome'])}"
3157                    )
3158
3159                if item['outcome']:
3160                    observeChallengeOutcomes(
3161                        context,
3162                        item['success'],
3163                        location=location,
3164                        policy=policy,
3165                        knownOutcomes=knownOutcomes,
3166                        makeCopy=False
3167                    )
3168                else:
3169                    observeChallengeOutcomes(
3170                        context,
3171                        item['failure'],
3172                        location=location,
3173                        policy=policy,
3174                        knownOutcomes=knownOutcomes,
3175                        makeCopy=False
3176                    )
3177
3178        elif 'value' in item:
3179            continue  # Effects do not need success/failure assigned
3180
3181        elif 'condition' in item:  # a Condition
3182            if item['condition'].satisfied(context):
3183                observeChallengeOutcomes(
3184                    context,
3185                    item['consequence'],
3186                    location=location,
3187                    policy=policy,
3188                    knownOutcomes=knownOutcomes,
3189                    makeCopy=False
3190                )
3191            else:
3192                observeChallengeOutcomes(
3193                    context,
3194                    item['alternative'],
3195                    location=location,
3196                    policy=policy,
3197                    knownOutcomes=knownOutcomes,
3198                    makeCopy=False
3199                )
3200
3201        else:  # bad dict
3202            raise TypeError(
3203                f"Invalid consequence: items in the list must be"
3204                f" Effects, Challenges, or Conditions (got a dictionary"
3205                f" without 'skills', 'value', or 'condition' keys)."
3206                f"\nGot item: {repr(item)}"
3207            )
3208
3209    # Return copy or original, now with options selected
3210    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):
3213class UnassignedOutcomeWarning(Warning):
3214    """
3215    A warning issued when asking for observed effects of a `Consequence`
3216    whose `Challenge` outcomes have not been fully assigned.
3217    """
3218    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]:
3221def observedEffects(
3222    context: RequirementContext,
3223    observed: Consequence,
3224    skipWarning=False,
3225    baseIndex: int = 0
3226) -> List[int]:
3227    """
3228    Given a `Situation` and a `Consequence` whose challenges have
3229    outcomes assigned, returns a tuple containing a list of the
3230    depth-first-indices of each effect to apply. You can use
3231    `consequencePart` to extract the actual `Effect` values from the
3232    consequence based on their indices.
3233
3234    Only effects that actually apply are included, based on the observed
3235    outcomes as well as which `Condition`(s) are met, although charges
3236    and delays for the effects are not taken into account.
3237
3238    `baseIndex` can be set to something other than 0 to start indexing
3239    at that value. Issues an `UnassignedOutcomeWarning` if it encounters
3240    a challenge whose outcome has not been observed, unless
3241    `skipWarning` is set to `True`. In that case, no effects are listed
3242    for outcomes of that challenge.
3243
3244    For example:
3245
3246    >>> from . import core
3247    >>> warnings.filterwarnings('error')
3248    >>> e = core.emptySituation()
3249    >>> def skilled(**skills):
3250    ...     'Create a clone of our FocalContext with specific skills.'
3251    ...     r = copy.deepcopy(e)
3252    ...     r.state['common']['capabilities']['skills'].update(skills)
3253    ...     return r
3254    >>> c = challenge(  # index 1 in [c] (index 0 is the outer list)
3255    ...     skills=BestSkill('charisma'),
3256    ...     level=8,
3257    ...     success=[
3258    ...         effect(gain='happy'),  # index 3 in [c]
3259    ...         challenge(
3260    ...             skills=BestSkill('strength'),
3261    ...             success=[effect(gain='winner')]  # index 6 in [c]
3262    ...             # failure is index 7
3263    ...         )  # level defaults to 0
3264    ...     ],
3265    ...     failure=[
3266    ...         challenge(
3267    ...             skills=BestSkill('strength'),
3268    ...             # success is index 10
3269    ...             failure=[effect(gain='loser')]  # index 12 in [c]
3270    ...         ),
3271    ...         effect(gain='sad')  # index 13 in [c]
3272    ...     ]
3273    ... )
3274    >>> import pytest
3275    >>> with pytest.warns(UnassignedOutcomeWarning):
3276    ...     observedEffects(e, [c])
3277    []
3278    >>> with pytest.warns(UnassignedOutcomeWarning):
3279    ...     observedEffects(e, [c, c])
3280    []
3281    >>> observedEffects(e, [c, c], skipWarning=True)
3282    []
3283    >>> c['outcome'] = 'invalid value'  # must be True, False, or None
3284    >>> observedEffects(e, [c])
3285    Traceback (most recent call last):
3286    ...
3287    TypeError...
3288    >>> yesYes = skilled(charisma=10, strength=5)
3289    >>> yesNo = skilled(charisma=10, strength=-1)
3290    >>> noYes = skilled(charisma=4, strength=5)
3291    >>> noNo = skilled(charisma=4, strength=-1)
3292    >>> resetChallengeOutcomes([c])
3293    >>> observedEffects(
3294    ...     yesYes,
3295    ...     observeChallengeOutcomes(yesYes, [c], policy='mostLikely')
3296    ... )
3297    [3, 6]
3298    >>> resetChallengeOutcomes([c])
3299    >>> observedEffects(
3300    ...     yesNo,
3301    ...     observeChallengeOutcomes(yesNo, [c], policy='mostLikely')
3302    ... )
3303    [3]
3304    >>> resetChallengeOutcomes([c])
3305    >>> observedEffects(
3306    ...     noYes,
3307    ...     observeChallengeOutcomes(noYes, [c], policy='mostLikely')
3308    ... )
3309    [13]
3310    >>> resetChallengeOutcomes([c])
3311    >>> observedEffects(
3312    ...     noNo,
3313    ...     observeChallengeOutcomes(noNo, [c], policy='mostLikely')
3314    ... )
3315    [12, 13]
3316    >>> warnings.filterwarnings('default')
3317    >>> # known outcomes override policy & pre-specified outcomes
3318    >>> observedEffects(
3319    ...     noNo,
3320    ...     observeChallengeOutcomes(
3321    ...         noNo,
3322    ...         [c],
3323    ...         policy='mostLikely',
3324    ...         knownOutcomes=[True, True])
3325    ... )
3326    [3, 6]
3327    >>> observedEffects(
3328    ...     yesYes,
3329    ...     observeChallengeOutcomes(
3330    ...         yesYes,
3331    ...         [c],
3332    ...         policy='mostLikely',
3333    ...         knownOutcomes=[False, False])
3334    ... )
3335    [12, 13]
3336    >>> resetChallengeOutcomes([c])
3337    >>> observedEffects(
3338    ...     yesYes,
3339    ...     observeChallengeOutcomes(
3340    ...         yesYes,
3341    ...         [c],
3342    ...         policy='mostLikely',
3343    ...         knownOutcomes=[False, False])
3344    ... )
3345    [12, 13]
3346    """
3347    result: List[int] = []
3348    totalCount: int = baseIndex + 1  # +1 for the outer list
3349    if not isinstance(observed, list):
3350        raise TypeError(
3351            f"Invalid consequence: must be a list."
3352            f"\nGot: {repr(observed)}"
3353        )
3354    for item in observed:
3355        if not isinstance(item, dict):
3356            raise TypeError(
3357                f"Invalid consequence: items in the list must be"
3358                f" Effects, Challenges, or Conditions."
3359                f"\nGot item: {repr(item)}"
3360            )
3361
3362        if 'skills' in item:  # must be a Challenge
3363            item = cast(Challenge, item)
3364            succeeded = item['outcome']
3365            useCh: Optional[Literal['success', 'failure']]
3366            if succeeded is True:
3367                useCh = 'success'
3368            elif succeeded is False:
3369                useCh = 'failure'
3370            else:
3371                useCh = None
3372                level = item["level"]
3373                if succeeded is not None:
3374                    raise TypeError(
3375                        f"Invalid outcome for level-{level} challenge:"
3376                        f" should be True, False, or None, but got:"
3377                        f" {repr(succeeded)}"
3378                    )
3379                else:
3380                    if not skipWarning:
3381                        warnings.warn(
3382                            (
3383                                f"A level-{level} challenge in the"
3384                                f" consequence being observed has no"
3385                                f" observed outcome; no effects from"
3386                                f" either success or failure branches"
3387                                f" will be included. Use"
3388                                f" observeChallengeOutcomes to fill in"
3389                                f" unobserved outcomes."
3390                            ),
3391                            UnassignedOutcomeWarning
3392                        )
3393
3394            if useCh is not None:
3395                skipped = 0
3396                if useCh == 'failure':
3397                    skipped = countParts(item['success'])
3398                subEffects = observedEffects(
3399                    context,
3400                    item[useCh],
3401                    skipWarning=skipWarning,
3402                    baseIndex=totalCount + skipped + 1
3403                )
3404                result.extend(subEffects)
3405
3406            # TODO: Go back to returning tuples but fix counts to include
3407            # skipped stuff; this is horribly inefficient :(
3408            totalCount += countParts(item)
3409
3410        elif 'value' in item:  # an effect, not a challenge
3411            item = cast(Effect, item)
3412            result.append(totalCount)
3413            totalCount += 1
3414
3415        elif 'condition' in item:  # a Condition
3416            item = cast(Condition, item)
3417            useCo: Literal['consequence', 'alternative']
3418            if item['condition'].satisfied(context):
3419                useCo = 'consequence'
3420                skipped = 0
3421            else:
3422                useCo = 'alternative'
3423                skipped = countParts(item['consequence'])
3424            subEffects = observedEffects(
3425                context,
3426                item[useCo],
3427                skipWarning=skipWarning,
3428                baseIndex=totalCount + skipped + 1
3429            )
3430            result.extend(subEffects)
3431            totalCount += countParts(item)
3432
3433        else:  # bad dict
3434            raise TypeError(
3435                f"Invalid consequence: items in the list must be"
3436                f" Effects, Challenges, or Conditions (got a dictionary"
3437                f" without 'skills', 'value', or 'condition' keys)."
3438                f"\nGot item: {repr(item)}"
3439            )
3440
3441    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:
3456class Requirement:
3457    """
3458    Represents a precondition for traversing an edge or taking an action.
3459    This can be any boolean expression over `Capability`, mechanism (see
3460    `MechanismName`), and/or `Token` states that must obtain, with
3461    numerical values for the number of tokens required, and specific
3462    mechanism states or active capabilities necessary. For example, if
3463    the player needs either the wall-break capability or the wall-jump
3464    capability plus a balloon token, or for the switch mechanism to be
3465    on, you could represent that using:
3466
3467        ReqAny(
3468            ReqCapability('wall-break'),
3469            ReqAll(
3470                ReqCapability('wall-jump'),
3471                ReqTokens('balloon', 1)
3472            ),
3473            ReqMechanism('switch', 'on')
3474        )
3475
3476    The subclasses define concrete requirements.
3477
3478    Note that mechanism names are searched for using `lookupMechanism`,
3479    starting from the `DecisionID`s of the decisions on either end of
3480    the transition where a requirement is being checked. You may need to
3481    rename mechanisms to avoid a `MechanismCollisionError`if decisions
3482    on either end of a transition use the same mechanism name.
3483    """
3484    def satisfied(
3485        self,
3486        context: RequirementContext,
3487        dontRecurse: Optional[
3488            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
3489        ] = None
3490    ) -> bool:
3491        """
3492        This will return `True` if the requirement is satisfied in the
3493        given `RequirementContext`, resolving mechanisms from the
3494        context's set of decisions and graph, and respecting the
3495        context's equivalences. It returns `False` otherwise.
3496
3497        The `dontRecurse` set should be unspecified to start, and will
3498        be used to avoid infinite recursion in cases of circular
3499        equivalences (requirements are not considered satisfied by
3500        equivalence loops).
3501
3502        TODO: Examples
3503        """
3504        raise NotImplementedError(
3505            "Requirement is an abstract class and cannot be"
3506            " used directly."
3507        )
3508
3509    def __eq__(self, other: Any) -> bool:
3510        raise NotImplementedError(
3511            "Requirement is an abstract class and cannot be compared."
3512        )
3513
3514    def __hash__(self) -> int:
3515        raise NotImplementedError(
3516            "Requirement is an abstract class and cannot be hashed."
3517        )
3518
3519    def walk(self) -> Generator['Requirement', None, None]:
3520        """
3521        Yields every part of the requirement in depth-first traversal
3522        order.
3523        """
3524        raise NotImplementedError(
3525            "Requirement is an abstract class and cannot be walked."
3526        )
3527
3528    def asEffectList(self) -> List[Effect]:
3529        """
3530        Transforms this `Requirement` into a list of `Effect`
3531        objects that gain the `Capability`, set the `Token` amounts, and
3532        set the `Mechanism` states mentioned by the requirement. The
3533        requirement must be either a `ReqTokens`, a `ReqCapability`, a
3534        `ReqMechanism`, or a `ReqAll` which includes nothing besides
3535        those types as sub-requirements. The token and capability
3536        requirements at the leaves of the tree will be collected into a
3537        list for the result (note that whether `ReqAny` or `ReqAll` is
3538        used is ignored, all of the tokens/capabilities/mechanisms
3539        mentioned are listed). For each `Capability` requirement a
3540        'gain' effect for that capability will be included. For each
3541        `Mechanism` or `Token` requirement, a 'set' effect for that
3542        mechanism state or token count will be included. Note that if
3543        the requirement has contradictory clauses (e.g., two different
3544        mechanism states) multiple effects which cancel each other out
3545        will be included. Also note that setting token amounts may end
3546        up decreasing them unnecessarily.
3547
3548        Raises a `TypeError` if this requirement is not suitable for
3549        transformation into an effect list.
3550        """
3551        raise NotImplementedError("Requirement is an abstract class.")
3552
3553    def flatten(self) -> 'Requirement':
3554        """
3555        Returns a simplified version of this requirement that merges
3556        multiple redundant layers of `ReqAny`/`ReqAll` into single
3557        `ReqAny`/`ReqAll` structures, including recursively. May return
3558        the original requirement if there's no simplification to be done.
3559
3560        Default implementation just returns `self`.
3561        """
3562        return self
3563
3564    def unparse(self) -> str:
3565        """
3566        Returns a string which would convert back into this `Requirement`
3567        object if you fed it to `parsing.ParseFormat.parseRequirement`.
3568
3569        TODO: Move this over into `parsing`?
3570
3571        Examples:
3572
3573        >>> r = ReqAny([
3574        ...     ReqCapability('capability'),
3575        ...     ReqTokens('token', 3),
3576        ...     ReqMechanism('mechanism', 'state')
3577        ... ])
3578        >>> rep = r.unparse()
3579        >>> rep
3580        '(capability|token*3|mechanism:state)'
3581        >>> from . import parsing
3582        >>> pf = parsing.ParseFormat()
3583        >>> back = pf.parseRequirement(rep)
3584        >>> back == r
3585        True
3586        >>> ReqNot(ReqNothing()).unparse()
3587        '!(O)'
3588        >>> ReqImpossible().unparse()
3589        'X'
3590        >>> r = ReqAny([ReqCapability('A'), ReqCapability('B'),
3591        ...     ReqCapability('C')])
3592        >>> rep = r.unparse()
3593        >>> rep
3594        '(A|B|C)'
3595        >>> back = pf.parseRequirement(rep)
3596        >>> back == r
3597        True
3598        """
3599        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:
3484    def satisfied(
3485        self,
3486        context: RequirementContext,
3487        dontRecurse: Optional[
3488            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
3489        ] = None
3490    ) -> bool:
3491        """
3492        This will return `True` if the requirement is satisfied in the
3493        given `RequirementContext`, resolving mechanisms from the
3494        context's set of decisions and graph, and respecting the
3495        context's equivalences. It returns `False` otherwise.
3496
3497        The `dontRecurse` set should be unspecified to start, and will
3498        be used to avoid infinite recursion in cases of circular
3499        equivalences (requirements are not considered satisfied by
3500        equivalence loops).
3501
3502        TODO: Examples
3503        """
3504        raise NotImplementedError(
3505            "Requirement is an abstract class and cannot be"
3506            " used directly."
3507        )

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

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

def asEffectList(self) -> List[Effect]:
3528    def asEffectList(self) -> List[Effect]:
3529        """
3530        Transforms this `Requirement` into a list of `Effect`
3531        objects that gain the `Capability`, set the `Token` amounts, and
3532        set the `Mechanism` states mentioned by the requirement. The
3533        requirement must be either a `ReqTokens`, a `ReqCapability`, a
3534        `ReqMechanism`, or a `ReqAll` which includes nothing besides
3535        those types as sub-requirements. The token and capability
3536        requirements at the leaves of the tree will be collected into a
3537        list for the result (note that whether `ReqAny` or `ReqAll` is
3538        used is ignored, all of the tokens/capabilities/mechanisms
3539        mentioned are listed). For each `Capability` requirement a
3540        'gain' effect for that capability will be included. For each
3541        `Mechanism` or `Token` requirement, a 'set' effect for that
3542        mechanism state or token count will be included. Note that if
3543        the requirement has contradictory clauses (e.g., two different
3544        mechanism states) multiple effects which cancel each other out
3545        will be included. Also note that setting token amounts may end
3546        up decreasing them unnecessarily.
3547
3548        Raises a `TypeError` if this requirement is not suitable for
3549        transformation into an effect list.
3550        """
3551        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:
3553    def flatten(self) -> 'Requirement':
3554        """
3555        Returns a simplified version of this requirement that merges
3556        multiple redundant layers of `ReqAny`/`ReqAll` into single
3557        `ReqAny`/`ReqAll` structures, including recursively. May return
3558        the original requirement if there's no simplification to be done.
3559
3560        Default implementation just returns `self`.
3561        """
3562        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:
3564    def unparse(self) -> str:
3565        """
3566        Returns a string which would convert back into this `Requirement`
3567        object if you fed it to `parsing.ParseFormat.parseRequirement`.
3568
3569        TODO: Move this over into `parsing`?
3570
3571        Examples:
3572
3573        >>> r = ReqAny([
3574        ...     ReqCapability('capability'),
3575        ...     ReqTokens('token', 3),
3576        ...     ReqMechanism('mechanism', 'state')
3577        ... ])
3578        >>> rep = r.unparse()
3579        >>> rep
3580        '(capability|token*3|mechanism:state)'
3581        >>> from . import parsing
3582        >>> pf = parsing.ParseFormat()
3583        >>> back = pf.parseRequirement(rep)
3584        >>> back == r
3585        True
3586        >>> ReqNot(ReqNothing()).unparse()
3587        '!(O)'
3588        >>> ReqImpossible().unparse()
3589        'X'
3590        >>> r = ReqAny([ReqCapability('A'), ReqCapability('B'),
3591        ...     ReqCapability('C')])
3592        >>> rep = r.unparse()
3593        >>> rep
3594        '(A|B|C)'
3595        >>> back = pf.parseRequirement(rep)
3596        >>> back == r
3597        True
3598        """
3599        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):
3602class ReqAny(Requirement):
3603    """
3604    A disjunction requirement satisfied when any one of its
3605    sub-requirements is satisfied.
3606    """
3607    def __init__(self, subs: Iterable[Requirement]) -> None:
3608        self.subs = list(subs)
3609
3610    def __hash__(self) -> int:
3611        result = 179843
3612        for sub in self.subs:
3613            result = 31 * (result + hash(sub))
3614        return result
3615
3616    def __eq__(self, other: Any) -> bool:
3617        return isinstance(other, ReqAny) and other.subs == self.subs
3618
3619    def __repr__(self):
3620        return "ReqAny(" + repr(self.subs) + ")"
3621
3622    def satisfied(
3623        self,
3624        context: RequirementContext,
3625        dontRecurse: Optional[
3626            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
3627        ] = None
3628    ) -> bool:
3629        """
3630        True as long as any one of the sub-requirements is satisfied.
3631        """
3632        return any(
3633            sub.satisfied(context, dontRecurse)
3634            for sub in self.subs
3635        )
3636
3637    def walk(self) -> Generator[Requirement, None, None]:
3638        yield self
3639        for sub in self.subs:
3640            yield from sub.walk()
3641
3642    def asEffectList(self) -> List[Effect]:
3643        """
3644        Raises a `TypeError` since disjunctions don't have a translation
3645        into a simple list of effects to satisfy them.
3646        """
3647        raise TypeError(
3648            "Cannot convert ReqAny into an effect list:"
3649            " contradictory token or mechanism requirements on"
3650            " different branches are not easy to synthesize."
3651        )
3652
3653    def flatten(self) -> Requirement:
3654        """
3655        Flattens this requirement by merging any sub-requirements which
3656        are also `ReqAny` instances into this one.
3657        """
3658        merged = []
3659        for sub in self.subs:
3660            flat = sub.flatten()
3661            if isinstance(flat, ReqAny):
3662                merged.extend(flat.subs)
3663            else:
3664                merged.append(flat)
3665
3666        return ReqAny(merged)
3667
3668    def unparse(self) -> str:
3669        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])
3607    def __init__(self, subs: Iterable[Requirement]) -> None:
3608        self.subs = list(subs)
subs
def satisfied( self, context: RequirementContext, dontRecurse: Optional[Set[Union[str, Tuple[int, str]]]] = None) -> bool:
3622    def satisfied(
3623        self,
3624        context: RequirementContext,
3625        dontRecurse: Optional[
3626            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
3627        ] = None
3628    ) -> bool:
3629        """
3630        True as long as any one of the sub-requirements is satisfied.
3631        """
3632        return any(
3633            sub.satisfied(context, dontRecurse)
3634            for sub in self.subs
3635        )

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

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

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

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

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

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

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

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

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

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

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

def asEffectList(self) -> List[Effect]:
3712    def asEffectList(self) -> List[Effect]:
3713        """
3714        Returns a gain list composed by adding together the gain lists
3715        for each sub-requirement. Note that some types of requirement
3716        will raise a `TypeError` during this process if they appear as a
3717        sub-requirement.
3718        """
3719        result = []
3720        for sub in self.subs:
3721            result += sub.asEffectList()
3722
3723        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:
3725    def flatten(self) -> Requirement:
3726        """
3727        Flattens this requirement by merging any sub-requirements which
3728        are also `ReqAll` instances into this one.
3729        """
3730        merged = []
3731        for sub in self.subs:
3732            flat = sub.flatten()
3733            if isinstance(flat, ReqAll):
3734                merged.extend(flat.subs)
3735            else:
3736                merged.append(flat)
3737
3738        return ReqAll(merged)

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

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

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

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

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

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

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

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

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:
3789    def flatten(self) -> Requirement:
3790        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:
3792    def unparse(self) -> str:
3793        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):
3796class ReqCapability(Requirement):
3797    """
3798    A capability requirement is satisfied if the specified capability is
3799    possessed by the player according to the given state.
3800    """
3801    def __init__(self, capability: Capability) -> None:
3802        self.capability = capability
3803
3804    def __hash__(self) -> int:
3805        return 47923 + hash(self.capability)
3806
3807    def __eq__(self, other: Any) -> bool:
3808        return (
3809            isinstance(other, ReqCapability)
3810        and other.capability == self.capability
3811        )
3812
3813    def __repr__(self):
3814        return "ReqCapability(" + repr(self.capability) + ")"
3815
3816    def satisfied(
3817        self,
3818        context: RequirementContext,
3819        dontRecurse: Optional[
3820            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
3821        ] = None
3822    ) -> bool:
3823        return hasCapabilityOrEquivalent(
3824            self.capability,
3825            context,
3826            dontRecurse
3827        )
3828
3829    def walk(self) -> Generator[Requirement, None, None]:
3830        yield self
3831
3832    def asEffectList(self) -> List[Effect]:
3833        """
3834        Returns a list containing a single 'gain' effect which grants
3835        the required capability.
3836        """
3837        return [effect(gain=self.capability)]
3838
3839    def unparse(self) -> str:
3840        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)
3801    def __init__(self, capability: Capability) -> None:
3802        self.capability = capability
capability
def satisfied( self, context: RequirementContext, dontRecurse: Optional[Set[Union[str, Tuple[int, str]]]] = None) -> bool:
3816    def satisfied(
3817        self,
3818        context: RequirementContext,
3819        dontRecurse: Optional[
3820            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
3821        ] = None
3822    ) -> bool:
3823        return hasCapabilityOrEquivalent(
3824            self.capability,
3825            context,
3826            dontRecurse
3827        )

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]:
3829    def walk(self) -> Generator[Requirement, None, None]:
3830        yield self

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

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

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

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

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

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

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]:
3960    def walk(self) -> Generator[Requirement, None, None]:
3961        yield self

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

def asEffectList(self) -> List[Effect]:
3963    def asEffectList(self) -> List[Effect]:
3964        """
3965        Returns a list containing a single 'set' effect which sets the
3966        required mechanism to the required state.
3967        """
3968        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:
3970    def unparse(self) -> str:
3971        if isinstance(self.mechanism, (MechanismID, MechanismName)):
3972            return f'{self.mechanism}:{self.reqState}'
3973        else:  # Must be a MechanismSpecifier
3974            # TODO: This elsewhere!
3975            domain, zone, decision, mechanism = self.mechanism
3976            mspec = ''
3977            if domain is not None:
3978                mspec += domain + '//'
3979            if zone is not None:
3980                mspec += zone + '::'
3981            if decision is not None:
3982                mspec += decision + '::'
3983            mspec += mechanism
3984            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):
3987class ReqLevel(Requirement):
3988    """
3989    A tag requirement satisfied if a specific skill is at or above the
3990    specified level.
3991    """
3992    def __init__(
3993        self,
3994        skill: Skill,
3995        minLevel: Level,
3996    ) -> None:
3997        self.skill = skill
3998        self.minLevel = minLevel
3999
4000    def __hash__(self) -> int:
4001        return (
4002            (79 * hash(self.skill))
4003          + (55 * hash(self.minLevel))
4004        )
4005
4006    def __eq__(self, other: Any) -> bool:
4007        return (
4008            isinstance(other, ReqLevel)
4009        and other.skill == self.skill
4010        and other.minLevel == self.minLevel
4011        )
4012
4013    def __repr__(self):
4014        sRep = repr(self.skill)
4015        lRep = repr(self.minLevel)
4016        return f"ReqLevel({sRep}, {lRep})"
4017
4018    def satisfied(
4019        self,
4020        context: RequirementContext,
4021        dontRecurse: Optional[
4022            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
4023        ] = None
4024    ) -> bool:
4025        return getSkillLevel(context.state, self.skill) >= self.minLevel
4026
4027    def walk(self) -> Generator[Requirement, None, None]:
4028        yield self
4029
4030    def asEffectList(self) -> List[Effect]:
4031        """
4032        Returns a list containing a single 'set' effect which sets the
4033        required skill to the minimum required level. Note that this may
4034        reduce a skill level that was more than sufficient to meet the
4035        requirement.
4036        """
4037        return [effect(set=("skill", self.skill, self.minLevel))]
4038
4039    def unparse(self) -> str:
4040        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)
3992    def __init__(
3993        self,
3994        skill: Skill,
3995        minLevel: Level,
3996    ) -> None:
3997        self.skill = skill
3998        self.minLevel = minLevel
skill
minLevel
def satisfied( self, context: RequirementContext, dontRecurse: Optional[Set[Union[str, Tuple[int, str]]]] = None) -> bool:
4018    def satisfied(
4019        self,
4020        context: RequirementContext,
4021        dontRecurse: Optional[
4022            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
4023        ] = None
4024    ) -> bool:
4025        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]:
4027    def walk(self) -> Generator[Requirement, None, None]:
4028        yield self

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

def asEffectList(self) -> List[Effect]:
4030    def asEffectList(self) -> List[Effect]:
4031        """
4032        Returns a list containing a single 'set' effect which sets the
4033        required skill to the minimum required level. Note that this may
4034        reduce a skill level that was more than sufficient to meet the
4035        requirement.
4036        """
4037        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:
4039    def unparse(self) -> str:
4040        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):
4043class ReqTag(Requirement):
4044    """
4045    A tag requirement satisfied if there is any active decision that has
4046    the specified value for the given tag (default value is 1 for tags
4047    where a value wasn't specified). Zone tags also satisfy the
4048    requirement if they're applied to zones that include active
4049    decisions.
4050    """
4051    def __init__(
4052        self,
4053        tag: "Tag",
4054        value: "TagValue",
4055    ) -> None:
4056        self.tag = tag
4057        self.value = value
4058
4059    def __hash__(self) -> int:
4060        return (
4061            (71 * hash(self.tag))
4062          + (43 * hash(self.value))
4063        )
4064
4065    def __eq__(self, other: Any) -> bool:
4066        return (
4067            isinstance(other, ReqTag)
4068        and other.tag == self.tag
4069        and other.value == self.value
4070        )
4071
4072    def __repr__(self):
4073        tRep = repr(self.tag)
4074        vRep = repr(self.value)
4075        return f"ReqTag({tRep}, {vRep})"
4076
4077    def satisfied(
4078        self,
4079        context: RequirementContext,
4080        dontRecurse: Optional[
4081            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
4082        ] = None
4083    ) -> bool:
4084        active = combinedDecisionSet(context.state)
4085        graph = context.graph
4086        zones = set()
4087        for decision in active:
4088            tags = graph.decisionTags(decision)
4089            if self.tag in tags and tags[self.tag] == self.value:
4090                return True
4091            zones |= graph.zoneAncestors(decision)
4092        for zone in zones:
4093            zTags = graph.zoneTags(zone)
4094            if self.tag in zTags and zTags[self.tag] == self.value:
4095                return True
4096
4097        return False
4098
4099    def walk(self) -> Generator[Requirement, None, None]:
4100        yield self
4101
4102    def asEffectList(self) -> List[Effect]:
4103        """
4104        Returns a list containing a single 'set' effect which sets the
4105        required mechanism to the required state.
4106        """
4107        raise TypeError(
4108            "Cannot convert ReqTag into an effect list:"
4109            " effects cannot apply/remove/change tags"
4110        )
4111
4112    def unparse(self) -> str:
4113        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]]])
4051    def __init__(
4052        self,
4053        tag: "Tag",
4054        value: "TagValue",
4055    ) -> None:
4056        self.tag = tag
4057        self.value = value
tag
value
def satisfied( self, context: RequirementContext, dontRecurse: Optional[Set[Union[str, Tuple[int, str]]]] = None) -> bool:
4077    def satisfied(
4078        self,
4079        context: RequirementContext,
4080        dontRecurse: Optional[
4081            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
4082        ] = None
4083    ) -> bool:
4084        active = combinedDecisionSet(context.state)
4085        graph = context.graph
4086        zones = set()
4087        for decision in active:
4088            tags = graph.decisionTags(decision)
4089            if self.tag in tags and tags[self.tag] == self.value:
4090                return True
4091            zones |= graph.zoneAncestors(decision)
4092        for zone in zones:
4093            zTags = graph.zoneTags(zone)
4094            if self.tag in zTags and zTags[self.tag] == self.value:
4095                return True
4096
4097        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]:
4099    def walk(self) -> Generator[Requirement, None, None]:
4100        yield self

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

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

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

def unparse(self) -> str:
4112    def unparse(self) -> str:
4113        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):
4116class ReqNothing(Requirement):
4117    """
4118    A requirement representing that something doesn't actually have a
4119    requirement. This requirement is always satisfied.
4120    """
4121    def __hash__(self) -> int:
4122        return 127942
4123
4124    def __eq__(self, other: Any) -> bool:
4125        return isinstance(other, ReqNothing)
4126
4127    def __repr__(self):
4128        return "ReqNothing()"
4129
4130    def satisfied(
4131        self,
4132        context: RequirementContext,
4133        dontRecurse: Optional[
4134            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
4135        ] = None
4136    ) -> bool:
4137        return True
4138
4139    def walk(self) -> Generator[Requirement, None, None]:
4140        yield self
4141
4142    def asEffectList(self) -> List[Effect]:
4143        """
4144        Returns an empty list, since nothing is required.
4145        """
4146        return []
4147
4148    def unparse(self) -> str:
4149        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:
4130    def satisfied(
4131        self,
4132        context: RequirementContext,
4133        dontRecurse: Optional[
4134            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
4135        ] = None
4136    ) -> bool:
4137        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]:
4139    def walk(self) -> Generator[Requirement, None, None]:
4140        yield self

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

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

Returns an empty list, since nothing is required.

def unparse(self) -> str:
4148    def unparse(self) -> str:
4149        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):
4152class ReqImpossible(Requirement):
4153    """
4154    A requirement representing that something is impossible. This
4155    requirement is never satisfied.
4156    """
4157    def __hash__(self) -> int:
4158        return 478743
4159
4160    def __eq__(self, other: Any) -> bool:
4161        return isinstance(other, ReqImpossible)
4162
4163    def __repr__(self):
4164        return "ReqImpossible()"
4165
4166    def satisfied(
4167        self,
4168        context: RequirementContext,
4169        dontRecurse: Optional[
4170            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
4171        ] = None
4172    ) -> bool:
4173        return False
4174
4175    def walk(self) -> Generator[Requirement, None, None]:
4176        yield self
4177
4178    def asEffectList(self) -> List[Effect]:
4179        """
4180        Raises a `TypeError` since a `ReqImpossible` cannot be converted
4181        into an effect which would allow the transition to be taken.
4182        """
4183        raise TypeError(
4184            "Cannot convert ReqImpossible into an effect list:"
4185            " there are no powers or tokens which could be gained to"
4186            " satisfy this requirement."
4187        )
4188
4189    def unparse(self) -> str:
4190        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:
4166    def satisfied(
4167        self,
4168        context: RequirementContext,
4169        dontRecurse: Optional[
4170            Set[Union[Capability, Tuple[MechanismID, MechanismState]]]
4171        ] = None
4172    ) -> bool:
4173        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]:
4175    def walk(self) -> Generator[Requirement, None, None]:
4176        yield self

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

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

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

def unparse(self) -> str:
4189    def unparse(self) -> str:
4190        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:
4302class NoTagValue:
4303    """
4304    Class used to indicate no tag value for things that return tag values
4305    since `None` is a valid tag value.
4306    """
4307    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):
4329class ZoneInfo(NamedTuple):
4330    """
4331    Zone info holds a level integer (starting from 0 as the level directly
4332    above decisions), a set of parent zones, a set of child decisions
4333    and/or zones, and zone tags and annotations. Zones at a particular
4334    level may only contain zones in lower levels, although zones at any
4335    level may also contain decisions directly.  The norm is for zones at
4336    level 0 to contain decisions, while zones at higher levels contain
4337    zones from the level directly below them.
4338
4339    Note that zones may have multiple parents, because one sub-zone may be
4340    contained within multiple super-zones.
4341    """
4342    level: int
4343    parents: Set[Zone]
4344    contents: Set[Union[DecisionID, Zone]]
4345    tags: Dict[Tag, TagValue]
4346    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
class DefaultZone:
4349class DefaultZone:
4350    """
4351    Default argument for a `Zone` when `None` is used to mean "Do not add
4352    to the zone you normally would."
4353    """
4354    pass

Default argument for a Zone when None is used to mean "Do not add to the zone you normally would."

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], Union[str, NoneType, type[DefaultZone]]], Tuple[Literal['explore'], Tuple[Literal['common', 'active'], str, str], Tuple[str, List[bool]], Union[str, int, NoneType], Optional[str], Union[str, NoneType, type[DefaultZone]]], 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], Union[str, NoneType, type[DefaultZone]]], Tuple[Literal['explore'], Tuple[Literal['common', 'active'], str, str], Tuple[str, List[bool]], Union[str, int, NoneType], Optional[str], Union[str, NoneType, type[DefaultZone]]], 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, type[DefaultZone], None] = action[-1]
4642        if newZone in (None, DefaultZone):
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], Union[str, NoneType, type[DefaultZone]]], Tuple[Literal['explore'], Tuple[Literal['common', 'active'], str, str], Tuple[str, List[bool]], Union[str, int, NoneType], Optional[str], Union[str, NoneType, type[DefaultZone]]], 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], Union[str, NoneType, type[DefaultZone]]], Tuple[Literal['explore'], Tuple[Literal['common', 'active'], str, str], Tuple[str, List[bool]], Union[str, int, NoneType], Optional[str], Union[str, NoneType, type[DefaultZone]]], 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

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):
5765class FeatureSpecifier(NamedTuple):
5766    """
5767    There are several ways to specify a feature within a `FeatureGraph`:
5768    Simplest is to just include the `FeatureID` directly (in that case
5769    the domain must be `None` and the 'within' sequence must be empty).
5770    A specific domain and/or a sequence of containing features (starting
5771    from most-external to most-internal) may also be specified when a
5772    string is used as the feature itself, to help disambiguate (when an
5773    ambiguous `FeatureSpecifier` is used,
5774    `AmbiguousFeatureSpecifierError` may arise in some cases). For any
5775    feature, a part may also be specified indicating which part of the
5776    feature is being referred to; this can be `None` when not referring
5777    to any specific sub-part.
5778    """
5779    domain: Optional[Domain]
5780    within: Sequence[Feature]
5781    feature: Union[Feature, FeatureID]
5782    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:
5785def feature(
5786    name: Feature,
5787    part: Optional[Part] = None,
5788    domain: Optional[Domain] = None,
5789    within: Optional[Sequence[Feature]] = None
5790) -> FeatureSpecifier:
5791    """
5792    Builds a `FeatureSpecifier` with some defaults. The default domain
5793    is `None`, and by default the feature has an empty 'within' field and
5794    its part field is `None`.
5795    """
5796    if within is None:
5797        within = []
5798    return FeatureSpecifier(
5799        domain=domain,
5800        within=within,
5801        feature=name,
5802        part=part
5803    )

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:
5819def normalizeFeatureSpecifier(spec: AnyFeatureSpecifier) -> FeatureSpecifier:
5820    """
5821    Turns an `AnyFeatureSpecifier` into a `FeatureSpecifier`. Note that
5822    it does not do parsing from a complex string. Use
5823    `parsing.ParseFormat.parseFeatureSpecifier` for that.
5824
5825    It will turn a feature specifier with an int-convertible feature name
5826    into a feature-ID-based specifier, discarding any domain and/or zone
5827    parts.
5828
5829    TODO: Issue a warning if parts are discarded?
5830    """
5831    if isinstance(spec, (FeatureID, Feature)):
5832        return FeatureSpecifier(
5833            domain=None,
5834            within=[],
5835            feature=spec,
5836            part=None
5837        )
5838    elif isinstance(spec, FeatureSpecifier):
5839        try:
5840            fID = int(spec.feature)
5841            return FeatureSpecifier(None, [], fID, spec.part)
5842        except ValueError:
5843            return spec
5844    else:
5845        raise TypeError(
5846            f"Invalid feature specifier type: {type(spec)}"
5847        )

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:
5850class MetricSpace:
5851    """
5852    TODO
5853    Represents a variable-dimensional coordinate system within which
5854    locations can be identified by coordinates. May (or may not) include
5855    a reference to one or more images which are visual representation(s)
5856    of the space.
5857    """
5858    def __init__(self, name: str):
5859        self.name = name
5860
5861        self.points: Dict[PointID, Coords] = {}
5862        # Holds all IDs and their corresponding coordinates as key/value
5863        # pairs
5864
5865        self.nextID: PointID = 0
5866        # ID numbers should not be repeated or reused
5867
5868    def addPoint(self, coords: Coords) -> PointID:
5869        """
5870        Given a sequence (list/array/etc) of int coordinates, creates a
5871        point and adds it to the metric space object
5872
5873        >>> ms = MetricSpace("test")
5874        >>> ms.addPoint([2, 3])
5875        0
5876        >>> #expected result
5877        >>> ms.addPoint([2, 7, 0])
5878        1
5879        """
5880        thisID = self.nextID
5881
5882        self.nextID += 1
5883
5884        self.points[thisID] = coords  # creates key value pair
5885
5886        return thisID
5887
5888        # How do we "add" things to the metric space? What data structure
5889        # is it? dictionary
5890
5891    def removePoint(self, thisID: PointID) -> None:
5892        """
5893        Given the ID of a point/coord, checks the dictionary
5894        (points) for that key and removes the key/value pair from
5895        it.
5896
5897        >>> ms = MetricSpace("test")
5898        >>> ms.addPoint([2, 3])
5899        0
5900        >>> ms.removePoint(0)
5901        >>> ms.removePoint(0)
5902        Traceback (most recent call last):
5903        ...
5904        KeyError...
5905        >>> #expected result should be a caught KeyNotFound exception
5906        """
5907        self.points.pop(thisID)
5908
5909    def distance(self, origin: AnyPoint, dest: AnyPoint) -> float:
5910        """
5911        Given an orgin point and destination point, returns the
5912        distance between the two points as a float.
5913
5914        >>> ms = MetricSpace("test")
5915        >>> ms.addPoint([4, 0])
5916        0
5917        >>> ms.addPoint([1, 0])
5918        1
5919        >>> ms.distance(0, 1)
5920        3.0
5921        >>> p1 = ms.addPoint([4, 3])
5922        >>> p2 = ms.addPoint([4, 9])
5923        >>> ms.distance(p1, p2)
5924        6.0
5925        >>> ms.distance([8, 6], [4, 6])
5926        4.0
5927        >>> ms.distance([1, 1], [1, 1])
5928        0.0
5929        >>> ms.distance([-2, -3], [-5, -7])
5930        5.0
5931        >>> ms.distance([2.5, 3.7], [4.9, 6.1])
5932        3.394112549695428
5933        """
5934        if isinstance(origin, PointID):
5935            coord1 = self.points[origin]
5936        else:
5937            coord1 = origin
5938
5939        if isinstance(dest, PointID):
5940            coord2 = self.points[dest]
5941        else:
5942            coord2 = dest
5943
5944        inside = 0.0
5945
5946        for dim in range(max(len(coord1), len(coord2))):
5947            if dim < len(coord1):
5948                val1 = coord1[dim]
5949            else:
5950                val1 = 0
5951            if dim < len(coord2):
5952                val2 = coord2[dim]
5953            else:
5954                val2 = 0
5955
5956            inside += (val2 - val1)**2
5957
5958        result = math.sqrt(inside)
5959        return result
5960
5961    def NDCoords(
5962        self,
5963        point: AnyPoint,
5964        numDimension: int
5965    ) -> Coords:
5966        """
5967        Given a 2D set of coordinates (x, y), converts them to the desired
5968        dimension
5969
5970        >>> ms = MetricSpace("test")
5971        >>> ms.NDCoords([5, 9], 3)
5972        [5, 9, 0]
5973        >>> ms.NDCoords([3, 1], 1)
5974        [3]
5975        """
5976        if isinstance(point, PointID):
5977            coords = self.points[point]
5978        else:
5979            coords = point
5980
5981        seqLength = len(coords)
5982
5983        if seqLength != numDimension:
5984
5985            newCoords: Coords
5986
5987            if seqLength < numDimension:
5988
5989                newCoords = [item for item in coords]
5990
5991                for i in range(numDimension - seqLength):
5992                    newCoords.append(0)
5993
5994            else:
5995                newCoords = coords[:numDimension]
5996
5997        return newCoords
5998
5999    def lastID(self) -> PointID:
6000        """
6001        Returns the most updated ID of the metricSpace instance. The nextID
6002        field is always 1 more than the last assigned ID. Assumes that there
6003        has at least been one ID assigned to a point as a key value pair
6004        in the dictionary. Returns 0 if that is not the case. Does not
6005        consider possible counting errors if a point has been removed from
6006        the dictionary. The last ID does not neccessarily equal the number
6007        of points in the metricSpace (or in the dictionary).
6008
6009        >>> ms = MetricSpace("test")
6010        >>> ms.lastID()
6011        0
6012        >>> ms.addPoint([2, 3])
6013        0
6014        >>> ms.addPoint([2, 7, 0])
6015        1
6016        >>> ms.addPoint([2, 7])
6017        2
6018        >>> ms.lastID()
6019        2
6020        >>> ms.removePoint(2)
6021        >>> ms.lastID()
6022        2
6023        """
6024        if self.nextID < 1:
6025            return self.nextID
6026        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)
5858    def __init__(self, name: str):
5859        self.name = name
5860
5861        self.points: Dict[PointID, Coords] = {}
5862        # Holds all IDs and their corresponding coordinates as key/value
5863        # pairs
5864
5865        self.nextID: PointID = 0
5866        # ID numbers should not be repeated or reused
name
points: Dict[int, Sequence[float]]
nextID: int
def addPoint(self, coords: Sequence[float]) -> int:
5868    def addPoint(self, coords: Coords) -> PointID:
5869        """
5870        Given a sequence (list/array/etc) of int coordinates, creates a
5871        point and adds it to the metric space object
5872
5873        >>> ms = MetricSpace("test")
5874        >>> ms.addPoint([2, 3])
5875        0
5876        >>> #expected result
5877        >>> ms.addPoint([2, 7, 0])
5878        1
5879        """
5880        thisID = self.nextID
5881
5882        self.nextID += 1
5883
5884        self.points[thisID] = coords  # creates key value pair
5885
5886        return thisID
5887
5888        # How do we "add" things to the metric space? What data structure
5889        # 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:
5891    def removePoint(self, thisID: PointID) -> None:
5892        """
5893        Given the ID of a point/coord, checks the dictionary
5894        (points) for that key and removes the key/value pair from
5895        it.
5896
5897        >>> ms = MetricSpace("test")
5898        >>> ms.addPoint([2, 3])
5899        0
5900        >>> ms.removePoint(0)
5901        >>> ms.removePoint(0)
5902        Traceback (most recent call last):
5903        ...
5904        KeyError...
5905        >>> #expected result should be a caught KeyNotFound exception
5906        """
5907        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:
5909    def distance(self, origin: AnyPoint, dest: AnyPoint) -> float:
5910        """
5911        Given an orgin point and destination point, returns the
5912        distance between the two points as a float.
5913
5914        >>> ms = MetricSpace("test")
5915        >>> ms.addPoint([4, 0])
5916        0
5917        >>> ms.addPoint([1, 0])
5918        1
5919        >>> ms.distance(0, 1)
5920        3.0
5921        >>> p1 = ms.addPoint([4, 3])
5922        >>> p2 = ms.addPoint([4, 9])
5923        >>> ms.distance(p1, p2)
5924        6.0
5925        >>> ms.distance([8, 6], [4, 6])
5926        4.0
5927        >>> ms.distance([1, 1], [1, 1])
5928        0.0
5929        >>> ms.distance([-2, -3], [-5, -7])
5930        5.0
5931        >>> ms.distance([2.5, 3.7], [4.9, 6.1])
5932        3.394112549695428
5933        """
5934        if isinstance(origin, PointID):
5935            coord1 = self.points[origin]
5936        else:
5937            coord1 = origin
5938
5939        if isinstance(dest, PointID):
5940            coord2 = self.points[dest]
5941        else:
5942            coord2 = dest
5943
5944        inside = 0.0
5945
5946        for dim in range(max(len(coord1), len(coord2))):
5947            if dim < len(coord1):
5948                val1 = coord1[dim]
5949            else:
5950                val1 = 0
5951            if dim < len(coord2):
5952                val2 = coord2[dim]
5953            else:
5954                val2 = 0
5955
5956            inside += (val2 - val1)**2
5957
5958        result = math.sqrt(inside)
5959        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]:
5961    def NDCoords(
5962        self,
5963        point: AnyPoint,
5964        numDimension: int
5965    ) -> Coords:
5966        """
5967        Given a 2D set of coordinates (x, y), converts them to the desired
5968        dimension
5969
5970        >>> ms = MetricSpace("test")
5971        >>> ms.NDCoords([5, 9], 3)
5972        [5, 9, 0]
5973        >>> ms.NDCoords([3, 1], 1)
5974        [3]
5975        """
5976        if isinstance(point, PointID):
5977            coords = self.points[point]
5978        else:
5979            coords = point
5980
5981        seqLength = len(coords)
5982
5983        if seqLength != numDimension:
5984
5985            newCoords: Coords
5986
5987            if seqLength < numDimension:
5988
5989                newCoords = [item for item in coords]
5990
5991                for i in range(numDimension - seqLength):
5992                    newCoords.append(0)
5993
5994            else:
5995                newCoords = coords[:numDimension]
5996
5997        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:
5999    def lastID(self) -> PointID:
6000        """
6001        Returns the most updated ID of the metricSpace instance. The nextID
6002        field is always 1 more than the last assigned ID. Assumes that there
6003        has at least been one ID assigned to a point as a key value pair
6004        in the dictionary. Returns 0 if that is not the case. Does not
6005        consider possible counting errors if a point has been removed from
6006        the dictionary. The last ID does not neccessarily equal the number
6007        of points in the metricSpace (or in the dictionary).
6008
6009        >>> ms = MetricSpace("test")
6010        >>> ms.lastID()
6011        0
6012        >>> ms.addPoint([2, 3])
6013        0
6014        >>> ms.addPoint([2, 7, 0])
6015        1
6016        >>> ms.addPoint([2, 7])
6017        2
6018        >>> ms.lastID()
6019        2
6020        >>> ms.removePoint(2)
6021        >>> ms.lastID()
6022        2
6023        """
6024        if self.nextID < 1:
6025            return self.nextID
6026        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:
6029def featurePart(spec: AnyFeatureSpecifier, part: Part) -> FeatureSpecifier:
6030    """
6031    Returns a new feature specifier (and/or normalizes to one) that
6032    contains the specified part in the 'part' slot. If the provided
6033    feature specifier already contains a 'part', that will be replaced.
6034
6035    For example:
6036
6037    >>> featurePart('town', 'north')
6038    FeatureSpecifier(domain=None, within=[], feature='town', part='north')
6039    >>> featurePart(5, 'top')
6040    FeatureSpecifier(domain=None, within=[], feature=5, part='top')
6041    >>> featurePart(
6042    ...     FeatureSpecifier('dom', ['one', 'two'], 'three', 'middle'),
6043    ...     'top'
6044    ... )
6045    FeatureSpecifier(domain='dom', within=['one', 'two'], feature='three',\
6046 part='top')
6047    >>> featurePart(FeatureSpecifier(None, ['region'], 'place', None), 'top')
6048    FeatureSpecifier(domain=None, within=['region'], feature='place',\
6049 part='top')
6050    """
6051    spec = normalizeFeatureSpecifier(spec)
6052    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):
6213class FeatureDecision(TypedDict):
6214    """
6215    Represents a decision made during exploration, including the
6216    position(s) at which the explorer made the decision, which
6217    feature(s) were most relevant to the decision and what course of
6218    action was decided upon (see `FeatureAction`). Has the following
6219    slots:
6220
6221    - 'type': The type of decision (see `exploration.core.DecisionType`).
6222    - 'domains': A set of domains which are active during the decision,
6223        as opposed to domains which may be unfocused or otherwise
6224        inactive.
6225    - 'focus': An optional single `FeatureSpecifier` which represents the
6226        focal character or object for a decision. May be `None` e.g. in
6227        cases where a menu is in focus. Note that the 'positions' slot
6228        determines which positions are relevant to the decision,
6229        potentially separately from the focus but usually overlapping it.
6230    - 'positions': A dictionary mapping `core.Domain`s to sets of
6231        `FeatureSpecifier`s representing the player's position(s) in
6232        each domain. Some domains may function like tech trees, where
6233        the set of positions only expands over time. Others may function
6234        like a single avatar in a virtual world, where there is only one
6235        position. Still others might function like a group of virtual
6236        avatars, with multiple positions that can be updated
6237        independently.
6238    - 'intention': A `FeatureAction` indicating the action taken or
6239        attempted next as a result of the decision.
6240    """
6241    # TODO: HERE
6242    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 exploration.core.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': {'path', 'region', 'edge', 'landmark', 'entity', 'node'}, 'recede': {'path', 'region', 'edge', 'landmark', 'entity', 'node'}, 'follow': {'edge', 'path', 'entity'}, 'cross': {'edge', 'path', 'node', 'region'}, 'enter': {'node', 'region'}, 'exit': {'node', 'region'}, 'explore': {'edge', 'path', 'node', 'region'}, 'scrutinize': {'path', 'region', 'edge', 'landmark', 'entity', 'node', 'affordance'}, 'do': {'affordance'}, 'interact': {'entity', 'node'}}

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

class FeatureEffect(typing.TypedDict):
6322class FeatureEffect(TypedDict):
6323    """
6324    Similar to `Effect` but with more options for how to manipulate the
6325    game state. This represents a single concrete change to either
6326    internal game state, or to the feature graph. Multiple changes
6327    (possibly with random factors involved) can be represented by a
6328    `Consequence`; a `FeatureEffect` is used as a leaf in a `Consequence`
6329    tree.
6330    """
6331    type: Literal[
6332        'gain',
6333        'lose',
6334        'toggle',
6335        'deactivate',
6336        'move',
6337        'focus',
6338        'initiate'
6339        'foreground',
6340        'background',
6341    ]
6342    value: Union[
6343        Capability,
6344        Tuple[Token, int],
6345        List[Capability],
6346        None
6347    ]
6348    charges: Optional[int]
6349    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):
6352def featureEffect(
6353    #applyTo: ContextSpecifier = 'active',
6354    #gain: Optional[Union[
6355    #    Capability,
6356    #    Tuple[Token, TokenCount],
6357    #    Tuple[Literal['skill'], Skill, Level]
6358    #]] = None,
6359    #lose: Optional[Union[
6360    #    Capability,
6361    #    Tuple[Token, TokenCount],
6362    #    Tuple[Literal['skill'], Skill, Level]
6363    #]] = None,
6364    #set: Optional[Union[
6365    #    Tuple[Token, TokenCount],
6366    #    Tuple[AnyMechanismSpecifier, MechanismState],
6367    #    Tuple[Literal['skill'], Skill, Level]
6368    #]] = None,
6369    #toggle: Optional[Union[
6370    #    Tuple[AnyMechanismSpecifier, List[MechanismState]],
6371    #    List[Capability]
6372    #]] = None,
6373    #deactivate: Optional[bool] = None,
6374    #edit: Optional[List[List[commands.Command]]] = None,
6375    #goto: Optional[Union[
6376    #    AnyDecisionSpecifier,
6377    #    Tuple[AnyDecisionSpecifier, FocalPointName]
6378    #]] = None,
6379    #bounce: Optional[bool] = None,
6380    #delay: Optional[int] = None,
6381    #charges: Optional[int] = None,
6382    **kwargs
6383):
6384    # TODO: HERE
6385    return effect(**kwargs)
class FeatureAction(typing.TypedDict):
6390class FeatureAction(TypedDict):
6391    """
6392    Indicates an action decided on by a `FeatureDecision`. Has the
6393    following slots:
6394
6395    - 'subject': the main feature (an `AnyFeatureSpecifier`) that
6396        performs the action (usually an 'entity').
6397    - 'object': the main feature (an `AnyFeatureSpecifier`) with which
6398        the affordance is performed.
6399    - 'affordance': the specific `FeatureAffordance` indicating the type
6400        of action.
6401    - 'direction': The general direction of movement (especially when
6402        the affordance is `follow`). This can be either a direction in
6403        an associated `MetricSpace`, or it can be defined towards or
6404        away from the destination specified. If a destination but no
6405        direction is provided, the direction is assumed to be towards
6406        that destination.
6407    - 'part': The part within/along a feature for movement (e.g., which
6408        side of an edge are you on, or which part of a region are you
6409        traveling through).
6410    - 'destination': The destination of the action (when known ahead of
6411        time). For example, moving along a path towards a particular
6412        feature touching that path, or entering a node into a particular
6413        feature within that node. Note that entering of regions can be
6414        left implicit: if you enter a region to get to a landmark within
6415        it, noting that as approaching the landmark is more appropriate
6416        than noting that as entering the region with the landmark as the
6417        destination. The system can infer what regions you're in by
6418        which feature you're at.
6419    - 'outcome': A `Consequence` list/tree indicating one or more
6420        outcomes, possibly involving challenges. Note that the actual
6421        outcomes of an action may be altered by triggers; the outcomes
6422        listed here are the default outcomes if no triggers are tripped.
6423
6424    The 'direction', 'part', and/or 'destination' may each be None,
6425    depending on the type of affordance and/or amount of detail desired.
6426    """
6427    subject: AnyFeatureSpecifier
6428    object: AnyFeatureSpecifier
6429    affordance: FeatureAffordance
6430    direction: Optional[Part]
6431    part: Optional[Part]
6432    destination: Optional[AnyFeatureSpecifier]
6433    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]]