exploration.base
- Authors: Peter Mawhorter
- Consulted:
- Date: 2023-12-27
- Purpose: Basic types that structure information used for parts of the
core types (see
core.py
).
Besides very basic utility functions, code for dealing with these types
is defined in other files, notably core.py
and parsing.py
.
Defines the following base types:
Domain
Zone
DecisionID
DecisionName
DecisionSpecifier
AnyDecisionSpecifier
Transition
TransitionWithOutcomes
Capability
Token
TokenCount
Skill
Level
MechanismID
MechanismName
MechanismState
CapabilitySet
DomainFocalization
FocalPointName
ContextSpecifier
FocalPointSpecifier
FocalContext
FocalContextName
State
RequirementContext
Effect
SkillCombination
Challenge
Condition
Consequence
Equivalences
Requirement
Tag
TagValueTypes
TagValue
NoTagValue
TagUpdateFunction
Annotations
ZoneInfo
ExplorationActionType
ExplorationAction
DecisionType
Situation
PointID
Coords
AnyPoint
Feature
FeatureID
Part
FeatureSpecifier
AnyFeatureSpecifier
MetricSpace
FeatureType
FeatureRelationshipType
FeatureDecision
FeatureAffordance
FeatureEffect
FeatureAction
1""" 2- Authors: Peter Mawhorter 3- Consulted: 4- Date: 2023-12-27 5- Purpose: Basic types that structure information used for parts of the 6 core types (see `core.py`). 7 8Besides very basic utility functions, code for dealing with these types 9is defined in other files, notably `core.py` and `parsing.py`. 10 11Defines the following base types: 12 13- `Domain` 14- `Zone` 15- `DecisionID` 16- `DecisionName` 17- `DecisionSpecifier` 18- `AnyDecisionSpecifier` 19- `Transition` 20- `TransitionWithOutcomes` 21- `Capability` 22- `Token` 23- `TokenCount` 24- `Skill` 25- `Level` 26- `MechanismID` 27- `MechanismName` 28- `MechanismState` 29- `CapabilitySet` 30- `DomainFocalization` 31- `FocalPointName` 32- `ContextSpecifier` 33- `FocalPointSpecifier` 34- `FocalContext` 35- `FocalContextName` 36- `State` 37- `RequirementContext` 38- `Effect` 39- `SkillCombination` 40 - `BestSkill` 41 - `WorstSkill` 42 - `CombinedSkill` 43 - `InverseSkill` 44 - `ConditionalSkill` 45- `Challenge` 46- `Condition` 47- `Consequence` 48- `Equivalences` 49- `Requirement` 50 - `ReqAny` 51 - `ReqAll` 52 - `ReqNot` 53 - `ReqCapability` 54 - `ReqTokens` 55 - `ReqMechanism` 56 - `ReqNothing` 57 - `ReqImpossible` 58- `Tag` 59- `TagValueTypes` 60- `TagValue` 61- `NoTagValue` 62- `TagUpdateFunction` 63- `Annotations` 64- `ZoneInfo` 65- `ExplorationActionType` 66- `ExplorationAction` 67- `DecisionType` 68- `Situation` 69- `PointID` 70- `Coords` 71- `AnyPoint` 72- `Feature` 73- `FeatureID` 74- `Part` 75- `FeatureSpecifier` 76- `AnyFeatureSpecifier` 77- `MetricSpace` 78- `FeatureType` 79- `FeatureRelationshipType` 80- `FeatureDecision` 81- `FeatureAffordance` 82- `FeatureEffect` 83- `FeatureAction` 84""" 85 86from typing import ( 87 Any, Optional, List, Set, Union, Iterable, Tuple, Dict, TypedDict, 88 Literal, TypeAlias, TYPE_CHECKING, cast, Callable, get_args, 89 Sequence, NamedTuple, Generator 90) 91 92import copy 93import random 94import re 95import warnings 96import math 97 98from . import commands 99 100# `DecisionGraph` is defined in core.py, but `RequirementContext` needs to 101# be defined here for a couple of reasons. Thankfully, the details of 102# `DecisionGraph` don't matter here for type-checking purposes, so we 103# can provide this fake definition for the type checker: 104if TYPE_CHECKING: 105 from .core import DecisionGraph 106 107 108#---------# 109# Globals # 110#---------# 111 112DEFAULT_DOMAIN: 'Domain' = 'main' 113""" 114Default domain value for use when a domain is needed but not specified. 115""" 116 117DEFAULT_FOCAL_CONTEXT_NAME: 'FocalContextName' = 'main' 118""" 119Default focal context name for use when a focal context name is needed 120but not specified. 121""" 122 123DEFAULT_MECHANISM_STATE: 'MechanismState' = 'off' 124""" 125Default state we assume in situations where a mechanism hasn't been 126assigned a state. 127""" 128 129DEFAULT_EXPLORATION_STATUS: 'ExplorationStatus' = 'noticed' 130""" 131Default exploration status we assume when no exploration status has been 132set. 133""" 134 135DEFAULT_SAVE_SLOT: 'SaveSlot' = "slot0" 136""" 137Default save slot to use when saving or reverting and the slot isn't 138specified. 139""" 140 141 142#------------# 143# Base Types # 144#------------# 145 146Domain: 'TypeAlias' = str 147""" 148A type alias: Domains are identified by their names. 149 150A domain represents a separable sphere of action in a game, such as 151movement in the in-game virtual space vs. movement in a menu (which is 152really just another kind of virtual space). Progress along a quest or tech 153tree can also be modeled as a separate domain. 154 155Conceptually, domains may be either currently-active or 156currently-inactive (e.g., when a menu is open vs. closed, or when 157real-world movement is paused (or not) during menuing; see `State`). Also, 158the game state stores a set of 'active' decision points for each domain. 159At each particular game step, the set of options available to the player 160is the union of all outgoing transitions from active nodes in each active 161domain. 162 163Each decision belongs to a single domain. 164""" 165 166Zone: 'TypeAlias' = str 167""" 168A type alias: A zone as part of a `DecisionGraph` is identified using 169its name. 170 171Zones contain decisions and/or other zones; one zone may be contained by 172multiple other zones, but a zone may not contain itself or otherwise 173form a containment loop. 174 175Note that zone names must be globally unique within a `DecisionGraph`, 176and by extension, two zones with the same name at different steps of a 177`DiscreteExploration` are assumed to represent the same space. 178 179The empty string is used to mean "default zone" in a few places, so it 180should not be used as a real zone name. 181""" 182 183DecisionID: 'TypeAlias' = int 184""" 185A type alias: decision points are defined by arbitrarily assigned 186unique-per-`Exploration` ID numbers. 187 188A decision represents a location within a decision graph where a decision 189can be made about where to go, or a dead-end reached by a previous 190decision. Typically, one room can have multiple decision points in it, 191even though many rooms have only one. Concepts like 'room' and 'area' 192that group multiple decisions together (at various scales) are handled 193by the idea of a `Zone`. 194""" 195 196DecisionName: 'TypeAlias' = str 197""" 198A type alias: decisions have names which are strings. 199 200Two decisions might share the same name, but they can be disambiguated 201because they may be in different `Zone`s, and ultimately, they will have 202different `DecisionID`s. 203""" 204 205 206class DecisionSpecifier(NamedTuple): 207 """ 208 A decision specifier attempts to uniquely identify a decision by 209 name, rather than by ID. See `AnyDecisionSpecifier` for a type which 210 can also be an ID. 211 212 Ambiguity is possible if two decisions share the same name; the 213 decision specifier provides two means of disambiguation: a domain 214 may be specified, and a zone may be specified; if either is 215 specified only decisions within that domain and/or zone will match, 216 but of course there could still be multiple decisions that match 217 those criteria that still share names, in which case many operations 218 will end up raising an `AmbiguousDecisionSpecifierError`. 219 """ 220 domain: Optional[Domain] 221 zone: Optional[Zone] 222 name: DecisionName 223 224 225AnyDecisionSpecifier: 'TypeAlias' = Union[DecisionID, DecisionSpecifier, str] 226""" 227A type alias: Collects three different ways of specifying a decision: by 228ID, by `DecisionSpecifier`, or by a string which will be treated as 229either a `DecisionName`, or as a `DecisionID` if it can be converted to 230an integer. 231""" 232 233 234class InvalidDecisionSpecifierError(ValueError): 235 """ 236 An error used when a decision specifier is in the wrong format. 237 """ 238 239 240class InvalidMechanismSpecifierError(ValueError): 241 """ 242 An error used when a mechanism specifier is invalid. 243 """ 244 245 246Transition: 'TypeAlias' = str 247""" 248A type alias: transitions are defined by their names. 249 250A transition represents a means of travel from one decision to another. 251Outgoing transition names have to be unique at each decision, but not 252globally. 253""" 254 255 256TransitionWithOutcomes: 'TypeAlias' = Tuple[Transition, List[bool]] 257""" 258A type alias: a transition with an outcome attached is a tuple that has 259a `Transition` and then a sequence of booleans indicating 260success/failure of successive challenges attached to that transition. 261Challenges encountered during application of transition effects will each 262have their outcomes dictated by successive booleans in the sequence. If 263the sequence is shorter than the number of challenges encountered, 264additional challenges are resolved according to a `ChallengePolicy` 265specified when applying effects. 266TODO: Implement this, including parsing. 267""" 268 269 270AnyTransition: 'TypeAlias' = Union[Transition, TransitionWithOutcomes] 271""" 272Either a `Transition` or a `TransitionWithOutcomes`. 273""" 274 275 276def nameAndOutcomes(transition: AnyTransition) -> TransitionWithOutcomes: 277 """ 278 Returns a `TransitionWithOutcomes` when given either one of those 279 already or a base `Transition`. Outcomes will be an empty list when 280 given a transition alone. Checks that the type actually matches. 281 """ 282 if isinstance(transition, Transition): 283 return (transition, []) 284 else: 285 if not isinstance(transition, tuple) or len(transition) != 2: 286 raise TypeError( 287 f"Transition with outcomes must be a length-2 tuple." 288 f" Got: {transition!r}" 289 ) 290 name, outcomes = transition 291 if not isinstance(name, Transition): 292 raise TypeError( 293 f"Transition name must be a string." 294 f" Got: {name!r}" 295 ) 296 if ( 297 not isinstance(outcomes, list) 298 or not all(isinstance(x, bool) for x in outcomes) 299 ): 300 raise TypeError( 301 f"Transition outcomes must be a list of booleans." 302 f" Got: {outcomes!r}" 303 ) 304 return transition 305 306 307Capability: 'TypeAlias' = str 308""" 309A type alias: capabilities are defined by their names. 310 311A capability represents the power to traverse certain transitions. These 312transitions should have a `Requirement` specified to indicate which 313capability/ies and/or token(s) can be used to traverse them. Capabilities 314are usually permanent, but may in some cases be temporary or be 315temporarily disabled. Capabilities might also combine (e.g., speed booster 316can't be used underwater until gravity suit is acquired but this is 317modeled through either `Requirement` expressions or equivalences (see 318`DecisionGraph.addEquivalence`). 319 320By convention, a capability whose name starts with '?' indicates a 321capability that the player considers unknown, to be filled in later via 322equivalence. Similarly, by convention capabilities local to a particular 323zone and/or decision will be prefixed with the name of that zone/decision 324and '::' (subject to the restriction that capability names may NOT contain 325the symbols '&', '|', '!', '*', '(', and ')'). Note that in most cases 326zone-local capabilities can instead be `Mechanism`s, which are zone-local 327by default. 328""" 329 330Token: 'TypeAlias' = str 331""" 332A type alias: tokens are defined by their type names. 333 334A token represents an expendable item that can be used to traverse certain 335transitions a limited number of times (normally once after which the 336token is used up), or to permanently open certain transitions (perhaps 337when a certain amount have been acquired). 338 339When a key permanently opens only one specific door, or is re-usable to 340open many doors, that should be represented as a `Capability`, not a 341token. Only when there is a choice of which door to unlock (and the key is 342then used up) should keys be represented as tokens. 343 344Like capabilities, tokens can be unknown (names starting with '?') or may 345be zone- or decision-specific (prefixed with a zone/decision name and 346'::'). Also like capabilities, token names may not contain any of the 347symbols '&', '|', '!', '*', '(', or ')'. 348""" 349 350TokenCount: 'TypeAlias' = int 351""" 352A token count is just an integer. 353""" 354 355Skill: 'TypeAlias' = str 356""" 357Names a skill to be used for a challenge. The agent's skill level along 358with the challenge level determines the probability of success (see 359`Challenge`). When an agent doesn't list a skill at all, the level is 360assumed to be 0. 361""" 362 363 364Level: 'TypeAlias' = int 365""" 366A challenge or skill level is just an integer. 367""" 368 369MechanismID: 'TypeAlias' = int 370""" 371A type alias: mechanism IDs are integers. See `MechanismName` and 372`MechanismState`. 373""" 374 375MechanismName: 'TypeAlias' = str 376""" 377A type alias: mechanism names are strings. See also `MechanismState`. 378 379A mechanism represents something in the world that can be toggled or can 380otherwise change state, and which may alter the requirements for 381transitions and/or actions. For example, a switch that opens and closes 382one or more gates. Mechanisms can be used in `Requirement`s by writing 383"mechanism:state", for example, "switch:on". Each mechanism can only be 384in one of its possible states at a time, so an effect that puts a 385mechanism in one state removes it from all other states. Mechanism states 386can be the subject of equivalences (see `DecisionGraph.addEquivalence`). 387 388Mechanisms have `MechanismID`s and are each associated with a specific 389decision (unless they are global), and when a mechanism name is 390mentioned, we look for the first mechanism with that name at the current 391decision, then in the lowest zone(s) containing that decision, etc. It's 392an error if we find two mechanisms with the same name at the same level 393of search. `DecisionGraph.addMechanism` will create a named mechanism 394and assign it an ID. 395 396By convention, a capability whose name starts with '?' indicates a 397mechanism that the player considers unknown, to be filled in later via 398equivalence. Mechanism names are resolved by searching incrementally 399through higher and higher-level zones, then a global mechanism set and 400finally in all decisions. This means that the same mechanism name can 401potentially be re-used in different zones, especially when all 402transitions which depend on that mechanism's state are within the same 403zone. 404TODO: G: for global scope? 405 406Mechanism states are not tracked as part of `FocalContext`s but are 407instead tracked as part of the `DecisionGraph` itself. If there are 408mechanism-like things which operate on a per-character basis or otherwise 409need to be tracked as part of focal contexts, use decision-local 410`Capability` names to track them instead. 411""" 412 413 414class MechanismSpecifier(NamedTuple): 415 """ 416 Specifies a mechanism either just by name, or with domain and/or 417 zone and/or decision name hints. 418 """ 419 domain: Optional[Domain] 420 zone: Optional[Zone] 421 decision: Optional[DecisionName] 422 name: MechanismName 423 424 425def mechanismAt( 426 name: MechanismName, 427 domain: Optional[Domain] = None, 428 zone: Optional[Zone] = None, 429 decision: Optional[DecisionName] = None 430) -> MechanismSpecifier: 431 """ 432 Builds a `MechanismSpecifier` using `None` default hints but 433 accepting `domain`, `zone`, and/or `decision` hints. 434 """ 435 return MechanismSpecifier(domain, zone, decision, name) 436 437 438AnyMechanismSpecifier: 'TypeAlias' = Union[ 439 MechanismID, 440 MechanismName, 441 MechanismSpecifier 442] 443""" 444Can be a mechanism ID, mechanism name, or a mechanism specifier. 445""" 446 447MechanismState: 'TypeAlias' = str 448""" 449A type alias: the state of a mechanism is a string. See `Mechanism`. 450 451Each mechanism may have any number of states, but may only be in one of 452them at once. Mechanism states may NOT be strings which can be 453converted to integers using `int` because otherwise the 'set' effect 454would have trouble figuring out whether a mechanism or item count was 455being set. 456""" 457 458EffectSpecifier: 'TypeAlias' = Tuple[DecisionID, Transition, int] 459""" 460Identifies a particular effect that's part of a consequence attached to 461a certain transition in a `DecisionGraph`. Identifies the effect based 462on the transition's source `DecisionID` and `Transition` name, plus an 463integer. The integer specifies the index of the effect in depth-first 464traversal order of the consequence of the specified transition. 465 466TODO: Ensure these are updated when merging/deleting/renaming stuff. 467""" 468 469 470class CapabilitySet(TypedDict): 471 """ 472 Represents a set of capabilities, including boolean on/off 473 `Capability` names, countable `Token`s accumulated, and 474 integer-leveled skills. It has three slots: 475 476 - 'capabilities': A set representing which `Capability`s this 477 `CapabilitySet` includes. 478 - 'tokens': A dictionary mapping `Token` types to integers 479 representing how many of that token type this `CapabilitySet` has 480 accumulated. 481 - 'skills': A dictionary mapping `Skill` types to `Level` integers, 482 representing what skill levels this `CapabilitySet` has. 483 """ 484 capabilities: Set[Capability] 485 tokens: Dict[Token, TokenCount] 486 skills: Dict[Skill, Level] 487 488 489DomainFocalization: 'TypeAlias' = Literal[ 490 'singular', 491 'plural', 492 'spreading' 493] 494""" 495How the player experiences decisions in a domain is controlled by 496focalization, which is specific to a `FocalContext` and a `Domain`: 497 498- Typically, focalization is 'singular' and there's a particular avatar 499 (or co-located group of avatars) that the player follows around, at 500 each point making a decision based on the position of that avatar 501 (that avatar is effectively "at" one decision in the graph). Position 502 in a singular domain is represented as a single `DecisionID`. When the 503 player picks a transition, this decision ID is updated to the decision 504 on the other side of that transition. 505- Less commonly, there can be multiple points of focalization which the 506 player can freely switch between, meaning the player can at any given 507 moment decide both which focal point to actually attend to, and what 508 transition to take at that decision. This is called 'plural' 509 focalization, and is common in tactics or strategy games where the 510 player commands multiple units, although those games are often a poor 511 match for decision mapping approaches. Position in a plural domain is 512 represented by a dictionary mapping one or more focal-point name 513 strings to single `DecisionID`s. When the player makes a decision, 514 they need to specify the name of the focal point for which the 515 decision is made along with the transition name at that focal point, 516 and that focal point is updated to the decision on the other side of 517 the chosen transition. 518- Focalization can also be 'spreading' meaning that not only can the 519 player pick options from one of multiple decisions, they also 520 effectively expand the set of available decisions without having to 521 give up access to old ones. This happens for example in a tech tree, 522 where the player can invest some resource to unlock new nodes. 523 Position in a spreading domain is represented by a set of 524 `DecisionID`s, and when a transition is chosen, the decision on the 525 other side is added to the set if it wasn't already present. 526""" 527 528 529FocalPointName: 'TypeAlias' = str 530""" 531The name of a particular focal point in 'plural' `DomainFocalization`. 532""" 533 534 535ContextSpecifier: 'TypeAlias' = Literal["common", "active"] 536""" 537Used when it's necessary to specify whether the common or the active 538`FocalContext` is being referenced and/or updated. 539""" 540 541 542FocalPointSpecifier: 'TypeAlias' = Tuple[ 543 ContextSpecifier, 544 Domain, 545 FocalPointName 546] 547""" 548Specifies a particular focal point by picking out whether it's in the 549common or active context, which domain it's in, and the focal point name 550within that domain. Only needed for domains with 'plural' focalization 551(see `DomainFocalization`). 552""" 553 554 555class FocalContext(TypedDict): 556 """ 557 Focal contexts identify an avatar or similar situation where the player 558 has certain capabilities available (a `CapabilitySet`) and may also have 559 position information in one or more `Domain`s (see `State` and 560 `DomainFocalization`). Normally, only a single `FocalContext` is needed, 561 but situations where the player swaps between capability sets and/or 562 positions sometimes call for more. 563 564 At each decision step, only a single `FocalContext` is active, and the 565 capabilities of that context (plus capabilities of the 'common' 566 context) determine what transitions are traversable. At the same time, 567 the set of reachable transitions is determined by the focal context's 568 per-domain position information, including its per-domain 569 `DomainFocalization` type. 570 571 The slots are: 572 573 - 'capabilities': A `CapabilitySet` representing what capabilities, 574 tokens, and skills this context has. Note that capabilities from 575 the common `FocalContext` are added to these to determine what 576 transition requirements are met in a given step. 577 - 'focalization': A mapping from `Domain`s to `DomainFocalization` 578 specifying how this context is focalized in each domain. 579 - 'activeDomains': A set of `Domain`s indicating which `Domain`(s) are 580 active for this focal context right now. 581 - 'activeDecisions': A mapping from `Domain`s to either single 582 `DecisionID`s, dictionaries mapping `FocalPointName`s to 583 optional `DecisionID`s, or sets of `DecisionID`s. Which one is 584 used depends on the `DomainFocalization` of this context for 585 that domain. May also be `None` for domains in which no 586 decisions are active (and in 'plural'-focalization lists, 587 individual entries may be `None`). Active decisions from the 588 common `FocalContext` are also considered active at each step. 589 """ 590 capabilities: CapabilitySet 591 focalization: Dict[Domain, DomainFocalization] 592 activeDomains: Set[Domain] 593 activeDecisions: Dict[ 594 Domain, 595 Union[ 596 None, 597 DecisionID, 598 Dict[FocalPointName, Optional[DecisionID]], 599 Set[DecisionID] 600 ] 601 ] 602 603 604FocalContextName: 'TypeAlias' = str 605""" 606`FocalContext`s are assigned names are are indexed under those names 607within `State` objects (they don't contain their own name). Note that 608the 'common' focal context does not have a name. 609""" 610 611 612def getDomainFocalization( 613 context: FocalContext, 614 domain: Domain, 615 defaultFocalization: DomainFocalization = 'singular' 616) -> DomainFocalization: 617 """ 618 Fetches the focalization value for the given domain in the given 619 focal context, setting it to the provided default first if that 620 focal context didn't have an entry for that domain yet. 621 """ 622 return context['focalization'].setdefault(domain, defaultFocalization) 623 624 625class State(TypedDict): 626 """ 627 Represents a game state, including certain exploration-relevant 628 information, plus possibly extra custom information. Has the 629 following slots: 630 631 - 'common': A single `FocalContext` containing capability and position 632 information which is always active in addition to the current 633 `FocalContext`'s information. 634 - 'contexts': A dictionary mapping strings to `FocalContext`s, which 635 store capability and position information. 636 - 'activeContext': A string naming the currently-active 637 `FocalContext` (a key of the 'contexts' slot). 638 - 'primaryDecision': A `DecisionID` (or `None`) indicating the 639 primary decision that is being considered in this state. Whereas 640 the focalization structures can and often will indicate multiple 641 active decisions, whichever decision the player just arrived at 642 via the transition selected in a previous state will be the most 643 relevant, and we track that here. Of course, for some states 644 (like a pre-starting initial state) there is no primary 645 decision. 646 - 'mechanisms': A dictionary mapping `Mechanism` IDs to 647 `MechanismState` strings. 648 - 'exploration': A dictionary mapping decision IDs to exploration 649 statuses, which tracks how much knowledge the player has of 650 different decisions. 651 - 'effectCounts': A dictionary mapping `EffectSpecifier`s to 652 integers specifying how many times that effect has been 653 triggered since the beginning of the exploration (including 654 times that the actual effect was not applied due to delays 655 and/or charges. This is used to figure out when effects with 656 charges and/or delays should be applied. 657 - 'deactivated': A set of (`DecisionID`, `Transition`) tuples 658 specifying which transitions have been deactivated. This is used 659 in addition to transition requirements to figure out which 660 transitions are traversable. 661 - 'custom': An arbitrary sub-dictionary representing any kind of 662 custom game state. In most cases, things can be reasonably 663 approximated via capabilities and tokens and custom game state is 664 not needed. 665 """ 666 common: FocalContext 667 contexts: Dict[FocalContextName, FocalContext] 668 activeContext: FocalContextName 669 primaryDecision: Optional[DecisionID] 670 mechanisms: Dict[MechanismID, MechanismState] 671 exploration: Dict[DecisionID, 'ExplorationStatus'] 672 effectCounts: Dict[EffectSpecifier, int] 673 deactivated: Set[Tuple[DecisionID, Transition]] 674 custom: dict 675 676 677#-------------------# 678# Utility Functions # 679#-------------------# 680 681def idOrDecisionSpecifier( 682 ds: DecisionSpecifier 683) -> Union[DecisionSpecifier, int]: 684 """ 685 Given a decision specifier which might use a name that's convertible 686 to an integer ID, returns the appropriate ID if so, and the original 687 decision specifier if not, raising an 688 `InvalidDecisionSpecifierError` if given a specifier with a 689 convertible name that also has other parts. 690 """ 691 try: 692 dID = int(ds.name) 693 except ValueError: 694 return ds 695 696 if ds.domain is None and ds.zone is None: 697 return dID 698 else: 699 raise InvalidDecisionSpecifierError( 700 f"Specifier {ds} has an ID name but also includes" 701 f" domain and/or zone information." 702 ) 703 704 705def spliceDecisionSpecifiers( 706 base: DecisionSpecifier, 707 default: DecisionSpecifier 708) -> DecisionSpecifier: 709 """ 710 Copies domain and/or zone info from the `default` specifier into the 711 `base` specifier, returning a new `DecisionSpecifier` without 712 modifying either argument. Info is only copied where the `base` 713 specifier has a missing value, although if the base specifier has a 714 domain but no zone and the domain is different from that of the 715 default specifier, no zone info is copied. 716 717 For example: 718 719 >>> d1 = DecisionSpecifier('main', 'zone', 'name') 720 >>> d2 = DecisionSpecifier('niam', 'enoz', 'eman') 721 >>> spliceDecisionSpecifiers(d1, d2) 722 DecisionSpecifier(domain='main', zone='zone', name='name') 723 >>> spliceDecisionSpecifiers(d2, d1) 724 DecisionSpecifier(domain='niam', zone='enoz', name='eman') 725 >>> d3 = DecisionSpecifier(None, None, 'three') 726 >>> spliceDecisionSpecifiers(d3, d1) 727 DecisionSpecifier(domain='main', zone='zone', name='three') 728 >>> spliceDecisionSpecifiers(d3, d2) 729 DecisionSpecifier(domain='niam', zone='enoz', name='three') 730 >>> d4 = DecisionSpecifier('niam', None, 'four') 731 >>> spliceDecisionSpecifiers(d4, d1) # diff domain -> no zone 732 DecisionSpecifier(domain='niam', zone=None, name='four') 733 >>> spliceDecisionSpecifiers(d4, d2) # same domian -> copy zone 734 DecisionSpecifier(domain='niam', zone='enoz', name='four') 735 >>> d5 = DecisionSpecifier(None, 'cone', 'five') 736 >>> spliceDecisionSpecifiers(d4, d5) # None domain -> copy zone 737 DecisionSpecifier(domain='niam', zone='cone', name='four') 738 """ 739 newDomain = base.domain 740 if newDomain is None: 741 newDomain = default.domain 742 newZone = base.zone 743 if ( 744 newZone is None 745 and (newDomain == default.domain or default.domain is None) 746 ): 747 newZone = default.zone 748 749 return DecisionSpecifier(domain=newDomain, zone=newZone, name=base.name) 750 751 752def mergeCapabilitySets(A: CapabilitySet, B: CapabilitySet) -> CapabilitySet: 753 """ 754 Merges two capability sets into a new one, where all capabilities in 755 either original set are active, and token counts and skill levels are 756 summed. 757 758 Example: 759 760 >>> cs1 = { 761 ... 'capabilities': {'fly', 'whistle'}, 762 ... 'tokens': {'subway': 3}, 763 ... 'skills': {'agility': 1, 'puzzling': 3}, 764 ... } 765 >>> cs2 = { 766 ... 'capabilities': {'dig', 'fly'}, 767 ... 'tokens': {'subway': 1, 'goat': 2}, 768 ... 'skills': {'agility': -1}, 769 ... } 770 >>> ms = mergeCapabilitySets(cs1, cs2) 771 >>> ms['capabilities'] == {'fly', 'whistle', 'dig'} 772 True 773 >>> ms['tokens'] == {'subway': 4, 'goat': 2} 774 True 775 >>> ms['skills'] == {'agility': 0, 'puzzling': 3} 776 True 777 """ 778 # Set up our result 779 result: CapabilitySet = { 780 'capabilities': set(), 781 'tokens': {}, 782 'skills': {} 783 } 784 785 # Merge capabilities 786 result['capabilities'].update(A['capabilities']) 787 result['capabilities'].update(B['capabilities']) 788 789 # Merge tokens 790 tokensA = A['tokens'] 791 tokensB = B['tokens'] 792 resultTokens = result['tokens'] 793 for tType, val in tokensA.items(): 794 if tType not in resultTokens: 795 resultTokens[tType] = val 796 else: 797 resultTokens[tType] += val 798 for tType, val in tokensB.items(): 799 if tType not in resultTokens: 800 resultTokens[tType] = val 801 else: 802 resultTokens[tType] += val 803 804 # Merge skills 805 skillsA = A['skills'] 806 skillsB = B['skills'] 807 resultSkills = result['skills'] 808 for skill, level in skillsA.items(): 809 if skill not in resultSkills: 810 resultSkills[skill] = level 811 else: 812 resultSkills[skill] += level 813 for skill, level in skillsB.items(): 814 if skill not in resultSkills: 815 resultSkills[skill] = level 816 else: 817 resultSkills[skill] += level 818 819 return result 820 821 822def emptyFocalContext() -> FocalContext: 823 """ 824 Returns a completely empty focal context, which has no capabilities 825 and which has no associated domains. 826 """ 827 return { 828 'capabilities': { 829 'capabilities': set(), 830 'tokens': {}, 831 'skills': {} 832 }, 833 'focalization': {}, 834 'activeDomains': set(), 835 'activeDecisions': {} 836 } 837 838 839def basicFocalContext( 840 domain: Optional[Domain] = None, 841 focalization: DomainFocalization = 'singular' 842): 843 """ 844 Returns a basic focal context, which has no capabilities and which 845 uses the given focalization (default 'singular') for a single 846 domain with the given name (default `DEFAULT_DOMAIN`) which is 847 active but which has no position specified. 848 """ 849 if domain is None: 850 domain = DEFAULT_DOMAIN 851 return { 852 'capabilities': { 853 'capabilities': set(), 854 'tokens': {}, 855 'skills': {} 856 }, 857 'focalization': {domain: focalization}, 858 'activeDomains': {domain}, 859 'activeDecisions': {domain: None} 860 } 861 862 863def emptyState() -> State: 864 """ 865 Returns an empty `State` dictionary. The empty dictionary uses 866 `DEFAULT_FOCAL_CONTEXT_NAME` as the name of the active 867 `FocalContext`. 868 """ 869 return { 870 'common': emptyFocalContext(), 871 'contexts': {DEFAULT_FOCAL_CONTEXT_NAME: basicFocalContext()}, 872 'activeContext': DEFAULT_FOCAL_CONTEXT_NAME, 873 'primaryDecision': None, 874 'mechanisms': {}, 875 'exploration': {}, 876 'effectCounts': {}, 877 'deactivated': set(), 878 'custom': {} 879 } 880 881 882def basicState( 883 context: Optional[FocalContextName] = None, 884 domain: Optional[Domain] = None, 885 focalization: DomainFocalization = 'singular' 886) -> State: 887 """ 888 Returns a `State` dictionary with a newly created single active 889 focal context that uses the given name (default 890 `DEFAULT_FOCAL_CONTEXT_NAME`). This context is created using 891 `basicFocalContext` with the given domain and focalization as 892 arguments (defaults `DEFAULT_DOMAIN` and 'singular'). 893 """ 894 if context is None: 895 context = DEFAULT_FOCAL_CONTEXT_NAME 896 return { 897 'common': emptyFocalContext(), 898 'contexts': {context: basicFocalContext(domain, focalization)}, 899 'activeContext': context, 900 'primaryDecision': None, 901 'mechanisms': {}, 902 'exploration': {}, 903 'effectCounts': {}, 904 'deactivated': set(), 905 'custom': {} 906 } 907 908 909def effectiveCapabilitySet(state: State) -> CapabilitySet: 910 """ 911 Given a `baseTypes.State` object, computes the effective capability 912 set for that state, which merges capabilities and tokens from the 913 common `baseTypes.FocalContext` with those of the active one. 914 915 Returns a `CapabilitySet`. 916 """ 917 # Grab relevant contexts 918 commonContext = state['common'] 919 activeContext = state['contexts'][state['activeContext']] 920 921 # Extract capability dictionaries 922 commonCapabilities = commonContext['capabilities'] 923 activeCapabilities = activeContext['capabilities'] 924 925 return mergeCapabilitySets( 926 commonCapabilities, 927 activeCapabilities 928 ) 929 930 931def combinedDecisionSet(state: State) -> Set[DecisionID]: 932 """ 933 Given a `State` object, computes the active decision set for that 934 state, which is the set of decisions at which the player can make an 935 immediate decision. This depends on the 'common' `FocalContext` as 936 well as the active focal context, and of course each `FocalContext` 937 may specify separate active decisions for different domains, separate 938 sets of active domains, etc. See `FocalContext` and 939 `DomainFocalization` for more details, as well as `activeDecisionSet`. 940 941 Returns a set of `DecisionID`s. 942 """ 943 commonContext = state['common'] 944 activeContext = state['contexts'][state['activeContext']] 945 result = set() 946 for ctx in (commonContext, activeContext): 947 result |= activeDecisionSet(ctx) 948 949 return result 950 951 952def activeDecisionSet(context: FocalContext) -> Set[DecisionID]: 953 """ 954 Given a `FocalContext`, returns the set of all `DecisionID`s which 955 are active in that focal context. This includes only decisions which 956 are in active domains. 957 958 For example: 959 960 >>> fc = emptyFocalContext() 961 >>> activeDecisionSet(fc) 962 set() 963 >>> fc['focalization'] = { 964 ... 'Si': 'singular', 965 ... 'Pl': 'plural', 966 ... 'Sp': 'spreading' 967 ... } 968 >>> fc['activeDomains'] = {'Si'} 969 >>> fc['activeDecisions'] = { 970 ... 'Si': 0, 971 ... 'Pl': {'one': 1, 'two': 2}, 972 ... 'Sp': {3, 4} 973 ... } 974 >>> activeDecisionSet(fc) 975 {0} 976 >>> fc['activeDomains'] = {'Si', 'Pl'} 977 >>> sorted(activeDecisionSet(fc)) 978 [0, 1, 2] 979 >>> fc['activeDomains'] = {'Pl'} 980 >>> sorted(activeDecisionSet(fc)) 981 [1, 2] 982 >>> fc['activeDomains'] = {'Sp'} 983 >>> sorted(activeDecisionSet(fc)) 984 [3, 4] 985 >>> fc['activeDomains'] = {'Si', 'Pl', 'Sp'} 986 >>> sorted(activeDecisionSet(fc)) 987 [0, 1, 2, 3, 4] 988 """ 989 result = set() 990 decisionsMap = context['activeDecisions'] 991 for domain in context['activeDomains']: 992 activeGroup = decisionsMap[domain] 993 if activeGroup is None: 994 pass 995 elif isinstance(activeGroup, DecisionID): 996 result.add(activeGroup) 997 elif isinstance(activeGroup, dict): 998 for x in activeGroup.values(): 999 if x is not None: 1000 result.add(x) 1001 elif isinstance(activeGroup, set): 1002 result.update(activeGroup) 1003 else: 1004 raise TypeError( 1005 f"The FocalContext {repr(context)} has an invalid" 1006 f" active group for domain {repr(domain)}." 1007 f"\nGroup is: {repr(activeGroup)}" 1008 ) 1009 1010 return result 1011 1012 1013ExplorationStatus: 'TypeAlias' = Literal[ 1014 'unknown', 1015 'hypothesized', 1016 'noticed', 1017 'exploring', 1018 'explored', 1019] 1020""" 1021Exploration statuses track what kind of knowledge the player has about a 1022decision. Note that this is independent of whether or not they've 1023visited it. They are one of the following strings: 1024 1025 - 'unknown': Indicates a decision that the player has absolutely no 1026 knowledge of, not even by implication. Normally such decisions 1027 are not part of a decision map, since the player can only write 1028 down what they've at least seen implied. But in cases where you 1029 want to track exploration of a pre-specified decision map, 1030 decisions that are pre-specified but which the player hasn't had 1031 any hint of yet would have this status. 1032 - 'hypothesized': Indicates a decision that the player can 1033 reasonably expect might be there, but which they haven't yet 1034 confirmed. This comes up when, for example, there's a flashback 1035 during which the player explores an older version of an area, 1036 which they then return to in the "present day." In this case, 1037 the player can hypothesize that the area layout will be the 1038 same, although in the end, it could in fact be different. The 1039 entire flashback area can be cloned and the cloned decisions 1040 marked as hypothesized to represent this. Note that this does 1041 NOT apply to decisions which are definitely implied, such as the 1042 decision on the other side of something the player recognizes as 1043 a door. Those kind of decisions should be marked as 'noticed'. 1044 - 'noticed': Indicates a decision that the player assumes will 1045 exist, and/or which the player has been able to observe some 1046 aspects of indirectly, such as in a cutscene. A decision on the 1047 other side of a closed door is in this category, since even 1048 though the player hasn't seen anything about it, they can pretty 1049 reliably assume there will be some decision there. 1050 - 'exploring': Indicates that a player has started to gain some 1051 knowledge of the transitions available at a decision (beyond the 1052 obvious reciprocals for connections to a 'noticed' decision, 1053 usually but not always by having now visited that decision. Even 1054 the most cursory visit should elevate a decision's exploration 1055 level to 'exploring', except perhaps if the visit is in a 1056 cutscene (although that can also count in some cases). 1057 - 'explored': Indicates that the player believes they have 1058 discovered all of the relevant transitions at this decision, and 1059 there is no need for them to explore it further. This notation 1060 should be based on the player's immediate belief, so even if 1061 it's known that the player will later discover another hidden 1062 option at this transition (or even if the options will later 1063 change), unless the player is cognizant of that, it should be 1064 marked as 'explored' as soon as the player believes they've 1065 exhausted observation of transitions. The player does not have 1066 to have explored all of those transitions yet, including 1067 actions, as long as they're satisfied for now that they've found 1068 all of the options available. 1069""" 1070 1071 1072def moreExplored( 1073 a: ExplorationStatus, 1074 b: ExplorationStatus 1075) -> ExplorationStatus: 1076 """ 1077 Returns whichever of the two exploration statuses counts as 'more 1078 explored'. 1079 """ 1080 eArgs = get_args(ExplorationStatus) 1081 try: 1082 aIndex = eArgs.index(a) 1083 except ValueError: 1084 raise ValueError( 1085 f"Status {a!r} is not a valid exploration status. Must be" 1086 f" one of: {eArgs!r}" 1087 ) 1088 try: 1089 bIndex = eArgs.index(b) 1090 except ValueError: 1091 raise ValueError( 1092 f"Status {b!r} is not a valid exploration status. Must be" 1093 f" one of: {eArgs!r}" 1094 ) 1095 if aIndex > bIndex: 1096 return a 1097 else: 1098 return b 1099 1100 1101def statusVisited(status: ExplorationStatus) -> bool: 1102 """ 1103 Returns true or false depending on whether the provided status 1104 indicates a decision has been visited or not. The 'exploring' and 1105 'explored' statuses imply a decision has been visisted, but other 1106 statuses do not. 1107 """ 1108 return status in ('exploring', 'explored') 1109 1110 1111RestoreFCPart: 'TypeAlias' = Literal[ 1112 "capabilities", 1113 "tokens", 1114 "skills", 1115 "positions" 1116] 1117""" 1118Parts of a `FocalContext` that can be restored. Used in `revertedState`. 1119""" 1120 1121RestoreCapabilityPart = Literal["capabilities", "tokens", "skills"] 1122""" 1123Parts of a focal context `CapabilitySet` that can be restored. Used in 1124`revertedState`. 1125""" 1126 1127RestoreFCKey = Literal["focalization", "activeDomains", "activeDecisions"] 1128""" 1129Parts of a `FocalContext` besides the capability set that we can restore. 1130""" 1131 1132RestoreStatePart = Literal["mechanisms", "exploration", "custom"] 1133""" 1134Parts of a State that we can restore besides the `FocalContext` stuff. 1135Doesn't include the stuff covered by the 'effects' restore aspect. See 1136`revertedState` for more. 1137""" 1138 1139 1140def revertedState( 1141 currentStuff: Tuple['DecisionGraph', State], 1142 savedStuff: Tuple['DecisionGraph', State], 1143 revisionAspects: Set[str] 1144) -> Tuple['DecisionGraph', State]: 1145 """ 1146 Given two (graph, state) pairs, as well as a set of reversion aspect 1147 strings, returns a (graph, state) pair representing the reverted 1148 graph and state. The provided graphs and states will not be 1149 modified, and the return value will not include references to them, 1150 so modifying the returned state will not modify the original or 1151 saved states or graphs. 1152 1153 If the `revisionAspects` set is empty, then all aspects except 1154 skills, exploration statuses, and the graph will be reverted. 1155 1156 Note that the reversion process can lead to impossible states if the 1157 wrong combination of reversion aspects is used (e.g., reverting the 1158 graph but not focal context position information might lead to 1159 positions that refer to decisions which do not exist). 1160 1161 Valid reversion aspect strings are: 1162 - "common-capabilities", "common-tokens", "common-skills," 1163 "common-positions" or just "common" for all four. These 1164 control the parts of the common context's `CapabilitySet` 1165 that get reverted, as well as whether the focalization, 1166 active domains, and active decisions get reverted (those 1167 three as "positions"). 1168 - "c-*NAME*-capabilities" as well as -tokens, -skills, 1169 -positions, and without a suffix, where *NAME* is the name of 1170 a specific focal context. 1171 - "all-capabilities" as well as -tokens, -skills, -positions, 1172 and -contexts, reverting the relevant part of all focal 1173 contexts except the common one, with "all-contexts" reverting 1174 every part of all non-common focal contexts. 1175 - "current-capabilities" as well as -tokens, -skills, -positions, 1176 and without a suffix, for the currently-active focal context. 1177 - "primary" which reverts the primary decision (some positions should 1178 also be reverted in this case). 1179 - "mechanisms" which reverts mechanism states. 1180 - "exploration" which reverts the exploration state of decisions 1181 (note that the `DecisionGraph` also stores "unconfirmed" tags 1182 which are NOT affected by a revert unless "graph" is specified). 1183 - "effects" which reverts the record of how many times transition 1184 effects have been triggered, plus whether transitions have 1185 been disabled or not. 1186 - "custom" which reverts custom state. 1187 - "graph" reverts the graph itself (but this is usually not 1188 desired). This will still preserve the next-ID value for 1189 assigning new nodes, so that nodes created in a reverted graph 1190 will not re-use IDs from nodes created before the reversion. 1191 - "-*NAME*" where *NAME* is a custom reversion specification 1192 defined using `core.DecisionGraph.reversionType` and available 1193 in the "current" decision graph (note the dash is required 1194 before the custom name). This allows complex reversion systems 1195 to be set up once and referenced repeatedly. Any strings 1196 specified along with a custom reversion type will revert the 1197 specified state in addition to what the custom reversion type 1198 specifies. 1199 1200 For example: 1201 1202 >>> from . import core 1203 >>> g = core.DecisionGraph.example("simple") # A - B - C triangle 1204 >>> g.setTransitionRequirement('B', 'next', ReqCapability('helmet')) 1205 >>> g.addAction( 1206 ... 'A', 1207 ... 'getHelmet', 1208 ... consequence=[effect(gain='helmet'), effect(deactivate=True)] 1209 ... ) 1210 >>> s0 = basicState() 1211 >>> fc0 = s0['contexts']['main'] 1212 >>> fc0['activeDecisions']['main'] = 0 # A 1213 >>> s1 = basicState() 1214 >>> fc1 = s1['contexts']['main'] 1215 >>> fc1['capabilities']['capabilities'].add('helmet') 1216 >>> fc1['activeDecisions']['main'] = 1 # B 1217 >>> s1['exploration'] = {0: 'explored', 1: 'exploring', 2: 'unknown'} 1218 >>> s1['effectCounts'] = {(0, 'getHelmet', 1): 1} 1219 >>> s1['deactivated'] = {(0, "getHelmet")} 1220 >>> # Basic reversion of everything except graph & exploration 1221 >>> rg, rs = revertedState((g, s1), (g, s0), set()) 1222 >>> rg == g 1223 True 1224 >>> rg is g 1225 False 1226 >>> rs == s0 1227 False 1228 >>> rs is s0 1229 False 1230 >>> rs['contexts'] == s0['contexts'] 1231 True 1232 >>> rs['exploration'] == s1['exploration'] 1233 True 1234 >>> rs['effectCounts'] = s0['effectCounts'] 1235 >>> rs['deactivated'] = s0['deactivated'] 1236 >>> # Reverting capabilities but not position, exploration, or effects 1237 >>> rg, rs = revertedState((g, s1), (g, s0), {"current-capabilities"}) 1238 >>> rg == g 1239 True 1240 >>> rs == s0 or rs == s1 1241 False 1242 >>> s1['contexts']['main']['capabilities']['capabilities'] 1243 {'helmet'} 1244 >>> s0['contexts']['main']['capabilities']['capabilities'] 1245 set() 1246 >>> rs['contexts']['main']['capabilities']['capabilities'] 1247 set() 1248 >>> s1['contexts']['main']['activeDecisions']['main'] 1249 1 1250 >>> s0['contexts']['main']['activeDecisions']['main'] 1251 0 1252 >>> rs['contexts']['main']['activeDecisions']['main'] 1253 1 1254 >>> # Restore position and effects; that's all that wasn't reverted 1255 >>> rs['contexts']['main']['activeDecisions']['main'] = 0 1256 >>> rs['exploration'] = {} 1257 >>> rs['effectCounts'] = {} 1258 >>> rs['deactivated'] = set() 1259 >>> rs == s0 1260 True 1261 >>> # Reverting position but not state 1262 >>> rg, rs = revertedState((g, s1), (g, s0), {"current-positions"}) 1263 >>> rg == g 1264 True 1265 >>> s1['contexts']['main']['capabilities']['capabilities'] 1266 {'helmet'} 1267 >>> s0['contexts']['main']['capabilities']['capabilities'] 1268 set() 1269 >>> rs['contexts']['main']['capabilities']['capabilities'] 1270 {'helmet'} 1271 >>> s1['contexts']['main']['activeDecisions']['main'] 1272 1 1273 >>> s0['contexts']['main']['activeDecisions']['main'] 1274 0 1275 >>> rs['contexts']['main']['activeDecisions']['main'] 1276 0 1277 >>> # Reverting based on specific focal context name 1278 >>> rg2, rs2 = revertedState((g, s1), (g, s0), {"c-main-positions"}) 1279 >>> rg2 == rg 1280 True 1281 >>> rs2 == rs 1282 True 1283 >>> # Test of graph reversion 1284 >>> import copy 1285 >>> g2 = copy.deepcopy(g) 1286 >>> g2.addDecision('D') 1287 3 1288 >>> g2.addTransition(2, 'alt', 'D', 'return') 1289 >>> rg, rs = revertedState((g2, s1), (g, s0), {'graph'}) 1290 >>> rg == g 1291 True 1292 >>> rg is g 1293 False 1294 1295 TODO: More tests for various other reversion aspects 1296 TODO: Implement per-token-type / per-capability / per-mechanism / 1297 per-skill reversion. 1298 """ 1299 # Expand custom references 1300 expandedAspects = set() 1301 queue = list(revisionAspects) 1302 if len(queue) == 0: 1303 queue = [ # don't revert skills, exploration, and graph 1304 "common-capabilities", 1305 "common-tokens", 1306 "common-positions", 1307 "all-capabilities", 1308 "all-tokens", 1309 "all-positions", 1310 "mechanisms", 1311 "primary", 1312 "effects", 1313 "custom" 1314 ] # we do not include "graph" or "exploration" here... 1315 customLookup = currentStuff[0].reversionTypes 1316 while len(queue) > 0: 1317 aspect = queue.pop(0) 1318 if aspect.startswith('-'): 1319 customName = aspect[1:] 1320 if customName not in customLookup: 1321 raise ValueError( 1322 f"Custom reversion type {aspect[1:]!r} is invalid" 1323 f" because that reversion type has not been" 1324 f" defined. Defined types are:" 1325 f"\n{list(customLookup.keys())}" 1326 ) 1327 queue.extend(customLookup[customName]) 1328 else: 1329 expandedAspects.add(aspect) 1330 1331 # Further expand focal-context-part collectives 1332 if "common" in expandedAspects: 1333 expandedAspects.add("common-capabilities") 1334 expandedAspects.add("common-tokens") 1335 expandedAspects.add("common-skills") 1336 expandedAspects.add("common-positions") 1337 1338 if "all-contexts" in expandedAspects: 1339 expandedAspects.add("all-capabilities") 1340 expandedAspects.add("all-tokens") 1341 expandedAspects.add("all-skills") 1342 expandedAspects.add("all-positions") 1343 1344 if "current" in expandedAspects: 1345 expandedAspects.add("current-capabilities") 1346 expandedAspects.add("current-tokens") 1347 expandedAspects.add("current-skills") 1348 expandedAspects.add("current-positions") 1349 1350 # Figure out things to revert that are specific to named focal 1351 # contexts 1352 perFC: Dict[FocalContextName, Set[RestoreFCPart]] = {} 1353 currentFCName = currentStuff[1]['activeContext'] 1354 for aspect in expandedAspects: 1355 # For current- stuff, look up current context name 1356 if aspect.startswith("current"): 1357 found = False 1358 part: RestoreFCPart 1359 for part in get_args(RestoreFCPart): 1360 if aspect == f"current-{part}": 1361 perFC.setdefault(currentFCName, set()).add(part) 1362 found = True 1363 if not found and aspect != "current": 1364 raise RuntimeError(f"Invalid reversion aspect: {aspect!r}") 1365 elif aspect.startswith("c-"): 1366 if aspect.endswith("-capabilities"): 1367 fcName = aspect[2:-13] 1368 perFC.setdefault(fcName, set()).add("capabilities") 1369 elif aspect.endswith("-tokens"): 1370 fcName = aspect[2:-7] 1371 perFC.setdefault(fcName, set()).add("tokens") 1372 elif aspect.endswith("-skills"): 1373 fcName = aspect[2:-7] 1374 perFC.setdefault(fcName, set()).add("skills") 1375 elif aspect.endswith("-positions"): 1376 fcName = aspect[2:-10] 1377 perFC.setdefault(fcName, set()).add("positions") 1378 else: 1379 fcName = aspect[2:] 1380 forThis = perFC.setdefault(fcName, set()) 1381 forThis.add("capabilities") 1382 forThis.add("tokens") 1383 forThis.add("skills") 1384 forThis.add("positions") 1385 1386 currentState = currentStuff[1] 1387 savedState = savedStuff[1] 1388 1389 # Expand all-FC reversions to per-FC entries for each FC in both 1390 # current and prior states 1391 allFCs = set(currentState['contexts']) | set(savedState['contexts']) 1392 for part in get_args(RestoreFCPart): 1393 if f"all-{part}" in expandedAspects: 1394 for fcName in allFCs: 1395 perFC.setdefault(fcName, set()).add(part) 1396 1397 # Revert graph or not 1398 if "graph" in expandedAspects: 1399 resultGraph = copy.deepcopy(savedStuff[0]) 1400 # Patch nextID to avoid spurious ID matches 1401 resultGraph.nextID = currentStuff[0].nextID 1402 else: 1403 resultGraph = copy.deepcopy(currentStuff[0]) 1404 1405 # Start from non-reverted state copy 1406 resultState = copy.deepcopy(currentState) 1407 1408 # Revert primary decision or not 1409 if "primary" in expandedAspects: 1410 resultState['primaryDecision'] = savedState['primaryDecision'] 1411 1412 # Revert specified aspects of the common focal context 1413 savedCommon = savedState['common'] 1414 capKey: RestoreCapabilityPart 1415 for capKey in get_args(RestoreCapabilityPart): 1416 if f"common-{part}" in expandedAspects: 1417 resultState['common']['capabilities'][capKey] = copy.deepcopy( 1418 savedCommon['capabilities'][capKey] 1419 ) 1420 if "common-positions" in expandedAspects: 1421 fcKey: RestoreFCKey 1422 for fcKey in get_args(RestoreFCKey): 1423 resultState['common'][fcKey] = copy.deepcopy(savedCommon[fcKey]) 1424 1425 # Update focal context parts for named focal contexts: 1426 savedContextMap = savedState['contexts'] 1427 for fcName, restore in perFC.items(): 1428 thisFC = resultState['contexts'].setdefault( 1429 fcName, 1430 emptyFocalContext() 1431 ) # Create FC by name if it didn't exist already 1432 thatFC = savedContextMap.get(fcName) 1433 if thatFC is None: # what if it's a new one? 1434 if restore == set(get_args(RestoreFCPart)): 1435 # If we're restoring everything and the context didn't 1436 # exist in the prior state, delete it in the restored 1437 # state 1438 del resultState['contexts'][fcName] 1439 else: 1440 # Otherwise update parts of it to be blank since prior 1441 # state didn't have any info 1442 for part in restore: 1443 if part == "positions": 1444 thisFC['focalization'] = {} 1445 thisFC['activeDomains'] = set() 1446 thisFC['activeDecisions'] = {} 1447 elif part == "capabilities": 1448 thisFC['capabilities'][part] = set() 1449 else: 1450 thisFC['capabilities'][part] = {} 1451 else: # same context existed in saved data; update parts 1452 for part in restore: 1453 if part == "positions": 1454 for fcKey in get_args(RestoreFCKey): # typed above 1455 thisFC[fcKey] = copy.deepcopy(thatFC[fcKey]) 1456 else: 1457 thisFC['capabilities'][part] = copy.deepcopy( 1458 thatFC['capabilities'][part] 1459 ) 1460 1461 # Revert mechanisms, exploration, and/or custom state if specified 1462 statePart: RestoreStatePart 1463 for statePart in get_args(RestoreStatePart): 1464 if statePart in expandedAspects: 1465 resultState[statePart] = copy.deepcopy(savedState[statePart]) 1466 1467 # Revert effect tracking if specified 1468 if "effects" in expandedAspects: 1469 resultState['effectCounts'] = copy.deepcopy( 1470 savedState['effectCounts'] 1471 ) 1472 resultState['deactivated'] = copy.deepcopy(savedState['deactivated']) 1473 1474 return (resultGraph, resultState) 1475 1476 1477#--------------# 1478# Consequences # 1479#--------------# 1480 1481class RequirementContext(NamedTuple): 1482 """ 1483 The context necessary to check whether a requirement is satisfied or 1484 not. Also used for computing effective skill levels for 1485 `SkillCombination`s. Includes a `State` that specifies `Capability` 1486 and `Token` states, a `DecisionGraph` (which includes equivalences), 1487 and a set of `DecisionID`s to use as the starting place for finding 1488 mechanisms by name. 1489 """ 1490 state: State 1491 graph: 'DecisionGraph' 1492 searchFrom: Set[DecisionID] 1493 1494 1495def getSkillLevel(state: State, skill: Skill) -> Level: 1496 """ 1497 Given a `State` and a `Skill`, looks up that skill in both the 1498 common and active `FocalContext`s of the state, and adds those 1499 numbers together to get an effective skill level for that skill. 1500 Note that `SkillCombination`s can be used to set up more complex 1501 logic for skill combinations across different skills; if adding 1502 levels isn't desired between `FocalContext`s, use different skill 1503 names. 1504 1505 If the skill isn't mentioned, the level will count as 0. 1506 """ 1507 commonContext = state['common'] 1508 activeContext = state['contexts'][state['activeContext']] 1509 return ( 1510 commonContext['capabilities']['skills'].get(skill, 0) 1511 + activeContext['capabilities']['skills'].get(skill, 0) 1512 ) 1513 1514 1515SaveSlot: TypeAlias = str 1516 1517 1518EffectType = Literal[ 1519 'gain', 1520 'lose', 1521 'set', 1522 'toggle', 1523 'deactivate', 1524 'edit', 1525 'goto', 1526 'bounce', 1527 'follow', 1528 'save' 1529] 1530""" 1531The types that effects can use. See `Effect` for details. 1532""" 1533 1534AnyEffectValue: TypeAlias = Union[ 1535 Capability, 1536 Tuple[Token, TokenCount], 1537 Tuple[AnyMechanismSpecifier, MechanismState], 1538 Tuple[Literal['skill'], Skill, Level], 1539 Tuple[AnyMechanismSpecifier, List[MechanismState]], 1540 List[Capability], 1541 None, 1542 List[List[commands.Command]], 1543 AnyDecisionSpecifier, 1544 Tuple[AnyDecisionSpecifier, FocalPointName], 1545 Transition, 1546 SaveSlot 1547] 1548""" 1549A union of all possible effect types. 1550""" 1551 1552 1553class Effect(TypedDict): 1554 """ 1555 Represents one effect of a transition on the decision graph and/or 1556 game state. The `type` slot is an `EffectType` that indicates what 1557 type of effect it is, and determines what the `value` slot will hold. 1558 The `charges` slot is normally `None`, but when set to an integer, 1559 the effect will only trigger that many times, subtracting one charge 1560 each time until it reaches 0, after which the effect will remain but 1561 be ignored. The `delay` slot is also normally `None`, but when set to 1562 an integer, the effect won't trigger but will instead subtract one 1563 from the delay until it reaches zero, at which point it will start to 1564 trigger (and use up charges if there are any). The 'applyTo' slot 1565 should be either 'common' or 'active' (a `ContextSpecifier`) and 1566 determines which focal context the effect applies to. 1567 1568 The `value` values for each `type` are: 1569 1570 - `'gain'`: A `Capability`, (`Token`, `TokenCount`) pair, or 1571 ('skill', `Skill`, `Level`) triple indicating a capability 1572 gained, some tokens acquired, or skill levels gained. 1573 - `'lose'`: A `Capability`, (`Token`, `TokenCount`) pair, or 1574 ('skill', `Skill`, `Level`) triple indicating a capability lost, 1575 some tokens spent, or skill levels lost. Note that the literal 1576 string 'skill' is added to disambiguate skills from tokens. 1577 - `'set'`: A (`Token`, `TokenCount`) pair, a (`MechanismSpecifier`, 1578 `MechanismState`) pair, or a ('skill', `Skill`, `Level`) triple 1579 indicating the new number of tokens, new mechanism state, or new 1580 skill level to establish. Ignores the old count/level, unlike 1581 'gain' and 'lose.' 1582 - `'toggle'`: A list of capabilities which will be toggled on one 1583 after the other, toggling the rest off, OR, a tuple containing a 1584 mechanism name followed by a list of states to be set one after 1585 the other. Does not work for tokens or skills. If a `Capability` 1586 list only has one item, it will be toggled on or off depending 1587 on whether the player currently has that capability or not, 1588 otherwise, whichever capability in the toggle list is currently 1589 active will determine which one gets activated next (the 1590 subsequent one in the list, wrapping from the end to the start). 1591 Note that equivalences are NOT considered when determining which 1592 capability to turn on, and ALL capabilities in the toggle list 1593 except the next one to turn on are turned off. Also, if none of 1594 the capabilities in the list is currently activated, the first 1595 one will be. For mechanisms, `DEFAULT_MECHANISM_STATE` will be 1596 used as the default state if only one state is provided, since 1597 mechanisms can't be "not in a state." `Mechanism` toggles 1598 function based on the current mechanism state; if it's not in 1599 the list they set the first given state. 1600 - `'deactivate'`: `None`. When the effect is activated, the 1601 transition it applies on will be added to the deactivated set in 1602 the current state. This effect type ignores the 'applyTo' value 1603 since it does not make changes to a `FocalContext`. 1604 - `'edit'`: A list of lists of `Command`s, with each list to be 1605 applied in succession on every subsequent activation of the 1606 transition (like toggle). These can use extra variables '$@' to 1607 refer to the source decision of the transition the edit effect is 1608 attached to, '$@d' to refer to the destination decision, '$@t' to 1609 refer to the transition, and '$@r' to refer to its reciprocal. 1610 Commands are powerful and might edit more than just the 1611 specified focal context. 1612 TODO: How to handle list-of-lists format? 1613 - `'goto'`: Either an `AnyDecisionSpecifier` specifying where the 1614 player should end up, or an (`AnyDecisionSpecifier`, 1615 `FocalPointName`) specifying both where they should end up and 1616 which focal point in the relevant domain should be moved. If 1617 multiple 'goto' values are present on different effects of a 1618 transition, they each trigger in turn (and e.g., might activate 1619 multiple decision points in a spreading-focalized domain). Every 1620 transition has a destination, so 'goto' is not necessary: use it 1621 only when an attempt to take a transition is diverted (and 1622 normally, in conjunction with 'charges', 'delay', and/or as an 1623 effect that's behind a `Challenge` or `Conditional`). If a goto 1624 specifies a destination in a plural-focalized domain, but does 1625 not include a focal point name, then the focal point which was 1626 taking the transition will be the one to move. If that 1627 information is not available, the first focal point created in 1628 that domain will be moved by default. Note that when using 1629 something other than a destination ID as the 1630 `AnyDecisionSpecifier`, it's up to you to ensure that the 1631 specifier is not ambiguous, otherwise taking the transition will 1632 crash the program. 1633 - `'bounce'`: Value will be `None`. Prevents the normal position 1634 update associated with a transition that this effect applies to. 1635 Normally, a transition should be marked with an appropriate 1636 requirement to prevent access, even in cases where access seems 1637 possible until tested (just add the requirement on a step after 1638 the transition is observed where relevant). However, 'bounce' can 1639 be used in cases where there's a challenge to fail, for example. 1640 `bounce` is redundant with `goto`: if a `goto` effect applies on 1641 a certain transition, the presence or absence of `bounce` on the 1642 same transition is ignored, since the new position will be 1643 specified by the `goto` value anyways. 1644 - `'follow'`: Value will be a `Transition` name. A transition with 1645 that name must exist at the destination of the action, and when 1646 the follow effect triggers, the player will immediately take 1647 that transition (triggering any consequences it has) after 1648 arriving at their normal destination (so the exploration status 1649 of the normal destination will also be updated). This can result 1650 in an infinite loop if two 'follow' effects imply transitions 1651 which trigger each other, so don't do that. 1652 - `'save'`: Value will be a string indicating a save-slot name. 1653 Indicates a save point, which can be returned to using a 1654 'revertTo' `ExplorationAction`. The entire game state and current 1655 graph is recorded, including effects of the current consequence 1656 before, but not after, the 'save' effect. However, the graph 1657 configuration is not restored by default (see 'revert'). A revert 1658 effect may specify only parts of the state to revert. 1659 1660 TODO: 1661 'focus', 1662 'foreground', 1663 'background', 1664 """ 1665 type: EffectType 1666 applyTo: ContextSpecifier 1667 value: AnyEffectValue 1668 charges: Optional[int] 1669 delay: Optional[int] 1670 hidden: bool 1671 1672 1673def effect( 1674 *, 1675 applyTo: ContextSpecifier = 'active', 1676 gain: Optional[Union[ 1677 Capability, 1678 Tuple[Token, TokenCount], 1679 Tuple[Literal['skill'], Skill, Level] 1680 ]] = None, 1681 lose: Optional[Union[ 1682 Capability, 1683 Tuple[Token, TokenCount], 1684 Tuple[Literal['skill'], Skill, Level] 1685 ]] = None, 1686 set: Optional[Union[ 1687 Tuple[Token, TokenCount], 1688 Tuple[AnyMechanismSpecifier, MechanismState], 1689 Tuple[Literal['skill'], Skill, Level] 1690 ]] = None, 1691 toggle: Optional[Union[ 1692 Tuple[AnyMechanismSpecifier, List[MechanismState]], 1693 List[Capability] 1694 ]] = None, 1695 deactivate: Optional[bool] = None, 1696 edit: Optional[List[List[commands.Command]]] = None, 1697 goto: Optional[Union[ 1698 AnyDecisionSpecifier, 1699 Tuple[AnyDecisionSpecifier, FocalPointName] 1700 ]] = None, 1701 bounce: Optional[bool] = None, 1702 follow: Optional[Transition] = None, 1703 save: Optional[SaveSlot] = None, 1704 delay: Optional[int] = None, 1705 charges: Optional[int] = None, 1706 hidden: bool = False 1707) -> Effect: 1708 """ 1709 Factory for a transition effect which includes default values so you 1710 can just specify effect types that are relevant to a particular 1711 situation. You may not supply values for more than one of 1712 gain/lose/set/toggle/deactivate/edit/goto/bounce, since which one 1713 you use determines the effect type. 1714 """ 1715 tCount = len([ 1716 x 1717 for x in ( 1718 gain, 1719 lose, 1720 set, 1721 toggle, 1722 deactivate, 1723 edit, 1724 goto, 1725 bounce, 1726 follow, 1727 save 1728 ) 1729 if x is not None 1730 ]) 1731 if tCount == 0: 1732 raise ValueError( 1733 "You must specify one of gain, lose, set, toggle, deactivate," 1734 " edit, goto, bounce, follow, or save." 1735 ) 1736 elif tCount > 1: 1737 raise ValueError( 1738 f"You may only specify one of gain, lose, set, toggle," 1739 f" deactivate, edit, goto, bounce, follow, or save" 1740 f" (you provided values for {tCount} of those)." 1741 ) 1742 1743 result: Effect = { 1744 'type': 'edit', 1745 'applyTo': applyTo, 1746 'value': [], 1747 'delay': delay, 1748 'charges': charges, 1749 'hidden': hidden 1750 } 1751 1752 if gain is not None: 1753 result['type'] = 'gain' 1754 result['value'] = gain 1755 elif lose is not None: 1756 result['type'] = 'lose' 1757 result['value'] = lose 1758 elif set is not None: 1759 result['type'] = 'set' 1760 if ( 1761 len(set) == 2 1762 and isinstance(set[0], MechanismName) 1763 and isinstance(set[1], MechanismState) 1764 ): 1765 result['value'] = ( 1766 MechanismSpecifier(None, None, None, set[0]), 1767 set[1] 1768 ) 1769 else: 1770 result['value'] = set 1771 elif toggle is not None: 1772 result['type'] = 'toggle' 1773 result['value'] = toggle 1774 elif deactivate is not None: 1775 result['type'] = 'deactivate' 1776 result['value'] = None 1777 elif edit is not None: 1778 result['type'] = 'edit' 1779 result['value'] = edit 1780 elif goto is not None: 1781 result['type'] = 'goto' 1782 result['value'] = goto 1783 elif bounce is not None: 1784 result['type'] = 'bounce' 1785 result['value'] = None 1786 elif follow is not None: 1787 result['type'] = 'follow' 1788 result['value'] = follow 1789 elif save is not None: 1790 result['type'] = 'save' 1791 result['value'] = save 1792 else: 1793 raise RuntimeError( 1794 "No effect specified in effect function & check failed." 1795 ) 1796 1797 return result 1798 1799 1800class SkillCombination: 1801 """ 1802 Represents which skill(s) are used for a `Challenge`, including under 1803 what circumstances different skills might apply using 1804 `Requirement`s. This is an abstract class, use the subclasses 1805 `BestSkill`, `WorstSkill`, `CombinedSkill`, `InverseSkill`, and/or 1806 `ConditionalSkill` to represent a specific situation. To represent a 1807 single required skill, use a `BestSkill` or `CombinedSkill` with 1808 that skill as the only skill. 1809 1810 Use `SkillCombination.effectiveLevel` to figure out the effective 1811 level of the entire requirement in a given situation. Note that 1812 levels from the common and active `FocalContext`s are added together 1813 whenever a specific skill level is referenced. 1814 1815 Some examples: 1816 1817 >>> from . import core 1818 >>> ctx = RequirementContext(emptyState(), core.DecisionGraph(), set()) 1819 >>> ctx.state['common']['capabilities']['skills']['brawn'] = 1 1820 >>> ctx.state['common']['capabilities']['skills']['brains'] = 3 1821 >>> ctx.state['common']['capabilities']['skills']['luck'] = -1 1822 1823 1. To represent using just the 'brains' skill, you would use: 1824 1825 `BestSkill('brains')` 1826 1827 >>> sr = BestSkill('brains') 1828 >>> sr.effectiveLevel(ctx) 1829 3 1830 1831 If a skill isn't listed, its level counts as 0: 1832 1833 >>> sr = BestSkill('agility') 1834 >>> sr.effectiveLevel(ctx) 1835 0 1836 1837 To represent using the higher of 'brains' or 'brawn' you'd use: 1838 1839 `BestSkill('brains', 'brawn')` 1840 1841 >>> sr = BestSkill('brains', 'brawn') 1842 >>> sr.effectiveLevel(ctx) 1843 3 1844 1845 The zero default only applies if an unknown skill is in the mix: 1846 1847 >>> sr = BestSkill('luck') 1848 >>> sr.effectiveLevel(ctx) 1849 -1 1850 >>> sr = BestSkill('luck', 'agility') 1851 >>> sr.effectiveLevel(ctx) 1852 0 1853 1854 2. To represent using the lower of 'brains' or 'brawn' you'd use: 1855 1856 `WorstSkill('brains', 'brawn')` 1857 1858 >>> sr = WorstSkill('brains', 'brawn') 1859 >>> sr.effectiveLevel(ctx) 1860 1 1861 1862 3. To represent using 'brawn' if the focal context has the 'brawny' 1863 capability, but brains if not, use: 1864 1865 ``` 1866 ConditionalSkill( 1867 ReqCapability('brawny'), 1868 'brawn', 1869 'brains' 1870 ) 1871 ``` 1872 1873 >>> sr = ConditionalSkill( 1874 ... ReqCapability('brawny'), 1875 ... 'brawn', 1876 ... 'brains' 1877 ... ) 1878 >>> sr.effectiveLevel(ctx) 1879 3 1880 >>> brawny = copy.deepcopy(ctx) 1881 >>> brawny.state['common']['capabilities']['capabilities'].add( 1882 ... 'brawny' 1883 ... ) 1884 >>> sr.effectiveLevel(brawny) 1885 1 1886 1887 If the player can still choose to use 'brains' even when they 1888 have the 'brawny' capability, you would do: 1889 1890 >>> sr = ConditionalSkill( 1891 ... ReqCapability('brawny'), 1892 ... BestSkill('brawn', 'brains'), 1893 ... 'brains' 1894 ... ) 1895 >>> sr.effectiveLevel(ctx) 1896 3 1897 >>> sr.effectiveLevel(brawny) # can still use brains if better 1898 3 1899 1900 4. To represent using the combined level of the 'brains' and 1901 'brawn' skills, you would use: 1902 1903 `CombinedSkill('brains', 'brawn')` 1904 1905 >>> sr = CombinedSkill('brains', 'brawn') 1906 >>> sr.effectiveLevel(ctx) 1907 4 1908 1909 5. Skill names can be replaced by entire sub-`SkillCombination`s in 1910 any position, so more complex forms are possible: 1911 1912 >>> sr = BestSkill(CombinedSkill('brains', 'luck'), 'brawn') 1913 >>> sr.effectiveLevel(ctx) 1914 2 1915 >>> sr = BestSkill( 1916 ... ConditionalSkill( 1917 ... ReqCapability('brawny'), 1918 ... 'brawn', 1919 ... 'brains', 1920 ... ), 1921 ... CombinedSkill('brains', 'luck') 1922 ... ) 1923 >>> sr.effectiveLevel(ctx) 1924 3 1925 >>> sr.effectiveLevel(brawny) 1926 2 1927 """ 1928 def effectiveLevel(self, context: 'RequirementContext') -> Level: 1929 """ 1930 Returns the effective `Level` of the skill combination, given 1931 the situation specified by the provided `RequirementContext`. 1932 """ 1933 raise NotImplementedError( 1934 "SkillCombination is an abstract class. Use one of its" 1935 " subclsases instead." 1936 ) 1937 1938 def __eq__(self, other: Any) -> bool: 1939 raise NotImplementedError( 1940 "SkillCombination is an abstract class and cannot be compared." 1941 ) 1942 1943 def __hash__(self) -> int: 1944 raise NotImplementedError( 1945 "SkillCombination is an abstract class and cannot be hashed." 1946 ) 1947 1948 def walk(self) -> Generator[ 1949 Union['SkillCombination', Skill, Level], 1950 None, 1951 None 1952 ]: 1953 """ 1954 Yields this combination and each sub-part in depth-first 1955 traversal order. 1956 """ 1957 raise NotImplementedError( 1958 "SkillCombination is an abstract class and cannot be walked." 1959 ) 1960 1961 def unparse(self) -> str: 1962 """ 1963 Returns a string that `SkillCombination.parse` would turn back 1964 into a `SkillCombination` equivalent to this one. For example: 1965 1966 >>> BestSkill('brains').unparse() 1967 'best(brains)' 1968 >>> WorstSkill('brains', 'brawn').unparse() 1969 'worst(brains, brawn)' 1970 >>> CombinedSkill( 1971 ... ConditionalSkill(ReqTokens('orb', 3), 'brains', 0), 1972 ... InverseSkill('luck') 1973 ... ).unparse() 1974 'sum(if(orb*3, brains, 0), ~luck)' 1975 """ 1976 raise NotImplementedError( 1977 "SkillCombination is an abstract class and cannot be" 1978 " unparsed." 1979 ) 1980 1981 1982class BestSkill(SkillCombination): 1983 def __init__( 1984 self, 1985 *skills: Union[SkillCombination, Skill, Level] 1986 ): 1987 """ 1988 Given one or more `SkillCombination` sub-items and/or skill 1989 names or levels, represents a situation where the highest 1990 effective level among the sub-items is used. Skill names 1991 translate to the player's level for that skill (with 0 as a 1992 default) while level numbers translate to that number. 1993 """ 1994 if len(skills) == 0: 1995 raise ValueError( 1996 "Cannot create a `BestSkill` with 0 sub-skills." 1997 ) 1998 self.skills = skills 1999 2000 def __eq__(self, other: Any) -> bool: 2001 return isinstance(other, BestSkill) and other.skills == self.skills 2002 2003 def __hash__(self) -> int: 2004 result = 1829 2005 for sk in self.skills: 2006 result += hash(sk) 2007 return result 2008 2009 def __repr__(self) -> str: 2010 subs = ', '.join(repr(sk) for sk in self.skills) 2011 return "BestSkill(" + subs + ")" 2012 2013 def walk(self) -> Generator[ 2014 Union[SkillCombination, Skill, Level], 2015 None, 2016 None 2017 ]: 2018 yield self 2019 for sub in self.skills: 2020 if isinstance(sub, (Skill, Level)): 2021 yield sub 2022 else: 2023 yield from sub.walk() 2024 2025 def effectiveLevel(self, ctx: 'RequirementContext') -> Level: 2026 """ 2027 Determines the effective level of each sub-skill-combo and 2028 returns the highest of those. 2029 """ 2030 result = None 2031 level: Level 2032 if len(self.skills) == 0: 2033 raise RuntimeError("Invalid BestSkill: has zero sub-skills.") 2034 for sk in self.skills: 2035 if isinstance(sk, Level): 2036 level = sk 2037 elif isinstance(sk, Skill): 2038 level = getSkillLevel(ctx.state, sk) 2039 elif isinstance(sk, SkillCombination): 2040 level = sk.effectiveLevel(ctx) 2041 else: 2042 raise RuntimeError( 2043 f"Invalid BestSkill: found sub-skill '{repr(sk)}'" 2044 f" which is not a skill name string, level integer," 2045 f" or SkillCombination." 2046 ) 2047 if result is None or result < level: 2048 result = level 2049 2050 assert result is not None 2051 return result 2052 2053 def unparse(self): 2054 result = "best(" 2055 for sk in self.skills: 2056 if isinstance(sk, SkillCombination): 2057 result += sk.unparse() 2058 else: 2059 result += str(sk) 2060 result += ', ' 2061 return result[:-2] + ')' 2062 2063 2064class WorstSkill(SkillCombination): 2065 def __init__( 2066 self, 2067 *skills: Union[SkillCombination, Skill, Level] 2068 ): 2069 """ 2070 Given one or more `SkillCombination` sub-items and/or skill 2071 names or levels, represents a situation where the lowest 2072 effective level among the sub-items is used. Skill names 2073 translate to the player's level for that skill (with 0 as a 2074 default) while level numbers translate to that number. 2075 """ 2076 if len(skills) == 0: 2077 raise ValueError( 2078 "Cannot create a `WorstSkill` with 0 sub-skills." 2079 ) 2080 self.skills = skills 2081 2082 def __eq__(self, other: Any) -> bool: 2083 return isinstance(other, WorstSkill) and other.skills == self.skills 2084 2085 def __hash__(self) -> int: 2086 result = 7182 2087 for sk in self.skills: 2088 result += hash(sk) 2089 return result 2090 2091 def __repr__(self) -> str: 2092 subs = ', '.join(repr(sk) for sk in self.skills) 2093 return "WorstSkill(" + subs + ")" 2094 2095 def walk(self) -> Generator[ 2096 Union[SkillCombination, Skill, Level], 2097 None, 2098 None 2099 ]: 2100 yield self 2101 for sub in self.skills: 2102 if isinstance(sub, (Skill, Level)): 2103 yield sub 2104 else: 2105 yield from sub.walk() 2106 2107 def effectiveLevel(self, ctx: 'RequirementContext') -> Level: 2108 """ 2109 Determines the effective level of each sub-skill-combo and 2110 returns the lowest of those. 2111 """ 2112 result = None 2113 level: Level 2114 if len(self.skills) == 0: 2115 raise RuntimeError("Invalid WorstSkill: has zero sub-skills.") 2116 for sk in self.skills: 2117 if isinstance(sk, Level): 2118 level = sk 2119 elif isinstance(sk, Skill): 2120 level = getSkillLevel(ctx.state, sk) 2121 elif isinstance(sk, SkillCombination): 2122 level = sk.effectiveLevel(ctx) 2123 else: 2124 raise RuntimeError( 2125 f"Invalid WorstSkill: found sub-skill '{repr(sk)}'" 2126 f" which is not a skill name string, level integer," 2127 f" or SkillCombination." 2128 ) 2129 if result is None or result > level: 2130 result = level 2131 2132 assert result is not None 2133 return result 2134 2135 def unparse(self): 2136 result = "worst(" 2137 for sk in self.skills: 2138 if isinstance(sk, SkillCombination): 2139 result += sk.unparse() 2140 else: 2141 result += str(sk) 2142 result += ', ' 2143 return result[:-2] + ')' 2144 2145 2146class CombinedSkill(SkillCombination): 2147 def __init__( 2148 self, 2149 *skills: Union[SkillCombination, Skill, Level] 2150 ): 2151 """ 2152 Given one or more `SkillCombination` sub-items and/or skill 2153 names or levels, represents a situation where the sum of the 2154 effective levels of each sub-item is used. Skill names 2155 translate to the player's level for that skill (with 0 as a 2156 default) while level numbers translate to that number. 2157 """ 2158 if len(skills) == 0: 2159 raise ValueError( 2160 "Cannot create a `CombinedSkill` with 0 sub-skills." 2161 ) 2162 self.skills = skills 2163 2164 def __eq__(self, other: Any) -> bool: 2165 return ( 2166 isinstance(other, CombinedSkill) 2167 and other.skills == self.skills 2168 ) 2169 2170 def __hash__(self) -> int: 2171 result = 2871 2172 for sk in self.skills: 2173 result += hash(sk) 2174 return result 2175 2176 def __repr__(self) -> str: 2177 subs = ', '.join(repr(sk) for sk in self.skills) 2178 return "CombinedSkill(" + subs + ")" 2179 2180 def walk(self) -> Generator[ 2181 Union[SkillCombination, Skill, Level], 2182 None, 2183 None 2184 ]: 2185 yield self 2186 for sub in self.skills: 2187 if isinstance(sub, (Skill, Level)): 2188 yield sub 2189 else: 2190 yield from sub.walk() 2191 2192 def effectiveLevel(self, ctx: 'RequirementContext') -> Level: 2193 """ 2194 Determines the effective level of each sub-skill-combo and 2195 returns the sum of those, with 0 as a default. 2196 """ 2197 result = 0 2198 level: Level 2199 if len(self.skills) == 0: 2200 raise RuntimeError( 2201 "Invalid CombinedSkill: has zero sub-skills." 2202 ) 2203 for sk in self.skills: 2204 if isinstance(sk, Level): 2205 level = sk 2206 elif isinstance(sk, Skill): 2207 level = getSkillLevel(ctx.state, sk) 2208 elif isinstance(sk, SkillCombination): 2209 level = sk.effectiveLevel(ctx) 2210 else: 2211 raise RuntimeError( 2212 f"Invalid CombinedSkill: found sub-skill '{repr(sk)}'" 2213 f" which is not a skill name string, level integer," 2214 f" or SkillCombination." 2215 ) 2216 result += level 2217 2218 assert result is not None 2219 return result 2220 2221 def unparse(self): 2222 result = "sum(" 2223 for sk in self.skills: 2224 if isinstance(sk, SkillCombination): 2225 result += sk.unparse() 2226 else: 2227 result += str(sk) 2228 result += ', ' 2229 return result[:-2] + ')' 2230 2231 2232class InverseSkill(SkillCombination): 2233 def __init__( 2234 self, 2235 invert: Union[SkillCombination, Skill, Level] 2236 ): 2237 """ 2238 Represents the effective level of the given `SkillCombination`, 2239 the level of the given `Skill`, or just the provided specific 2240 `Level`, except inverted (multiplied by -1). 2241 """ 2242 self.invert = invert 2243 2244 def __eq__(self, other: Any) -> bool: 2245 return ( 2246 isinstance(other, InverseSkill) 2247 and other.invert == self.invert 2248 ) 2249 2250 def __hash__(self) -> int: 2251 return 3193 + hash(self.invert) 2252 2253 def __repr__(self) -> str: 2254 return "InverseSkill(" + repr(self.invert) + ")" 2255 2256 def walk(self) -> Generator[ 2257 Union[SkillCombination, Skill, Level], 2258 None, 2259 None 2260 ]: 2261 yield self 2262 if isinstance(self.invert, SkillCombination): 2263 yield from self.invert.walk() 2264 else: 2265 yield self.invert 2266 2267 def effectiveLevel(self, ctx: 'RequirementContext') -> Level: 2268 """ 2269 Determines whether the requirement is satisfied or not and then 2270 returns the effective level of either the `ifSatisfied` or 2271 `ifNot` skill combination, as appropriate. 2272 """ 2273 if isinstance(self.invert, Level): 2274 return -self.invert 2275 elif isinstance(self.invert, Skill): 2276 return -getSkillLevel(ctx.state, self.invert) 2277 elif isinstance(self.invert, SkillCombination): 2278 return -self.invert.effectiveLevel(ctx) 2279 else: 2280 raise RuntimeError( 2281 f"Invalid InverseSkill: invert value {repr(self.invert)}" 2282 f" The invert value must be a Level (int), a Skill" 2283 f" (str), or a SkillCombination." 2284 ) 2285 2286 def unparse(self): 2287 # TODO: Move these to `parsing` to avoid hard-coded tokens here? 2288 if isinstance(self.invert, SkillCombination): 2289 return '~' + self.invert.unparse() 2290 else: 2291 return '~' + str(self.invert) 2292 2293 2294class ConditionalSkill(SkillCombination): 2295 def __init__( 2296 self, 2297 requirement: 'Requirement', 2298 ifSatisfied: Union[SkillCombination, Skill, Level], 2299 ifNot: Union[SkillCombination, Skill, Level] = 0 2300 ): 2301 """ 2302 Given a `Requirement` and two different sub-`SkillCombination`s, 2303 which can also be `Skill` names or fixed `Level`s, represents 2304 situations where which skills come into play depends on what 2305 capabilities the player has. In situations where the given 2306 requirement is satisfied, the `ifSatisfied` combination's 2307 effective level is used, and otherwise the `ifNot` level is 2308 used. By default `ifNot` is just the fixed level 0. 2309 """ 2310 self.requirement = requirement 2311 self.ifSatisfied = ifSatisfied 2312 self.ifNot = ifNot 2313 2314 def __eq__(self, other: Any) -> bool: 2315 return ( 2316 isinstance(other, ConditionalSkill) 2317 and other.requirement == self.requirement 2318 and other.ifSatisfied == self.ifSatisfied 2319 and other.ifNot == self.ifNot 2320 ) 2321 2322 def __hash__(self) -> int: 2323 return ( 2324 1278 2325 + hash(self.requirement) 2326 + hash(self.ifSatisfied) 2327 + hash(self.ifNot) 2328 ) 2329 2330 def __repr__(self) -> str: 2331 return ( 2332 "ConditionalSkill(" 2333 + repr(self.requirement) + ", " 2334 + repr(self.ifSatisfied) + ", " 2335 + repr(self.ifNot) 2336 + ")" 2337 ) 2338 2339 def walk(self) -> Generator[ 2340 Union[SkillCombination, Skill, Level], 2341 None, 2342 None 2343 ]: 2344 yield self 2345 for sub in (self.ifSatisfied, self.ifNot): 2346 if isinstance(sub, SkillCombination): 2347 yield from sub.walk() 2348 else: 2349 yield sub 2350 2351 def effectiveLevel(self, ctx: 'RequirementContext') -> Level: 2352 """ 2353 Determines whether the requirement is satisfied or not and then 2354 returns the effective level of either the `ifSatisfied` or 2355 `ifNot` skill combination, as appropriate. 2356 """ 2357 if self.requirement.satisfied(ctx): 2358 use = self.ifSatisfied 2359 sat = True 2360 else: 2361 use = self.ifNot 2362 sat = False 2363 2364 if isinstance(use, Level): 2365 return use 2366 elif isinstance(use, Skill): 2367 return getSkillLevel(ctx.state, use) 2368 elif isinstance(use, SkillCombination): 2369 return use.effectiveLevel(ctx) 2370 else: 2371 raise RuntimeError( 2372 f"Invalid ConditionalSkill: Requirement was" 2373 f" {'not ' if not sat else ''}satisfied, and the" 2374 f" corresponding skill value was not a level, skill, or" 2375 f" SkillCombination: {repr(use)}" 2376 ) 2377 2378 def unparse(self): 2379 result = f"if({self.requirement.unparse()}, " 2380 if isinstance(self.ifSatisfied, SkillCombination): 2381 result += self.ifSatisfied.unparse() 2382 else: 2383 result += str(self.ifSatisfied) 2384 result += ', ' 2385 if isinstance(self.ifNot, SkillCombination): 2386 result += self.ifNot.unparse() 2387 else: 2388 result += str(self.ifNot) 2389 return result + ')' 2390 2391 2392class Challenge(TypedDict): 2393 """ 2394 Represents a random binary decision between two possible outcomes, 2395 only one of which will actually occur. The 'outcome' can be set to 2396 `True` or `False` to represent that the outcome of the challenge has 2397 been observed, or to `None` (the default) to represent a pending 2398 challenge. The chance of 'success' is determined by the associated 2399 skill(s) and the challenge level, although one or both may be 2400 unknown in which case a variable is used in place of a concrete 2401 value. Probabilities that are of the form 1/2**n or (2**n - 1) / 2402 (2**n) can be represented, the specific formula for the chance of 2403 success is for a challenge with a single skill is: 2404 2405 s = interacting entity's skill level in associated skill 2406 c = challenge level 2407 P(success) = { 2408 1 - 1/2**(1 + s - c) if s > c 2409 1/2 if s == c 2410 1/2**(1 + c - s) if c > s 2411 } 2412 2413 This probability formula is equivalent to the following procedure: 2414 2415 1. Flip one coin, plus one additional coin for each level difference 2416 between the skill and challenge levels. 2417 2. If the skill level is equal to or higher than the challenge 2418 level, the outcome is success if any single coin comes up heads. 2419 3. If the skill level is less than the challenge level, then the 2420 outcome is success only if *all* coins come up heads. 2421 4. If the outcome is not success, it is failure. 2422 2423 Multiple skills can be combined into a `SkillCombination`, which can 2424 use the max or min of several skills, add skill levels together, 2425 and/or have skills which are only relevant when a certain 2426 `Requirement` is satisfied. If a challenge has no skills associated 2427 with it, then the player's skill level counts as 0. 2428 2429 The slots are: 2430 2431 - 'skills': A `SkillCombination` that specifies the relevant 2432 skill(s). 2433 - 'level': An integer specifying the level of the challenge. Along 2434 with the appropriate skill level of the interacting entity, this 2435 determines the probability of success or failure. 2436 - 'success': A `Consequence` which will happen when the outcome is 2437 success. Note that since a `Consequence` can be a `Challenge`, 2438 multi-outcome challenges can be represented by chaining multiple 2439 challenges together. 2440 - 'failure': A `Consequence` which will happen when the outcome is 2441 failure. 2442 - 'outcome': The outcome of the challenge: `True` means success, 2443 `False` means failure, and `None` means "not known (yet)." 2444 """ 2445 skills: SkillCombination 2446 level: Level 2447 success: 'Consequence' 2448 failure: 'Consequence' 2449 outcome: Optional[bool] 2450 2451 2452def challenge( 2453 skills: Optional[SkillCombination] = None, 2454 level: Level = 0, 2455 success: Optional['Consequence'] = None, 2456 failure: Optional['Consequence'] = None, 2457 outcome: Optional[bool] = None 2458): 2459 """ 2460 Factory for `Challenge`s, defaults to empty effects for both success 2461 and failure outcomes, so that you can just provide one or the other 2462 if you need to. Skills defaults to an empty list, the level defaults 2463 to 0 and the outcome defaults to `None` which means "not (yet) 2464 known." 2465 """ 2466 if skills is None: 2467 skills = BestSkill(0) 2468 if success is None: 2469 success = [] 2470 if failure is None: 2471 failure = [] 2472 return { 2473 'skills': skills, 2474 'level': level, 2475 'success': success, 2476 'failure': failure, 2477 'outcome': outcome 2478 } 2479 2480 2481class Condition(TypedDict): 2482 """ 2483 Represents a condition over `Capability`, `Token`, and/or `Mechanism` 2484 states which applies to one or more `Effect`s or `Challenge`s as part 2485 of a `Consequence`. If the specified `Requirement` is satisfied, the 2486 included `Consequence` is treated as if it were part of the 2487 `Consequence` that the `Condition` is inside of, if the requirement 2488 is not satisfied, then the internal `Consequence` is skipped and the 2489 alternate consequence is used instead. Either sub-consequence may of 2490 course be an empty list. 2491 """ 2492 condition: 'Requirement' 2493 consequence: 'Consequence' 2494 alternative: 'Consequence' 2495 2496 2497def condition( 2498 condition: 'Requirement', 2499 consequence: 'Consequence', 2500 alternative: Optional['Consequence'] = None 2501): 2502 """ 2503 Factory for conditions that just glues the given requirement, 2504 consequence, and alternative together. The alternative defaults to 2505 an empty list if not specified. 2506 """ 2507 if alternative is None: 2508 alternative = [] 2509 return { 2510 'condition': condition, 2511 'consequence': consequence, 2512 'alternative': alternative 2513 } 2514 2515 2516Consequence: 'TypeAlias' = List[Union[Challenge, Effect, Condition]] 2517""" 2518Represents a theoretical space of consequences that can occur as a 2519result of attempting an action, or as the success or failure outcome for 2520a challenge. It includes multiple effects and/or challenges, and since 2521challenges have consequences as their outcomes, consequences form a tree 2522structure, with `Effect`s as their leaves. Items in a `Consequence` are 2523applied in-order resolving all outcomes and sub-outcomes of a challenge 2524before considering the next item in the top-level consequence. 2525 2526The `Challenge`s in a `Consequence` may have their 'outcome' set to 2527`None` to represent a theoretical challenge, or it may be set to either 2528`True` or `False` to represent an observed outcome. 2529""" 2530 2531 2532ChallengePolicy: 'TypeAlias' = Literal[ 2533 'random', 2534 'mostLikely', 2535 'fewestEffects', 2536 'success', 2537 'failure', 2538 'specified', 2539] 2540""" 2541Specifies how challenges should be resolved. See 2542`observeChallengeOutcomes`. 2543""" 2544 2545 2546#-------------------------------# 2547# Consequence Utility Functions # 2548#-------------------------------# 2549 2550 2551def resetChallengeOutcomes(consequence: Consequence) -> None: 2552 """ 2553 Traverses all sub-consequences of the given consequence, setting the 2554 outcomes of any `Challenge`s it encounters to `None`, to prepare for 2555 a fresh call to `observeChallengeOutcomes`. 2556 2557 Resets all outcomes in every branch, regardless of previous 2558 outcomes. 2559 2560 For example: 2561 2562 >>> from . import core 2563 >>> e = core.emptySituation() 2564 >>> c = challenge( 2565 ... success=[effect(gain=('money', 12))], 2566 ... failure=[effect(lose=('money', 10))] 2567 ... ) # skill defaults to 'luck', level to 0, and outcome to None 2568 >>> c['outcome'] is None # default outcome is None 2569 True 2570 >>> r = observeChallengeOutcomes(e, [c], policy='mostLikely') 2571 >>> r[0]['outcome'] 2572 True 2573 >>> c['outcome'] # original outcome is changed from None 2574 True 2575 >>> r[0] is c 2576 True 2577 >>> resetChallengeOutcomes([c]) 2578 >>> c['outcome'] is None # now has been reset 2579 True 2580 >>> r[0]['outcome'] is None # same object... 2581 True 2582 >>> resetChallengeOutcomes(c) # can't reset just a Challenge 2583 Traceback (most recent call last): 2584 ... 2585 TypeError... 2586 >>> r = observeChallengeOutcomes(e, [c], policy='success') 2587 >>> r[0]['outcome'] 2588 True 2589 >>> r = observeChallengeOutcomes(e, [c], policy='failure') 2590 >>> r[0]['outcome'] # wasn't reset 2591 True 2592 >>> resetChallengeOutcomes([c]) # now reset it 2593 >>> c['outcome'] is None 2594 True 2595 >>> r = observeChallengeOutcomes(e, [c], policy='failure') 2596 >>> r[0]['outcome'] # was reset 2597 False 2598 """ 2599 if not isinstance(consequence, list): 2600 raise TypeError( 2601 f"Invalid consequence: must be a list." 2602 f"\nGot: {repr(consequence)}" 2603 ) 2604 2605 for item in consequence: 2606 if not isinstance(item, dict): 2607 raise TypeError( 2608 f"Invalid consequence: items in the list must be" 2609 f" Effects, Challenges, or Conditions." 2610 f"\nGot item: {repr(item)}" 2611 ) 2612 if 'skills' in item: # must be a Challenge 2613 item = cast(Challenge, item) 2614 item['outcome'] = None 2615 # reset both branches 2616 resetChallengeOutcomes(item['success']) 2617 resetChallengeOutcomes(item['failure']) 2618 2619 elif 'value' in item: # an Effect 2620 continue # Effects don't have sub-outcomes 2621 2622 elif 'condition' in item: # a Condition 2623 item = cast(Condition, item) 2624 resetChallengeOutcomes(item['consequence']) 2625 resetChallengeOutcomes(item['alternative']) 2626 2627 else: # bad dict 2628 raise TypeError( 2629 f"Invalid consequence: items in the list must be" 2630 f" Effects, Challenges, or Conditions (got a dictionary" 2631 f" without 'skills', 'value', or 'condition' keys)." 2632 f"\nGot item: {repr(item)}" 2633 ) 2634 2635 2636def observeChallengeOutcomes( 2637 context: RequirementContext, 2638 consequence: Consequence, 2639 location: Optional[Set[DecisionID]] = None, 2640 policy: ChallengePolicy = 'random', 2641 knownOutcomes: Optional[List[bool]] = None, 2642 makeCopy: bool = False 2643) -> Consequence: 2644 """ 2645 Given a `RequirementContext` (for `Capability`, `Token`, and `Skill` 2646 info as well as equivalences in the `DecisionGraph` and a 2647 search-from location for mechanism names) and a `Conseqeunce` to be 2648 observed, sets the 'outcome' value for each `Challenge` in it to 2649 either `True` or `False` by determining an outcome for each 2650 `Challenge` that's relevant (challenges locked behind unsatisfied 2651 `Condition`s or on untaken branches of other challenges are not 2652 given outcomes). `Challenge`s that already have assigned outcomes 2653 re-use those outcomes, call `resetChallengeOutcomes` beforehand if 2654 you want to re-decide each challenge with a new policy, and use the 2655 'specified' policy if you want to ensure only pre-specified outcomes 2656 are used. 2657 2658 Normally, the return value is just the original `consequence` 2659 object. However, if `makeCopy` is set to `True`, a deep copy is made 2660 and returned, so the original is not modified. One potential problem 2661 with this is that effects will be copied in this process, which 2662 means that if they are applied, things like delays and toggles won't 2663 update properly. `makeCopy` should thus normally not be used. 2664 2665 The 'policy' value can be one of the `ChallengePolicy` values. The 2666 default is 'random', in which case the `random.random` function is 2667 used to determine each outcome, based on the probability derived 2668 from the challenge level and the associated skill level. The other 2669 policies are: 2670 2671 - 'mostLikely': the result of each challenge will be whichever 2672 outcome is more likely, with success always happening instead of 2673 failure when the probabilities are 50/50. 2674 - 'fewestEffects`: whichever combination of outcomes leads to the 2675 fewest total number of effects will be chosen (modulo satisfying 2676 requirements of `Condition`s). Note that there's no estimation 2677 of the severity of effects, just the raw number. Ties in terms 2678 of number of effects are broken towards successes. This policy 2679 involves evaluating all possible outcome combinations to figure 2680 out which one has the fewest effects. 2681 - 'success' or 'failure': all outcomes will either succeed, or 2682 fail, as specified. Note that success/failure may cut off some 2683 challenges, so it's not the case that literally every challenge 2684 will succeed/fail; some may be skipped because of the 2685 specified success/failure of a prior challenge. 2686 - 'specified': all outcomes have already been specified, and those 2687 pre-specified outcomes should be used as-is. 2688 2689 2690 In call cases, outcomes specified via `knownOutcomes` take precedence 2691 over the challenge policy. The `knownOutcomes` list will be emptied 2692 out as this function works, but extra consequences beyond what's 2693 needed will be ignored (and left in the list). 2694 2695 Note that there are limits on the resolution of Python's random 2696 number generation; for challenges with extremely high or low levels 2697 relative to the associated skill(s) where the probability of success 2698 is very close to 1 or 0, there may not actually be any chance of 2699 success/failure at all. Typically you can ignore this, because such 2700 cases should not normally come up in practice, and because the odds 2701 of success/failure in those cases are such that to notice the 2702 missing possibility share you'd have to simulate outcomes a 2703 ridiculous number of times. 2704 2705 TODO: Location examples; move some of these to a separate testing 2706 file. 2707 2708 For example: 2709 2710 >>> random.seed(17) 2711 >>> warnings.filterwarnings('error') 2712 >>> from . import core 2713 >>> e = core.emptySituation() 2714 >>> c = challenge( 2715 ... success=[effect(gain=('money', 12))], 2716 ... failure=[effect(lose=('money', 10))] 2717 ... ) # skill defaults to 'luck', level to 0, and outcome to None 2718 >>> c['outcome'] is None # default outcome is None 2719 True 2720 >>> r = observeChallengeOutcomes(e, [c]) 2721 >>> r[0]['outcome'] 2722 False 2723 >>> c['outcome'] # original outcome is changed from None 2724 False 2725 >>> all( 2726 ... observeChallengeOutcomes(e, [c])[0]['outcome'] is False 2727 ... for i in range(20) 2728 ... ) # no reset -> same outcome 2729 True 2730 >>> resetChallengeOutcomes([c]) 2731 >>> observeChallengeOutcomes(e, [c])[0]['outcome'] # Random after reset 2732 False 2733 >>> resetChallengeOutcomes([c]) 2734 >>> observeChallengeOutcomes(e, [c])[0]['outcome'] # Random after reset 2735 False 2736 >>> resetChallengeOutcomes([c]) 2737 >>> observeChallengeOutcomes(e, [c])[0]['outcome'] # Random after reset 2738 True 2739 >>> observeChallengeOutcomes(e, c) # Can't resolve just a Challenge 2740 Traceback (most recent call last): 2741 ... 2742 TypeError... 2743 >>> allSame = [] 2744 >>> for i in range(20): 2745 ... resetChallengeOutcomes([c]) 2746 ... obs = observeChallengeOutcomes(e, [c, c]) 2747 ... allSame.append(obs[0]['outcome'] == obs[1]['outcome']) 2748 >>> allSame == [True]*20 2749 True 2750 >>> different = [] 2751 >>> for i in range(20): 2752 ... resetChallengeOutcomes([c]) 2753 ... obs = observeChallengeOutcomes(e, [c, copy.deepcopy(c)]) 2754 ... different.append(obs[0]['outcome'] == obs[1]['outcome']) 2755 >>> False in different 2756 True 2757 >>> all( # Tie breaks towards success 2758 ... ( 2759 ... resetChallengeOutcomes([c]), 2760 ... observeChallengeOutcomes(e, [c], policy='mostLikely') 2761 ... )[1][0]['outcome'] is True 2762 ... for i in range(20) 2763 ... ) 2764 True 2765 >>> all( # Tie breaks towards success 2766 ... ( 2767 ... resetChallengeOutcomes([c]), 2768 ... observeChallengeOutcomes(e, [c], policy='fewestEffects') 2769 ... )[1][0]['outcome'] is True 2770 ... for i in range(20) 2771 ... ) 2772 True 2773 >>> all( 2774 ... ( 2775 ... resetChallengeOutcomes([c]), 2776 ... observeChallengeOutcomes(e, [c], policy='success') 2777 ... )[1][0]['outcome'] is True 2778 ... for i in range(20) 2779 ... ) 2780 True 2781 >>> all( 2782 ... ( 2783 ... resetChallengeOutcomes([c]), 2784 ... observeChallengeOutcomes(e, [c], policy='failure') 2785 ... )[1][0]['outcome'] is False 2786 ... for i in range(20) 2787 ... ) 2788 True 2789 >>> c['outcome'] = False # Fix the outcome; now policy is ignored 2790 >>> observeChallengeOutcomes(e, [c], policy='success')[0]['outcome'] 2791 False 2792 >>> c = challenge( 2793 ... skills=BestSkill('charisma'), 2794 ... level=8, 2795 ... success=[ 2796 ... challenge( 2797 ... skills=BestSkill('strength'), 2798 ... success=[effect(gain='winner')] 2799 ... ) 2800 ... ], # level defaults to 0 2801 ... failure=[ 2802 ... challenge( 2803 ... skills=BestSkill('strength'), 2804 ... failure=[effect(gain='loser')] 2805 ... ), 2806 ... effect(gain='sad') 2807 ... ] 2808 ... ) 2809 >>> r = observeChallengeOutcomes(e, [c]) # random 2810 >>> r[0]['outcome'] 2811 False 2812 >>> r[0]['failure'][0]['outcome'] # also random 2813 True 2814 >>> r[0]['success'][0]['outcome'] is None # skipped so not assigned 2815 True 2816 >>> resetChallengeOutcomes([c]) 2817 >>> r2 = observeChallengeOutcomes(e, [c]) # random 2818 >>> r[0]['outcome'] 2819 False 2820 >>> r[0]['success'][0]['outcome'] is None # untaken branch no outcome 2821 True 2822 >>> r[0]['failure'][0]['outcome'] # also random 2823 False 2824 >>> def outcomeList(consequence): 2825 ... 'Lists outcomes from each challenge attempted.' 2826 ... result = [] 2827 ... for item in consequence: 2828 ... if 'skills' in item: 2829 ... result.append(item['outcome']) 2830 ... if item['outcome'] is True: 2831 ... result.extend(outcomeList(item['success'])) 2832 ... elif item['outcome'] is False: 2833 ... result.extend(outcomeList(item['failure'])) 2834 ... else: 2835 ... pass # end here 2836 ... return result 2837 >>> def skilled(**skills): 2838 ... 'Create a clone of our Situation with specific skills.' 2839 ... r = copy.deepcopy(e) 2840 ... r.state['common']['capabilities']['skills'].update(skills) 2841 ... return r 2842 >>> resetChallengeOutcomes([c]) 2843 >>> r = observeChallengeOutcomes( # 'mostLikely' policy 2844 ... skilled(charisma=9, strength=1), 2845 ... [c], 2846 ... policy='mostLikely' 2847 ... ) 2848 >>> outcomeList(r) 2849 [True, True] 2850 >>> resetChallengeOutcomes([c]) 2851 >>> outcomeList(observeChallengeOutcomes( 2852 ... skilled(charisma=7, strength=-1), 2853 ... [c], 2854 ... policy='mostLikely' 2855 ... )) 2856 [False, False] 2857 >>> resetChallengeOutcomes([c]) 2858 >>> outcomeList(observeChallengeOutcomes( 2859 ... skilled(charisma=8, strength=-1), 2860 ... [c], 2861 ... policy='mostLikely' 2862 ... )) 2863 [True, False] 2864 >>> resetChallengeOutcomes([c]) 2865 >>> outcomeList(observeChallengeOutcomes( 2866 ... skilled(charisma=7, strength=0), 2867 ... [c], 2868 ... policy='mostLikely' 2869 ... )) 2870 [False, True] 2871 >>> resetChallengeOutcomes([c]) 2872 >>> outcomeList(observeChallengeOutcomes( 2873 ... skilled(charisma=20, strength=10), 2874 ... [c], 2875 ... policy='mostLikely' 2876 ... )) 2877 [True, True] 2878 >>> resetChallengeOutcomes([c]) 2879 >>> outcomeList(observeChallengeOutcomes( 2880 ... skilled(charisma=-10, strength=-10), 2881 ... [c], 2882 ... policy='mostLikely' 2883 ... )) 2884 [False, False] 2885 >>> resetChallengeOutcomes([c]) 2886 >>> outcomeList(observeChallengeOutcomes( 2887 ... e, 2888 ... [c], 2889 ... policy='fewestEffects' 2890 ... )) 2891 [True, False] 2892 >>> resetChallengeOutcomes([c]) 2893 >>> outcomeList(observeChallengeOutcomes( 2894 ... skilled(charisma=-100, strength=100), 2895 ... [c], 2896 ... policy='fewestEffects' 2897 ... )) # unaffected by stats 2898 [True, False] 2899 >>> resetChallengeOutcomes([c]) 2900 >>> outcomeList(observeChallengeOutcomes(e, [c], policy='success')) 2901 [True, True] 2902 >>> resetChallengeOutcomes([c]) 2903 >>> outcomeList(observeChallengeOutcomes(e, [c], policy='failure')) 2904 [False, False] 2905 >>> cc = copy.deepcopy(c) 2906 >>> resetChallengeOutcomes([cc]) 2907 >>> cc['outcome'] = False 2908 >>> outcomeList(observeChallengeOutcomes( 2909 ... skilled(charisma=10, strength=10), 2910 ... [cc], 2911 ... policy='mostLikely' 2912 ... )) # pre-observed outcome won't be changed 2913 [False, True] 2914 >>> resetChallengeOutcomes([cc]) 2915 >>> cc['outcome'] = False 2916 >>> outcomeList(observeChallengeOutcomes( 2917 ... e, 2918 ... [cc], 2919 ... policy='fewestEffects' 2920 ... )) # pre-observed outcome won't be changed 2921 [False, True] 2922 >>> cc['success'][0]['outcome'] is None # not assigned on other branch 2923 True 2924 >>> resetChallengeOutcomes([cc]) 2925 >>> r = observeChallengeOutcomes(e, [cc], policy='fewestEffects') 2926 >>> r[0] is cc # results are aliases, not clones 2927 True 2928 >>> outcomeList(r) 2929 [True, False] 2930 >>> cc['success'][0]['outcome'] # inner outcome now assigned 2931 False 2932 >>> cc['failure'][0]['outcome'] is None # now this is other branch 2933 True 2934 >>> resetChallengeOutcomes([cc]) 2935 >>> r = observeChallengeOutcomes( 2936 ... e, 2937 ... [cc], 2938 ... policy='fewestEffects', 2939 ... makeCopy=True 2940 ... ) 2941 >>> r[0] is cc # now result is a clone 2942 False 2943 >>> outcomeList(r) 2944 [True, False] 2945 >>> observedEffects(genericContextForSituation(e), r) 2946 [] 2947 >>> r[0]['outcome'] # outcome was assigned 2948 True 2949 >>> cc['outcome'] is None # only to the copy, not to the original 2950 True 2951 >>> cn = [ 2952 ... condition( 2953 ... ReqCapability('boost'), 2954 ... [ 2955 ... challenge(success=[effect(gain=('$', 1))]), 2956 ... effect(gain=('$', 2)) 2957 ... ] 2958 ... ), 2959 ... challenge(failure=[effect(gain=('$', 4))]) 2960 ... ] 2961 >>> o = observeChallengeOutcomes(e, cn, policy='fewestEffects') 2962 >>> # Without 'boost', inner challenge does not get an outcome 2963 >>> o[0]['consequence'][0]['outcome'] is None 2964 True 2965 >>> o[1]['outcome'] # avoids effect 2966 True 2967 >>> hasBoost = copy.deepcopy(e) 2968 >>> hasBoost.state['common']['capabilities']['capabilities'].add('boost') 2969 >>> resetChallengeOutcomes(cn) 2970 >>> o = observeChallengeOutcomes(hasBoost, cn, policy='fewestEffects') 2971 >>> o[0]['consequence'][0]['outcome'] # now assigned an outcome 2972 False 2973 >>> o[1]['outcome'] # avoids effect 2974 True 2975 >>> from . import core 2976 >>> e = core.emptySituation() 2977 >>> c = challenge( 2978 ... skills=BestSkill('skill'), 2979 ... level=4, # very unlikely at level 0 2980 ... success=[], 2981 ... failure=[effect(lose=('money', 10))], 2982 ... outcome=True 2983 ... ) # pre-assigned outcome 2984 >>> c['outcome'] # verify 2985 True 2986 >>> r = observeChallengeOutcomes(e, [c], policy='specified') 2987 >>> r[0]['outcome'] 2988 True 2989 >>> c['outcome'] # original outcome is unchanged 2990 True 2991 >>> c['outcome'] = False # the more likely outcome 2992 >>> r = observeChallengeOutcomes(e, [c], policy='specified') 2993 >>> r[0]['outcome'] # re-uses the new outcome 2994 False 2995 >>> c['outcome'] # outcome is unchanged 2996 False 2997 >>> c['outcome'] = True # change it back 2998 >>> r = observeChallengeOutcomes(e, [c], policy='specified') 2999 >>> r[0]['outcome'] # re-use the outcome again 3000 True 3001 >>> c['outcome'] # outcome is unchanged 3002 True 3003 >>> c['outcome'] = None # set it to no info; will crash 3004 >>> r = observeChallengeOutcomes(e, [c], policy='specified') 3005 Traceback (most recent call last): 3006 ... 3007 ValueError... 3008 >>> warnings.filterwarnings('default') 3009 >>> c['outcome'] is None # same after crash 3010 True 3011 >>> r = observeChallengeOutcomes( 3012 ... e, 3013 ... [c], 3014 ... policy='specified', 3015 ... knownOutcomes=[True] 3016 ... ) 3017 >>> r[0]['outcome'] # picked up known outcome 3018 True 3019 >>> c['outcome'] # outcome is changed 3020 True 3021 >>> resetChallengeOutcomes([c]) 3022 >>> c['outcome'] is None # has been reset 3023 True 3024 >>> r = observeChallengeOutcomes( 3025 ... e, 3026 ... [c], 3027 ... policy='specified', 3028 ... knownOutcomes=[True] 3029 ... ) 3030 >>> c['outcome'] # from known outcomes 3031 True 3032 >>> ko = [False] 3033 >>> r = observeChallengeOutcomes( 3034 ... e, 3035 ... [c], 3036 ... policy='specified', 3037 ... knownOutcomes=ko 3038 ... ) 3039 >>> c['outcome'] # from known outcomes 3040 False 3041 >>> ko # known outcomes list gets used up 3042 [] 3043 >>> ko = [False, False] 3044 >>> r = observeChallengeOutcomes( 3045 ... e, 3046 ... [c], 3047 ... policy='specified', 3048 ... knownOutcomes=ko 3049 ... ) # too many outcomes is an error 3050 >>> ko 3051 [False] 3052 """ 3053 if not isinstance(consequence, list): 3054 raise TypeError( 3055 f"Invalid consequence: must be a list." 3056 f"\nGot: {repr(consequence)}" 3057 ) 3058 3059 if knownOutcomes is None: 3060 knownOutcomes = [] 3061 3062 if makeCopy: 3063 result = copy.deepcopy(consequence) 3064 else: 3065 result = consequence 3066 3067 for item in result: 3068 if not isinstance(item, dict): 3069 raise TypeError( 3070 f"Invalid consequence: items in the list must be" 3071 f" Effects, Challenges, or Conditions." 3072 f"\nGot item: {repr(item)}" 3073 ) 3074 if 'skills' in item: # must be a Challenge 3075 item = cast(Challenge, item) 3076 if len(knownOutcomes) > 0: 3077 item['outcome'] = knownOutcomes.pop(0) 3078 if item['outcome'] is not None: 3079 if item['outcome']: 3080 observeChallengeOutcomes( 3081 context, 3082 item['success'], 3083 location=location, 3084 policy=policy, 3085 knownOutcomes=knownOutcomes, 3086 makeCopy=False 3087 ) 3088 else: 3089 observeChallengeOutcomes( 3090 context, 3091 item['failure'], 3092 location=location, 3093 policy=policy, 3094 knownOutcomes=knownOutcomes, 3095 makeCopy=False 3096 ) 3097 else: # need to assign an outcome 3098 if policy == 'specified': 3099 raise ValueError( 3100 f"Challenge has unspecified outcome so the" 3101 f" 'specified' policy cannot be used when" 3102 f" observing its outcomes:" 3103 f"\n{item}" 3104 ) 3105 level = item['skills'].effectiveLevel(context) 3106 against = item['level'] 3107 if level < against: 3108 p = 1 / (2 ** (1 + against - level)) 3109 else: 3110 p = 1 - (1 / (2 ** (1 + level - against))) 3111 if policy == 'random': 3112 if random.random() < p: # success 3113 item['outcome'] = True 3114 else: 3115 item['outcome'] = False 3116 elif policy == 'mostLikely': 3117 if p >= 0.5: 3118 item['outcome'] = True 3119 else: 3120 item['outcome'] = False 3121 elif policy == 'fewestEffects': 3122 # Resolve copies so we don't affect original 3123 subSuccess = observeChallengeOutcomes( 3124 context, 3125 item['success'], 3126 location=location, 3127 policy=policy, 3128 knownOutcomes=knownOutcomes[:], 3129 makeCopy=True 3130 ) 3131 subFailure = observeChallengeOutcomes( 3132 context, 3133 item['failure'], 3134 location=location, 3135 policy=policy, 3136 knownOutcomes=knownOutcomes[:], 3137 makeCopy=True 3138 ) 3139 if ( 3140 len(observedEffects(context, subSuccess)) 3141 <= len(observedEffects(context, subFailure)) 3142 ): 3143 item['outcome'] = True 3144 else: 3145 item['outcome'] = False 3146 elif policy == 'success': 3147 item['outcome'] = True 3148 elif policy == 'failure': 3149 item['outcome'] = False 3150 3151 # Figure out outcomes for sub-consequence if we don't 3152 # already have them... 3153 if item['outcome'] not in (True, False): 3154 raise TypeError( 3155 f"Challenge has invalid outcome type" 3156 f" {type(item['outcome'])} after observation." 3157 f"\nOutcome value: {repr(item['outcome'])}" 3158 ) 3159 3160 if item['outcome']: 3161 observeChallengeOutcomes( 3162 context, 3163 item['success'], 3164 location=location, 3165 policy=policy, 3166 knownOutcomes=knownOutcomes, 3167 makeCopy=False 3168 ) 3169 else: 3170 observeChallengeOutcomes( 3171 context, 3172 item['failure'], 3173 location=location, 3174 policy=policy, 3175 knownOutcomes=knownOutcomes, 3176 makeCopy=False 3177 ) 3178 3179 elif 'value' in item: 3180 continue # Effects do not need success/failure assigned 3181 3182 elif 'condition' in item: # a Condition 3183 if item['condition'].satisfied(context): 3184 observeChallengeOutcomes( 3185 context, 3186 item['consequence'], 3187 location=location, 3188 policy=policy, 3189 knownOutcomes=knownOutcomes, 3190 makeCopy=False 3191 ) 3192 else: 3193 observeChallengeOutcomes( 3194 context, 3195 item['alternative'], 3196 location=location, 3197 policy=policy, 3198 knownOutcomes=knownOutcomes, 3199 makeCopy=False 3200 ) 3201 3202 else: # bad dict 3203 raise TypeError( 3204 f"Invalid consequence: items in the list must be" 3205 f" Effects, Challenges, or Conditions (got a dictionary" 3206 f" without 'skills', 'value', or 'condition' keys)." 3207 f"\nGot item: {repr(item)}" 3208 ) 3209 3210 # Return copy or original, now with options selected 3211 return result 3212 3213 3214class UnassignedOutcomeWarning(Warning): 3215 """ 3216 A warning issued when asking for observed effects of a `Consequence` 3217 whose `Challenge` outcomes have not been fully assigned. 3218 """ 3219 pass 3220 3221 3222def observedEffects( 3223 context: RequirementContext, 3224 observed: Consequence, 3225 skipWarning=False, 3226 baseIndex: int = 0 3227) -> List[int]: 3228 """ 3229 Given a `Situation` and a `Consequence` whose challenges have 3230 outcomes assigned, returns a tuple containing a list of the 3231 depth-first-indices of each effect to apply. You can use 3232 `consequencePart` to extract the actual `Effect` values from the 3233 consequence based on their indices. 3234 3235 Only effects that actually apply are included, based on the observed 3236 outcomes as well as which `Condition`(s) are met, although charges 3237 and delays for the effects are not taken into account. 3238 3239 `baseIndex` can be set to something other than 0 to start indexing 3240 at that value. Issues an `UnassignedOutcomeWarning` if it encounters 3241 a challenge whose outcome has not been observed, unless 3242 `skipWarning` is set to `True`. In that case, no effects are listed 3243 for outcomes of that challenge. 3244 3245 For example: 3246 3247 >>> from . import core 3248 >>> warnings.filterwarnings('error') 3249 >>> e = core.emptySituation() 3250 >>> def skilled(**skills): 3251 ... 'Create a clone of our FocalContext with specific skills.' 3252 ... r = copy.deepcopy(e) 3253 ... r.state['common']['capabilities']['skills'].update(skills) 3254 ... return r 3255 >>> c = challenge( # index 1 in [c] (index 0 is the outer list) 3256 ... skills=BestSkill('charisma'), 3257 ... level=8, 3258 ... success=[ 3259 ... effect(gain='happy'), # index 3 in [c] 3260 ... challenge( 3261 ... skills=BestSkill('strength'), 3262 ... success=[effect(gain='winner')] # index 6 in [c] 3263 ... # failure is index 7 3264 ... ) # level defaults to 0 3265 ... ], 3266 ... failure=[ 3267 ... challenge( 3268 ... skills=BestSkill('strength'), 3269 ... # success is index 10 3270 ... failure=[effect(gain='loser')] # index 12 in [c] 3271 ... ), 3272 ... effect(gain='sad') # index 13 in [c] 3273 ... ] 3274 ... ) 3275 >>> import pytest 3276 >>> with pytest.warns(UnassignedOutcomeWarning): 3277 ... observedEffects(e, [c]) 3278 [] 3279 >>> with pytest.warns(UnassignedOutcomeWarning): 3280 ... observedEffects(e, [c, c]) 3281 [] 3282 >>> observedEffects(e, [c, c], skipWarning=True) 3283 [] 3284 >>> c['outcome'] = 'invalid value' # must be True, False, or None 3285 >>> observedEffects(e, [c]) 3286 Traceback (most recent call last): 3287 ... 3288 TypeError... 3289 >>> yesYes = skilled(charisma=10, strength=5) 3290 >>> yesNo = skilled(charisma=10, strength=-1) 3291 >>> noYes = skilled(charisma=4, strength=5) 3292 >>> noNo = skilled(charisma=4, strength=-1) 3293 >>> resetChallengeOutcomes([c]) 3294 >>> observedEffects( 3295 ... yesYes, 3296 ... observeChallengeOutcomes(yesYes, [c], policy='mostLikely') 3297 ... ) 3298 [3, 6] 3299 >>> resetChallengeOutcomes([c]) 3300 >>> observedEffects( 3301 ... yesNo, 3302 ... observeChallengeOutcomes(yesNo, [c], policy='mostLikely') 3303 ... ) 3304 [3] 3305 >>> resetChallengeOutcomes([c]) 3306 >>> observedEffects( 3307 ... noYes, 3308 ... observeChallengeOutcomes(noYes, [c], policy='mostLikely') 3309 ... ) 3310 [13] 3311 >>> resetChallengeOutcomes([c]) 3312 >>> observedEffects( 3313 ... noNo, 3314 ... observeChallengeOutcomes(noNo, [c], policy='mostLikely') 3315 ... ) 3316 [12, 13] 3317 >>> warnings.filterwarnings('default') 3318 >>> # known outcomes override policy & pre-specified outcomes 3319 >>> observedEffects( 3320 ... noNo, 3321 ... observeChallengeOutcomes( 3322 ... noNo, 3323 ... [c], 3324 ... policy='mostLikely', 3325 ... knownOutcomes=[True, True]) 3326 ... ) 3327 [3, 6] 3328 >>> observedEffects( 3329 ... yesYes, 3330 ... observeChallengeOutcomes( 3331 ... yesYes, 3332 ... [c], 3333 ... policy='mostLikely', 3334 ... knownOutcomes=[False, False]) 3335 ... ) 3336 [12, 13] 3337 >>> resetChallengeOutcomes([c]) 3338 >>> observedEffects( 3339 ... yesYes, 3340 ... observeChallengeOutcomes( 3341 ... yesYes, 3342 ... [c], 3343 ... policy='mostLikely', 3344 ... knownOutcomes=[False, False]) 3345 ... ) 3346 [12, 13] 3347 """ 3348 result: List[int] = [] 3349 totalCount: int = baseIndex + 1 # +1 for the outer list 3350 if not isinstance(observed, list): 3351 raise TypeError( 3352 f"Invalid consequence: must be a list." 3353 f"\nGot: {repr(observed)}" 3354 ) 3355 for item in observed: 3356 if not isinstance(item, dict): 3357 raise TypeError( 3358 f"Invalid consequence: items in the list must be" 3359 f" Effects, Challenges, or Conditions." 3360 f"\nGot item: {repr(item)}" 3361 ) 3362 3363 if 'skills' in item: # must be a Challenge 3364 item = cast(Challenge, item) 3365 succeeded = item['outcome'] 3366 useCh: Optional[Literal['success', 'failure']] 3367 if succeeded is True: 3368 useCh = 'success' 3369 elif succeeded is False: 3370 useCh = 'failure' 3371 else: 3372 useCh = None 3373 level = item["level"] 3374 if succeeded is not None: 3375 raise TypeError( 3376 f"Invalid outcome for level-{level} challenge:" 3377 f" should be True, False, or None, but got:" 3378 f" {repr(succeeded)}" 3379 ) 3380 else: 3381 if not skipWarning: 3382 warnings.warn( 3383 ( 3384 f"A level-{level} challenge in the" 3385 f" consequence being observed has no" 3386 f" observed outcome; no effects from" 3387 f" either success or failure branches" 3388 f" will be included. Use" 3389 f" observeChallengeOutcomes to fill in" 3390 f" unobserved outcomes." 3391 ), 3392 UnassignedOutcomeWarning 3393 ) 3394 3395 if useCh is not None: 3396 skipped = 0 3397 if useCh == 'failure': 3398 skipped = countParts(item['success']) 3399 subEffects = observedEffects( 3400 context, 3401 item[useCh], 3402 skipWarning=skipWarning, 3403 baseIndex=totalCount + skipped + 1 3404 ) 3405 result.extend(subEffects) 3406 3407 # TODO: Go back to returning tuples but fix counts to include 3408 # skipped stuff; this is horribly inefficient :( 3409 totalCount += countParts(item) 3410 3411 elif 'value' in item: # an effect, not a challenge 3412 item = cast(Effect, item) 3413 result.append(totalCount) 3414 totalCount += 1 3415 3416 elif 'condition' in item: # a Condition 3417 item = cast(Condition, item) 3418 useCo: Literal['consequence', 'alternative'] 3419 if item['condition'].satisfied(context): 3420 useCo = 'consequence' 3421 skipped = 0 3422 else: 3423 useCo = 'alternative' 3424 skipped = countParts(item['consequence']) 3425 subEffects = observedEffects( 3426 context, 3427 item[useCo], 3428 skipWarning=skipWarning, 3429 baseIndex=totalCount + skipped + 1 3430 ) 3431 result.extend(subEffects) 3432 totalCount += countParts(item) 3433 3434 else: # bad dict 3435 raise TypeError( 3436 f"Invalid consequence: items in the list must be" 3437 f" Effects, Challenges, or Conditions (got a dictionary" 3438 f" without 'skills', 'value', or 'condition' keys)." 3439 f"\nGot item: {repr(item)}" 3440 ) 3441 3442 return result 3443 3444 3445#--------------# 3446# Requirements # 3447#--------------# 3448 3449MECHANISM_STATE_SUFFIX_RE = re.compile('(.*)(?<!:):([^:]+)$') 3450""" 3451Regular expression for finding mechanism state suffixes. These are a 3452single colon followed by any amount of non-colon characters until the 3453end of a token. 3454""" 3455 3456 3457class Requirement: 3458 """ 3459 Represents a precondition for traversing an edge or taking an action. 3460 This can be any boolean expression over `Capability`, mechanism (see 3461 `MechanismName`), and/or `Token` states that must obtain, with 3462 numerical values for the number of tokens required, and specific 3463 mechanism states or active capabilities necessary. For example, if 3464 the player needs either the wall-break capability or the wall-jump 3465 capability plus a balloon token, or for the switch mechanism to be 3466 on, you could represent that using: 3467 3468 ReqAny( 3469 ReqCapability('wall-break'), 3470 ReqAll( 3471 ReqCapability('wall-jump'), 3472 ReqTokens('balloon', 1) 3473 ), 3474 ReqMechanism('switch', 'on') 3475 ) 3476 3477 The subclasses define concrete requirements. 3478 3479 Note that mechanism names are searched for using `lookupMechanism`, 3480 starting from the `DecisionID`s of the decisions on either end of 3481 the transition where a requirement is being checked. You may need to 3482 rename mechanisms to avoid a `MechanismCollisionError`if decisions 3483 on either end of a transition use the same mechanism name. 3484 """ 3485 def satisfied( 3486 self, 3487 context: RequirementContext, 3488 dontRecurse: Optional[ 3489 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3490 ] = None 3491 ) -> bool: 3492 """ 3493 This will return `True` if the requirement is satisfied in the 3494 given `RequirementContext`, resolving mechanisms from the 3495 context's set of decisions and graph, and respecting the 3496 context's equivalences. It returns `False` otherwise. 3497 3498 The `dontRecurse` set should be unspecified to start, and will 3499 be used to avoid infinite recursion in cases of circular 3500 equivalences (requirements are not considered satisfied by 3501 equivalence loops). 3502 3503 TODO: Examples 3504 """ 3505 raise NotImplementedError( 3506 "Requirement is an abstract class and cannot be" 3507 " used directly." 3508 ) 3509 3510 def __eq__(self, other: Any) -> bool: 3511 raise NotImplementedError( 3512 "Requirement is an abstract class and cannot be compared." 3513 ) 3514 3515 def __hash__(self) -> int: 3516 raise NotImplementedError( 3517 "Requirement is an abstract class and cannot be hashed." 3518 ) 3519 3520 def walk(self) -> Generator['Requirement', None, None]: 3521 """ 3522 Yields every part of the requirement in depth-first traversal 3523 order. 3524 """ 3525 raise NotImplementedError( 3526 "Requirement is an abstract class and cannot be walked." 3527 ) 3528 3529 def asEffectList(self) -> List[Effect]: 3530 """ 3531 Transforms this `Requirement` into a list of `Effect` 3532 objects that gain the `Capability`, set the `Token` amounts, and 3533 set the `Mechanism` states mentioned by the requirement. The 3534 requirement must be either a `ReqTokens`, a `ReqCapability`, a 3535 `ReqMechanism`, or a `ReqAll` which includes nothing besides 3536 those types as sub-requirements. The token and capability 3537 requirements at the leaves of the tree will be collected into a 3538 list for the result (note that whether `ReqAny` or `ReqAll` is 3539 used is ignored, all of the tokens/capabilities/mechanisms 3540 mentioned are listed). For each `Capability` requirement a 3541 'gain' effect for that capability will be included. For each 3542 `Mechanism` or `Token` requirement, a 'set' effect for that 3543 mechanism state or token count will be included. Note that if 3544 the requirement has contradictory clauses (e.g., two different 3545 mechanism states) multiple effects which cancel each other out 3546 will be included. Also note that setting token amounts may end 3547 up decreasing them unnecessarily. 3548 3549 Raises a `TypeError` if this requirement is not suitable for 3550 transformation into an effect list. 3551 """ 3552 raise NotImplementedError("Requirement is an abstract class.") 3553 3554 def flatten(self) -> 'Requirement': 3555 """ 3556 Returns a simplified version of this requirement that merges 3557 multiple redundant layers of `ReqAny`/`ReqAll` into single 3558 `ReqAny`/`ReqAll` structures, including recursively. May return 3559 the original requirement if there's no simplification to be done. 3560 3561 Default implementation just returns `self`. 3562 """ 3563 return self 3564 3565 def unparse(self) -> str: 3566 """ 3567 Returns a string which would convert back into this `Requirement` 3568 object if you fed it to `parsing.ParseFormat.parseRequirement`. 3569 3570 TODO: Move this over into `parsing`? 3571 3572 Examples: 3573 3574 >>> r = ReqAny([ 3575 ... ReqCapability('capability'), 3576 ... ReqTokens('token', 3), 3577 ... ReqMechanism('mechanism', 'state') 3578 ... ]) 3579 >>> rep = r.unparse() 3580 >>> rep 3581 '(capability|token*3|mechanism:state)' 3582 >>> from . import parsing 3583 >>> pf = parsing.ParseFormat() 3584 >>> back = pf.parseRequirement(rep) 3585 >>> back == r 3586 True 3587 >>> ReqNot(ReqNothing()).unparse() 3588 '!(O)' 3589 >>> ReqImpossible().unparse() 3590 'X' 3591 >>> r = ReqAny([ReqCapability('A'), ReqCapability('B'), 3592 ... ReqCapability('C')]) 3593 >>> rep = r.unparse() 3594 >>> rep 3595 '(A|B|C)' 3596 >>> back = pf.parseRequirement(rep) 3597 >>> back == r 3598 True 3599 """ 3600 raise NotImplementedError("Requirement is an abstract class.") 3601 3602 3603class ReqAny(Requirement): 3604 """ 3605 A disjunction requirement satisfied when any one of its 3606 sub-requirements is satisfied. 3607 """ 3608 def __init__(self, subs: Iterable[Requirement]) -> None: 3609 self.subs = list(subs) 3610 3611 def __hash__(self) -> int: 3612 result = 179843 3613 for sub in self.subs: 3614 result = 31 * (result + hash(sub)) 3615 return result 3616 3617 def __eq__(self, other: Any) -> bool: 3618 return isinstance(other, ReqAny) and other.subs == self.subs 3619 3620 def __repr__(self): 3621 return "ReqAny(" + repr(self.subs) + ")" 3622 3623 def satisfied( 3624 self, 3625 context: RequirementContext, 3626 dontRecurse: Optional[ 3627 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3628 ] = None 3629 ) -> bool: 3630 """ 3631 True as long as any one of the sub-requirements is satisfied. 3632 """ 3633 return any( 3634 sub.satisfied(context, dontRecurse) 3635 for sub in self.subs 3636 ) 3637 3638 def walk(self) -> Generator[Requirement, None, None]: 3639 yield self 3640 for sub in self.subs: 3641 yield from sub.walk() 3642 3643 def asEffectList(self) -> List[Effect]: 3644 """ 3645 Raises a `TypeError` since disjunctions don't have a translation 3646 into a simple list of effects to satisfy them. 3647 """ 3648 raise TypeError( 3649 "Cannot convert ReqAny into an effect list:" 3650 " contradictory token or mechanism requirements on" 3651 " different branches are not easy to synthesize." 3652 ) 3653 3654 def flatten(self) -> Requirement: 3655 """ 3656 Flattens this requirement by merging any sub-requirements which 3657 are also `ReqAny` instances into this one. 3658 """ 3659 merged = [] 3660 for sub in self.subs: 3661 flat = sub.flatten() 3662 if isinstance(flat, ReqAny): 3663 merged.extend(flat.subs) 3664 else: 3665 merged.append(flat) 3666 3667 return ReqAny(merged) 3668 3669 def unparse(self) -> str: 3670 return '(' + '|'.join(sub.unparse() for sub in self.subs) + ')' 3671 3672 3673class ReqAll(Requirement): 3674 """ 3675 A conjunction requirement satisfied when all of its sub-requirements 3676 are satisfied. 3677 """ 3678 def __init__(self, subs: Iterable[Requirement]) -> None: 3679 self.subs = list(subs) 3680 3681 def __hash__(self) -> int: 3682 result = 182971 3683 for sub in self.subs: 3684 result = 17 * (result + hash(sub)) 3685 return result 3686 3687 def __eq__(self, other: Any) -> bool: 3688 return isinstance(other, ReqAll) and other.subs == self.subs 3689 3690 def __repr__(self): 3691 return "ReqAll(" + repr(self.subs) + ")" 3692 3693 def satisfied( 3694 self, 3695 context: RequirementContext, 3696 dontRecurse: Optional[ 3697 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3698 ] = None 3699 ) -> bool: 3700 """ 3701 True as long as all of the sub-requirements are satisfied. 3702 """ 3703 return all( 3704 sub.satisfied(context, dontRecurse) 3705 for sub in self.subs 3706 ) 3707 3708 def walk(self) -> Generator[Requirement, None, None]: 3709 yield self 3710 for sub in self.subs: 3711 yield from sub.walk() 3712 3713 def asEffectList(self) -> List[Effect]: 3714 """ 3715 Returns a gain list composed by adding together the gain lists 3716 for each sub-requirement. Note that some types of requirement 3717 will raise a `TypeError` during this process if they appear as a 3718 sub-requirement. 3719 """ 3720 result = [] 3721 for sub in self.subs: 3722 result += sub.asEffectList() 3723 3724 return result 3725 3726 def flatten(self) -> Requirement: 3727 """ 3728 Flattens this requirement by merging any sub-requirements which 3729 are also `ReqAll` instances into this one. 3730 """ 3731 merged = [] 3732 for sub in self.subs: 3733 flat = sub.flatten() 3734 if isinstance(flat, ReqAll): 3735 merged.extend(flat.subs) 3736 else: 3737 merged.append(flat) 3738 3739 return ReqAll(merged) 3740 3741 def unparse(self) -> str: 3742 return '(' + '&'.join(sub.unparse() for sub in self.subs) + ')' 3743 3744 3745class ReqNot(Requirement): 3746 """ 3747 A negation requirement satisfied when its sub-requirement is NOT 3748 satisfied. 3749 """ 3750 def __init__(self, sub: Requirement) -> None: 3751 self.sub = sub 3752 3753 def __hash__(self) -> int: 3754 return 17293 + hash(self.sub) 3755 3756 def __eq__(self, other: Any) -> bool: 3757 return isinstance(other, ReqNot) and other.sub == self.sub 3758 3759 def __repr__(self): 3760 return "ReqNot(" + repr(self.sub) + ")" 3761 3762 def satisfied( 3763 self, 3764 context: RequirementContext, 3765 dontRecurse: Optional[ 3766 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3767 ] = None 3768 ) -> bool: 3769 """ 3770 True as long as the sub-requirement is not satisfied. 3771 """ 3772 return not self.sub.satisfied(context, dontRecurse) 3773 3774 def walk(self) -> Generator[Requirement, None, None]: 3775 yield self 3776 yield self.sub 3777 3778 def asEffectList(self) -> List[Effect]: 3779 """ 3780 Raises a `TypeError` since understanding a `ReqNot` in terms of 3781 capabilities/tokens to be gained is not straightforward, and would 3782 need to be done relative to a game state in any case. 3783 """ 3784 raise TypeError( 3785 "Cannot convert ReqNot into an effect list:" 3786 " capabilities or tokens would have to be lost, not gained to" 3787 " satisfy this requirement." 3788 ) 3789 3790 def flatten(self) -> Requirement: 3791 return ReqNot(self.sub.flatten()) 3792 3793 def unparse(self) -> str: 3794 return '!(' + self.sub.unparse() + ')' 3795 3796 3797class ReqCapability(Requirement): 3798 """ 3799 A capability requirement is satisfied if the specified capability is 3800 possessed by the player according to the given state. 3801 """ 3802 def __init__(self, capability: Capability) -> None: 3803 self.capability = capability 3804 3805 def __hash__(self) -> int: 3806 return 47923 + hash(self.capability) 3807 3808 def __eq__(self, other: Any) -> bool: 3809 return ( 3810 isinstance(other, ReqCapability) 3811 and other.capability == self.capability 3812 ) 3813 3814 def __repr__(self): 3815 return "ReqCapability(" + repr(self.capability) + ")" 3816 3817 def satisfied( 3818 self, 3819 context: RequirementContext, 3820 dontRecurse: Optional[ 3821 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3822 ] = None 3823 ) -> bool: 3824 return hasCapabilityOrEquivalent( 3825 self.capability, 3826 context, 3827 dontRecurse 3828 ) 3829 3830 def walk(self) -> Generator[Requirement, None, None]: 3831 yield self 3832 3833 def asEffectList(self) -> List[Effect]: 3834 """ 3835 Returns a list containing a single 'gain' effect which grants 3836 the required capability. 3837 """ 3838 return [effect(gain=self.capability)] 3839 3840 def unparse(self) -> str: 3841 return self.capability 3842 3843 3844class ReqTokens(Requirement): 3845 """ 3846 A token requirement satisfied if the player possesses at least a 3847 certain number of a given type of token. 3848 3849 Note that checking the satisfaction of individual doors in a specific 3850 state is not enough to guarantee they're jointly traversable, since 3851 if a series of doors requires the same kind of token and they use up 3852 those tokens, further logic is needed to understand that as the 3853 tokens get used up, their requirements may no longer be satisfied. 3854 3855 Also note that a requirement for tokens does NOT mean that tokens 3856 will be subtracted when traversing the door (you can have re-usable 3857 tokens after all). To implement a token cost, use both a requirement 3858 and a 'lose' effect. 3859 """ 3860 def __init__(self, tokenType: Token, cost: TokenCount) -> None: 3861 self.tokenType = tokenType 3862 self.cost = cost 3863 3864 def __hash__(self) -> int: 3865 return (17 * hash(self.tokenType)) + (11 * self.cost) 3866 3867 def __eq__(self, other: Any) -> bool: 3868 return ( 3869 isinstance(other, ReqTokens) 3870 and other.tokenType == self.tokenType 3871 and other.cost == self.cost 3872 ) 3873 3874 def __repr__(self): 3875 return f"ReqTokens({repr(self.tokenType)}, {repr(self.cost)})" 3876 3877 def satisfied( 3878 self, 3879 context: RequirementContext, 3880 dontRecurse: Optional[ 3881 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3882 ] = None 3883 ) -> bool: 3884 return combinedTokenCount(context.state, self.tokenType) >= self.cost 3885 3886 def walk(self) -> Generator[Requirement, None, None]: 3887 yield self 3888 3889 def asEffectList(self) -> List[Effect]: 3890 """ 3891 Returns a list containing a single 'set' effect which sets the 3892 required tokens (note that this may unnecessarily subtract 3893 tokens if the state had more than enough tokens beforehand). 3894 """ 3895 return [effect(set=(self.tokenType, self.cost))] 3896 3897 def unparse(self) -> str: 3898 return f'{self.tokenType}*{self.cost}' 3899 3900 3901class ReqMechanism(Requirement): 3902 """ 3903 A mechanism requirement satisfied if the specified mechanism is in 3904 the specified state. The mechanism is specified by name and a lookup 3905 on that name will be performed when assessing the requirement, based 3906 on the specific position at which the requirement applies. However, 3907 if a `where` value is supplied, the lookup on the mechanism name will 3908 always start from that decision, regardless of where the requirement 3909 is being evaluated. 3910 """ 3911 def __init__( 3912 self, 3913 mechanism: AnyMechanismSpecifier, 3914 state: MechanismState, 3915 ) -> None: 3916 self.mechanism = mechanism 3917 self.reqState = state 3918 3919 # Normalize mechanism specifiers without any position information 3920 if isinstance(mechanism, tuple): 3921 if len(mechanism) != 4: 3922 raise ValueError( 3923 f"Mechanism specifier must have 4 parts if it's a" 3924 f" tuple. (Got: {mechanism})." 3925 ) 3926 elif all(x is None for x in mechanism[:3]): 3927 self.mechanism = mechanism[3] 3928 3929 def __hash__(self) -> int: 3930 return ( 3931 (11 * hash(self.mechanism)) 3932 + (31 * hash(self.reqState)) 3933 ) 3934 3935 def __eq__(self, other: Any) -> bool: 3936 return ( 3937 isinstance(other, ReqMechanism) 3938 and other.mechanism == self.mechanism 3939 and other.reqState == self.reqState 3940 ) 3941 3942 def __repr__(self): 3943 mRep = repr(self.mechanism) 3944 sRep = repr(self.reqState) 3945 return f"ReqMechanism({mRep}, {sRep})" 3946 3947 def satisfied( 3948 self, 3949 context: RequirementContext, 3950 dontRecurse: Optional[ 3951 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3952 ] = None 3953 ) -> bool: 3954 return mechanismInStateOrEquivalent( 3955 self.mechanism, 3956 self.reqState, 3957 context, 3958 dontRecurse 3959 ) 3960 3961 def walk(self) -> Generator[Requirement, None, None]: 3962 yield self 3963 3964 def asEffectList(self) -> List[Effect]: 3965 """ 3966 Returns a list containing a single 'set' effect which sets the 3967 required mechanism to the required state. 3968 """ 3969 return [effect(set=(self.mechanism, self.reqState))] 3970 3971 def unparse(self) -> str: 3972 if isinstance(self.mechanism, (MechanismID, MechanismName)): 3973 return f'{self.mechanism}:{self.reqState}' 3974 else: # Must be a MechanismSpecifier 3975 # TODO: This elsewhere! 3976 domain, zone, decision, mechanism = self.mechanism 3977 mspec = '' 3978 if domain is not None: 3979 mspec += domain + '//' 3980 if zone is not None: 3981 mspec += zone + '::' 3982 if decision is not None: 3983 mspec += decision + '::' 3984 mspec += mechanism 3985 return f'{mspec}:{self.reqState}' 3986 3987 3988class ReqLevel(Requirement): 3989 """ 3990 A tag requirement satisfied if a specific skill is at or above the 3991 specified level. 3992 """ 3993 def __init__( 3994 self, 3995 skill: Skill, 3996 minLevel: Level, 3997 ) -> None: 3998 self.skill = skill 3999 self.minLevel = minLevel 4000 4001 def __hash__(self) -> int: 4002 return ( 4003 (79 * hash(self.skill)) 4004 + (55 * hash(self.minLevel)) 4005 ) 4006 4007 def __eq__(self, other: Any) -> bool: 4008 return ( 4009 isinstance(other, ReqLevel) 4010 and other.skill == self.skill 4011 and other.minLevel == self.minLevel 4012 ) 4013 4014 def __repr__(self): 4015 sRep = repr(self.skill) 4016 lRep = repr(self.minLevel) 4017 return f"ReqLevel({sRep}, {lRep})" 4018 4019 def satisfied( 4020 self, 4021 context: RequirementContext, 4022 dontRecurse: Optional[ 4023 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 4024 ] = None 4025 ) -> bool: 4026 return getSkillLevel(context.state, self.skill) >= self.minLevel 4027 4028 def walk(self) -> Generator[Requirement, None, None]: 4029 yield self 4030 4031 def asEffectList(self) -> List[Effect]: 4032 """ 4033 Returns a list containing a single 'set' effect which sets the 4034 required skill to the minimum required level. Note that this may 4035 reduce a skill level that was more than sufficient to meet the 4036 requirement. 4037 """ 4038 return [effect(set=("skill", self.skill, self.minLevel))] 4039 4040 def unparse(self) -> str: 4041 return f'{self.skill}^{self.minLevel}' 4042 4043 4044class ReqTag(Requirement): 4045 """ 4046 A tag requirement satisfied if there is any active decision that has 4047 the specified value for the given tag (default value is 1 for tags 4048 where a value wasn't specified). Zone tags also satisfy the 4049 requirement if they're applied to zones that include active 4050 decisions. 4051 """ 4052 def __init__( 4053 self, 4054 tag: "Tag", 4055 value: "TagValue", 4056 ) -> None: 4057 self.tag = tag 4058 self.value = value 4059 4060 def __hash__(self) -> int: 4061 return ( 4062 (71 * hash(self.tag)) 4063 + (43 * hash(self.value)) 4064 ) 4065 4066 def __eq__(self, other: Any) -> bool: 4067 return ( 4068 isinstance(other, ReqTag) 4069 and other.tag == self.tag 4070 and other.value == self.value 4071 ) 4072 4073 def __repr__(self): 4074 tRep = repr(self.tag) 4075 vRep = repr(self.value) 4076 return f"ReqTag({tRep}, {vRep})" 4077 4078 def satisfied( 4079 self, 4080 context: RequirementContext, 4081 dontRecurse: Optional[ 4082 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 4083 ] = None 4084 ) -> bool: 4085 active = combinedDecisionSet(context.state) 4086 graph = context.graph 4087 zones = set() 4088 for decision in active: 4089 tags = graph.decisionTags(decision) 4090 if self.tag in tags and tags[self.tag] == self.value: 4091 return True 4092 zones |= graph.zoneAncestors(decision) 4093 for zone in zones: 4094 zTags = graph.zoneTags(zone) 4095 if self.tag in zTags and zTags[self.tag] == self.value: 4096 return True 4097 4098 return False 4099 4100 def walk(self) -> Generator[Requirement, None, None]: 4101 yield self 4102 4103 def asEffectList(self) -> List[Effect]: 4104 """ 4105 Returns a list containing a single 'set' effect which sets the 4106 required mechanism to the required state. 4107 """ 4108 raise TypeError( 4109 "Cannot convert ReqTag into an effect list:" 4110 " effects cannot apply/remove/change tags" 4111 ) 4112 4113 def unparse(self) -> str: 4114 return f'{self.tag}~{self.value!r}' 4115 4116 4117class ReqNothing(Requirement): 4118 """ 4119 A requirement representing that something doesn't actually have a 4120 requirement. This requirement is always satisfied. 4121 """ 4122 def __hash__(self) -> int: 4123 return 127942 4124 4125 def __eq__(self, other: Any) -> bool: 4126 return isinstance(other, ReqNothing) 4127 4128 def __repr__(self): 4129 return "ReqNothing()" 4130 4131 def satisfied( 4132 self, 4133 context: RequirementContext, 4134 dontRecurse: Optional[ 4135 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 4136 ] = None 4137 ) -> bool: 4138 return True 4139 4140 def walk(self) -> Generator[Requirement, None, None]: 4141 yield self 4142 4143 def asEffectList(self) -> List[Effect]: 4144 """ 4145 Returns an empty list, since nothing is required. 4146 """ 4147 return [] 4148 4149 def unparse(self) -> str: 4150 return 'O' 4151 4152 4153class ReqImpossible(Requirement): 4154 """ 4155 A requirement representing that something is impossible. This 4156 requirement is never satisfied. 4157 """ 4158 def __hash__(self) -> int: 4159 return 478743 4160 4161 def __eq__(self, other: Any) -> bool: 4162 return isinstance(other, ReqImpossible) 4163 4164 def __repr__(self): 4165 return "ReqImpossible()" 4166 4167 def satisfied( 4168 self, 4169 context: RequirementContext, 4170 dontRecurse: Optional[ 4171 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 4172 ] = None 4173 ) -> bool: 4174 return False 4175 4176 def walk(self) -> Generator[Requirement, None, None]: 4177 yield self 4178 4179 def asEffectList(self) -> List[Effect]: 4180 """ 4181 Raises a `TypeError` since a `ReqImpossible` cannot be converted 4182 into an effect which would allow the transition to be taken. 4183 """ 4184 raise TypeError( 4185 "Cannot convert ReqImpossible into an effect list:" 4186 " there are no powers or tokens which could be gained to" 4187 " satisfy this requirement." 4188 ) 4189 4190 def unparse(self) -> str: 4191 return 'X' 4192 4193 4194Equivalences = Dict[ 4195 Union[Capability, Tuple[MechanismID, MechanismState]], 4196 Set[Requirement] 4197] 4198""" 4199An `Equivalences` dictionary maps `Capability` names and/or 4200(`MechanismID`, `MechanismState`) pairs to `Requirement` objects, 4201indicating that that single capability or mechanism state should be 4202considered active if the specified requirement is met. Note that this 4203can lead to multiple states of the same mechanism being effectively 4204active at once if a state other than the current state is active via an 4205equivalence. 4206 4207When a circular dependency is created via equivalences, the capability or 4208mechanism state in question is considered inactive when the circular 4209dependency on it comes up, but the equivalence may still succeed (if it 4210uses a disjunction, for example). 4211""" 4212 4213 4214#----------------------# 4215# Tags and Annotations # 4216#----------------------# 4217 4218Tag: 'TypeAlias' = str 4219""" 4220A type alias: tags are strings. 4221 4222A tag is an arbitrary string key attached to a decision or transition, 4223with an associated value (default 1 to just mean "present"). 4224 4225Meanings are left up to the map-maker, but some conventions include: 4226 4227TODO: Actually use these conventions, or abandon them 4228 4229- `'hard'` indicates that an edge is non-trivial to navigate. An 4230 annotation starting with `'fail:'` can be used to name another edge 4231 which would be traversed instead if the player fails to navigate the 4232 edge (e.g., a difficult series of platforms with a pit below that 4233 takes you to another decision). This is of course entirely 4234 subjective. 4235- `'false'` indicates that an edge doesn't actually exist, although it 4236 appears to. This tag is added in the same exploration step that 4237 requirements are updated (normally to `ReqImpossible`) to indicate 4238 that although the edge appeared to be traversable, it wasn't. This 4239 distinguishes that case from a case where edge requirements actually 4240 change. 4241- `'error'` indicates that an edge does not actually exist, and it's 4242 different than `'false'` because it indicates an error on the 4243 player's part rather than intentional deception by the game (another 4244 subjective distinction). It can also be used with a colon and another 4245 tag to indicate that that tag was applied in error (e.g., a ledge 4246 thought to be too high was not actually too high). This should be 4247 used sparingly, because in most cases capturing the player's 4248 perception of the world is what's desired. This is normally applied 4249 in the step before an edge is removed from the graph. 4250- `'hidden'` indicates that an edge is non-trivial to perceive. Again 4251 this is subjective. `'hinted'` can be used as well to indicate that 4252 despite being obfuscated, there are hints that suggest the edge's 4253 existence. 4254- `'created'` indicates that this transition is newly created and 4255 represents a change to the decision layout. Normally, when entering 4256 a decision point, all visible options will be listed. When 4257 revisiting a decision, several things can happen: 4258 1. You could notice a transition you hadn't noticed before. 4259 2. You could traverse part of the room that you couldn't before, 4260 observing new transitions that have always been there (this 4261 would be represented as an internal edge to another decision 4262 node). 4263 3. You could observe that the decision had changed due to some 4264 action or event, and discover a new transition that didn't 4265 exist previously. 4266 This tag distinguishes case 3 from case 1. The presence or absence 4267 of a `'hidden'` tag in case 1 represents whether the newly-observed 4268 (but not new) transition was overlooked because it was hidden or was 4269 just overlooked accidentally. 4270""" 4271 4272TagValueTypes: Tuple = ( 4273 bool, 4274 int, 4275 float, 4276 str, 4277 list, 4278 dict, 4279 None, 4280 Requirement, 4281 Consequence 4282) 4283TagValue: 'TypeAlias' = Union[ 4284 bool, 4285 int, 4286 float, 4287 str, 4288 list, 4289 dict, 4290 None, 4291 Requirement, 4292 Consequence 4293] 4294""" 4295A type alias: tag values are any kind of JSON-serializable data (so 4296booleans, ints, floats, strings, lists, dicts, or Nones, plus 4297`Requirement` and `Consequence` which have custom serialization defined 4298(see `parsing.CustomJSONEncoder`) The default value for tags is the 4299integer 1. Note that this is not enforced recursively in some places... 4300""" 4301 4302 4303class NoTagValue: 4304 """ 4305 Class used to indicate no tag value for things that return tag values 4306 since `None` is a valid tag value. 4307 """ 4308 pass 4309 4310 4311TagUpdateFunction: 'TypeAlias' = Callable[ 4312 [Dict[Tag, TagValue], Tag, TagValue], 4313 TagValue 4314] 4315""" 4316A tag update function gets three arguments: the entire tags dictionary 4317for the thing being updated, the tag name of the tag being updated, and 4318the tag value for that tag. It must return a new tag value. 4319""" 4320 4321 4322Annotation: 'TypeAlias' = str 4323"A type alias: annotations are strings." 4324 4325 4326#-------# 4327# Zones # 4328#-------# 4329 4330class ZoneInfo(NamedTuple): 4331 """ 4332 Zone info holds a level integer (starting from 0 as the level directly 4333 above decisions), a set of parent zones, a set of child decisions 4334 and/or zones, and zone tags and annotations. Zones at a particular 4335 level may only contain zones in lower levels, although zones at any 4336 level may also contain decisions directly. The norm is for zones at 4337 level 0 to contain decisions, while zones at higher levels contain 4338 zones from the level directly below them. 4339 4340 Note that zones may have multiple parents, because one sub-zone may be 4341 contained within multiple super-zones. 4342 """ 4343 level: int 4344 parents: Set[Zone] 4345 contents: Set[Union[DecisionID, Zone]] 4346 tags: Dict[Tag, TagValue] 4347 annotations: List[Annotation] 4348 4349 4350DefaultZone: Zone = "" 4351""" 4352An alias for the empty string to indicate a default zone. 4353""" 4354 4355 4356#----------------------------------# 4357# Exploration actions & Situations # 4358#----------------------------------# 4359 4360ExplorationActionType = Literal[ 4361 'noAction', 4362 'start', 4363 'take', 4364 'explore', 4365 'warp', 4366 'focus', 4367 'swap', 4368 'focalize', 4369 'revertTo', 4370] 4371""" 4372The valid action types for exploration actions (see 4373`ExplorationAction`). 4374""" 4375 4376ExplorationAction: 'TypeAlias' = Union[ 4377 Tuple[Literal['noAction']], 4378 Tuple[ 4379 Literal['start'], 4380 Union[DecisionID, Dict[FocalPointName, DecisionID], Set[DecisionID]], 4381 Optional[DecisionID], 4382 Domain, 4383 Optional[CapabilitySet], 4384 Optional[Dict[MechanismID, MechanismState]], 4385 Optional[dict] 4386 ], 4387 Tuple[ 4388 Literal['explore'], 4389 ContextSpecifier, 4390 DecisionID, 4391 TransitionWithOutcomes, 4392 Union[DecisionName, DecisionID, None], # new name OR target 4393 Optional[Transition], # new reciprocal name 4394 Union[Zone, None] # new level-0 zone 4395 ], 4396 Tuple[ 4397 Literal['explore'], 4398 FocalPointSpecifier, 4399 TransitionWithOutcomes, 4400 Union[DecisionName, DecisionID, None], # new name OR target 4401 Optional[Transition], # new reciprocal name 4402 Union[Zone, None] # new level-0 zone 4403 ], 4404 Tuple[ 4405 Literal['take'], 4406 ContextSpecifier, 4407 DecisionID, 4408 TransitionWithOutcomes 4409 ], 4410 Tuple[Literal['take'], FocalPointSpecifier, TransitionWithOutcomes], 4411 Tuple[Literal['warp'], ContextSpecifier, DecisionID], 4412 Tuple[Literal['warp'], FocalPointSpecifier, DecisionID], 4413 Tuple[Literal['focus'], ContextSpecifier, Set[Domain], Set[Domain]], 4414 Tuple[Literal['swap'], FocalContextName], 4415 Tuple[Literal['focalize'], FocalContextName], 4416 Tuple[Literal['revertTo'], SaveSlot, Set[str]], 4417] 4418""" 4419Represents an action taken at one step of a `DiscreteExploration`. It's a 4420always a tuple, and the first element is a string naming the action. It 4421has multiple possible configurations: 4422 4423- The string 'noAction' as a singlet means that no action has been 4424 taken, which can be used to represent waiting or an ending. In 4425 situations where the player is still deciding on an action, `None` 4426 (which is not a valid `ExplorationAction` should be used instead. 4427- The string 'start' followed by a `DecisionID` / 4428 (`FocalPointName`-to-`DecisionID` dictionary) / set-of-`DecisionID`s 4429 position(s) specifier, another `DecisionID` (or `None`), a `Domain`, 4430 and then optional `CapabilitySet`, mechanism state dictionary, and 4431 custom state dictionary objects (each of which could instead be 4432 `None` for default). This indicates setting up starting state in a 4433 new focal context. The first decision ID (or similar) specifies 4434 active decisions, the second specifies the primary decision (which 4435 ought to be one of the active ones). It always affects the active 4436 focal context, and a `BadStart` error will result if that context 4437 already has any active decisions in the specified domain. The 4438 specified domain must already exist and must have the appropriate 4439 focalization depending on the type of position(s) specifier given; 4440 use `DiscreteExploration.createDomain` to create a domain first if 4441 necessary. Likewise, any specified decisions to activate must 4442 already exist, use `DecisionGraph.addDecision` to create them before 4443 using a 'start' action. 4444 4445 When mechanism states and/or custom state is specified, these 4446 replace current mechanism/custom states for the entire current 4447 state, since these things aren't focal-context-specific. Similarly, 4448 if capabilities are provided, these replace existing capabilities 4449 for the active focal context, since those aren't domain-specific. 4450 4451- The string 'explore' followed by: 4452 * A `ContextSpecifier` indicating which context to use 4453 * A `DecisionID` indicating the starting decision 4454 * Alternatively, a `FocalPointSpecifier` can be used in place of the 4455 context specifier and decision to specify which focal point 4456 moves in a plural-focalized domain. 4457 * A `TransitionWithOutcomes` indicating the transition taken and 4458 outcomes observed (if any). 4459 * An optional `DecisionName` used to rename the destination. 4460 * An optional `Transition` used to rename the reciprocal transition. 4461 * An optional `Zone` used to place the destination into a 4462 (possibly-new) level-0 zone. 4463 This represents exploration of a previously-unexplored decision, in 4464 contrast to 'take' (see below) which represents moving across a 4465 previously-explored transition. 4466 4467- The string 'take' followed by a `ContextSpecifier`, `DecisionID`, and 4468 `TransitionWithOutcomes` represents taking that transition at that 4469 decision, updating the specified context (i.e., common vs. active; 4470 to update a non-active context first swap to it). Normal 4471 `DomainFocalization`-based rules for updating active decisions 4472 determine what happens besides transition consequences, but for a 4473 'singular'-focalized domain (as determined by the active 4474 `FocalContext` in the `DiscreteExploration`'s current `State`), the 4475 current active decision becomes inactive and the decision at the 4476 other end of the selected transition becomes active. A warning or 4477 error may be issued if the `DecisionID` used is an inactive 4478 decision. 4479 * For 'plural'-focalized domains, a `FocalPointSpecifier` is needed 4480 to know which of the plural focal points to move, this takes the 4481 place of the source `ContextSpecifier` and `DecisionID` since it 4482 provides that information. In this case the third item is still a 4483 `Transition`. 4484 4485- The string 'warp' followed by either a `DecisionID`, or a 4486 `FocalPointSpecifier` tuple followed by a `DecisionID`. This 4487 represents activating a new decision without following a 4488 transition in the decision graph, such as when a cutscene moves 4489 you. Things like teleporters can be represented by normal 4490 transitions; a warp should be used when there's a 1-time effect 4491 that has no reciprocal. 4492 4493- The string 'focus' followed by a `ContextSpecifier` and then two sets 4494 of `Domain`s. The first one lists domains that become inactive, and 4495 the second lists domains that become active. This can be used to 4496 represent opening up a menu, although if the menu can easily be 4497 closed and re-opened anywhere, it's usually not necessary to track 4498 the focus swaps (think a cutscene that forces you to make a choice 4499 before being able to continue normal exploration). A focus swap can 4500 also be the consequence of taking a transition, in which case the 4501 exploration action just identifies the transition using one of the 4502 formats above. 4503 4504- The string 'swap' is followed by a `FocalContextName` and represents 4505 a complete `FocalContext` swap. If this something the player can 4506 trigger at will (or under certain conditions) it's better to use a 4507 transition consequence and have the action be taking that transition. 4508 4509- The string 'focalize' is followed by an unused `FocalContextName` 4510 and represents the creation of a new empty focal context (which 4511 will also be swapped-to). 4512 # TODO: domain and context focus swaps as effects! 4513 4514- The string 'revertTo' followed by a `SaveSlot` and then a set of 4515 reversion aspects (see `revertedState`). This will update the 4516 situation by restoring a previous state (or potentially only parts of 4517 it). An empty set of reversion aspects invokes the default revert 4518 behavior, which reverts all aspects of the state, except that changes 4519 to the `DecisionGraph` are preserved. 4520""" 4521 4522 4523def describeExplorationAction( 4524 situation: 'Situation', 4525 action: Optional[ExplorationAction] 4526) -> str: 4527 """ 4528 Returns a string description of the action represented by an 4529 `ExplorationAction` object (or the string '(no action)' for the value 4530 `None`). Uses the provided situation to look up things like decision 4531 names, focal point positions, and destinations where relevant. Does 4532 not know details of which graph it is applied to or the outcomes of 4533 the action, so just describes what is being attempted. 4534 """ 4535 if action is None: 4536 return '(no action)' 4537 4538 if ( 4539 not isinstance(action, tuple) 4540 or len(action) == 0 4541 ): 4542 raise TypeError(f"Not an exploration action: {action!r}") 4543 4544 graph = situation.graph 4545 4546 if action[0] not in get_args(ExplorationActionType): 4547 raise ValueError(f"Invalid exploration action type: {action[0]!r}") 4548 4549 aType = action[0] 4550 if aType == 'noAction': 4551 return "wait" 4552 4553 elif aType == 'start': 4554 if len(action) != 7: 4555 raise ValueError( 4556 f"Wrong number of parts for 'start' action: {action!r}" 4557 ) 4558 ( 4559 _, 4560 startActive, 4561 primary, 4562 domain, 4563 capabilities, 4564 mechanisms, 4565 custom 4566 ) = action 4567 Union[DecisionID, Dict[FocalPointName, DecisionID], Set[DecisionID]] 4568 at: str 4569 if primary is None: 4570 if isinstance(startActive, DecisionID): 4571 at = f" at {graph.identityOf(startActive)}" 4572 elif isinstance(startActive, dict): 4573 at = f" with {len(startActive)} focal point(s)" 4574 elif isinstance(startActive, set): 4575 at = f" from {len(startActive)} decisions" 4576 else: 4577 raise TypeError( 4578 f"Invalid type for starting location:" 4579 f" {type(startActive)}" 4580 ) 4581 else: 4582 at = f" at {graph.identityOf(primary)}" 4583 if isinstance(startActive, dict): 4584 at += f" (among {len(startActive)} focal point(s))" 4585 elif isinstance(startActive, set): 4586 at += f" (among {len(startActive)} decisions)" 4587 4588 return ( 4589 f"start exploring domain {domain}{at}" 4590 ) 4591 4592 elif aType == 'explore': 4593 if len(action) == 7: 4594 assert isinstance(action[2], DecisionID) 4595 fromID = action[2] 4596 assert isinstance(action[3], tuple) 4597 transitionName, specified = action[3] 4598 assert isinstance(action[3][0], Transition) 4599 assert isinstance(action[3][1], list) 4600 assert all(isinstance(x, bool) for x in action[3][1]) 4601 elif len(action) == 6: 4602 assert isinstance(action[1], tuple) 4603 assert len(action[1]) == 3 4604 fpPos = resolvePosition(situation, action[1]) 4605 if fpPos is None: 4606 raise ValueError( 4607 f"Invalid focal point specifier: no position found" 4608 f" for:\n{action[1]}" 4609 ) 4610 else: 4611 fromID = fpPos 4612 transitionName, specified = action[2] 4613 else: 4614 raise ValueError( 4615 f"Wrong number of parts for 'explore' action: {action!r}" 4616 ) 4617 4618 destID = graph.getDestination(fromID, transitionName) 4619 4620 frDesc = graph.identityOf(fromID) 4621 deDesc = graph.identityOf(destID) 4622 4623 newNameOrDest: Union[DecisionName, DecisionID, None] = action[-3] 4624 nowWord = "now " 4625 if newNameOrDest is None: 4626 if destID is None: 4627 nowWord = "" 4628 newName = "INVALID: an unspecified + unnamed decision" 4629 else: 4630 nowWord = "" 4631 newName = graph.nameFor(destID) 4632 elif isinstance(newNameOrDest, DecisionName): 4633 newName = newNameOrDest 4634 else: 4635 assert isinstance(newNameOrDest, DecisionID) 4636 destID = newNameOrDest 4637 nowWord = "now reaches " 4638 newName = graph.identityOf(destID) 4639 4640 newZone: Union[Zone, None] = action[-1] 4641 if newZone in (None, ""): 4642 deDesc = f"{destID} ({nowWord}{newName})" 4643 else: 4644 deDesc = f"{destID} ({nowWord}{newZone}::{newName})" 4645 # TODO: Don't hardcode '::' here? 4646 4647 oDesc = "" 4648 if len(specified) > 0: 4649 oDesc = " with outcomes: " 4650 first = True 4651 for o in specified: 4652 if first: 4653 first = False 4654 else: 4655 oDesc += ", " 4656 if o: 4657 oDesc += "success" 4658 else: 4659 oDesc += "failure" 4660 4661 return ( 4662 f"explore {transitionName} from decision {frDesc} to" 4663 f" {deDesc}{oDesc}" 4664 ) 4665 4666 elif aType == 'take': 4667 if len(action) == 4: 4668 assert action[1] in get_args(ContextSpecifier) 4669 assert isinstance(action[2], DecisionID) 4670 assert isinstance(action[3], tuple) 4671 assert len(action[3]) == 2 4672 assert isinstance(action[3][0], Transition) 4673 assert isinstance(action[3][1], list) 4674 context = action[1] 4675 fromID = action[2] 4676 transitionName, specified = action[3] 4677 destID = graph.getDestination(fromID, transitionName) 4678 oDesc = "" 4679 if len(specified) > 0: 4680 oDesc = " with outcomes: " 4681 first = True 4682 for o in specified: 4683 if first: 4684 first = False 4685 else: 4686 oDesc += ", " 4687 if o: 4688 oDesc += "success" 4689 else: 4690 oDesc += "failure" 4691 if fromID == destID: # an action 4692 return f"do action {transitionName}" 4693 else: # normal transition 4694 frDesc = graph.identityOf(fromID) 4695 deDesc = graph.identityOf(destID) 4696 4697 return ( 4698 f"take {transitionName} from decision {frDesc} to" 4699 f" {deDesc}{oDesc}" 4700 ) 4701 elif len(action) == 3: 4702 assert isinstance(action[1], tuple) 4703 assert len(action[1]) == 3 4704 assert isinstance(action[2], tuple) 4705 assert len(action[2]) == 2 4706 assert isinstance(action[2][0], Transition) 4707 assert isinstance(action[2][1], list) 4708 _, focalPoint, transition = action 4709 context, domain, name = focalPoint 4710 frID = resolvePosition(situation, focalPoint) 4711 4712 transitionName, specified = action[2] 4713 oDesc = "" 4714 if len(specified) > 0: 4715 oDesc = " with outcomes: " 4716 first = True 4717 for o in specified: 4718 if first: 4719 first = False 4720 else: 4721 oDesc += ", " 4722 if o: 4723 oDesc += "success" 4724 else: 4725 oDesc += "failure" 4726 4727 if frID is None: 4728 return ( 4729 f"invalid action (moves {focalPoint} which doesn't" 4730 f" exist)" 4731 ) 4732 else: 4733 destID = graph.getDestination(frID, transitionName) 4734 4735 if frID == destID: 4736 return "do action {transition}{oDesc}" 4737 else: 4738 frDesc = graph.identityOf(frID) 4739 deDesc = graph.identityOf(destID) 4740 return ( 4741 f"{name} takes {transition} from {frDesc} to" 4742 f" {deDesc}{oDesc}" 4743 ) 4744 else: 4745 raise ValueError( 4746 f"Wrong number of parts for 'take' action: {action!r}" 4747 ) 4748 4749 elif aType == 'warp': 4750 if len(action) != 3: 4751 raise ValueError( 4752 f"Wrong number of parts for 'warp' action: {action!r}" 4753 ) 4754 if action[1] in get_args(ContextSpecifier): 4755 assert isinstance(action[1], str) 4756 assert isinstance(action[2], DecisionID) 4757 _, context, destination = action 4758 deDesc = graph.identityOf(destination) 4759 return f"warp to {deDesc!r}" 4760 elif isinstance(action[1], tuple) and len(action[1]) == 3: 4761 assert isinstance(action[2], DecisionID) 4762 _, focalPoint, destination = action 4763 context, domain, name = focalPoint 4764 deDesc = graph.identityOf(destination) 4765 frID = resolvePosition(situation, focalPoint) 4766 frDesc = graph.identityOf(frID) 4767 return f"{name} warps to {deDesc!r}" 4768 else: 4769 raise TypeError( 4770 f"Invalid second part for 'warp' action: {action!r}" 4771 ) 4772 4773 elif aType == 'focus': 4774 if len(action) != 4: 4775 raise ValueError( 4776 "Wrong number of parts for 'focus' action: {action!r}" 4777 ) 4778 _, context, deactivate, activate = action 4779 assert isinstance(deactivate, set) 4780 assert isinstance(activate, set) 4781 result = "change in active domains: " 4782 clauses = [] 4783 if len(deactivate) > 0: 4784 clauses.append("deactivate domain(s) {', '.join(deactivate)}") 4785 if len(activate) > 0: 4786 clauses.append("activate domain(s) {', '.join(activate)}") 4787 result += '; '.join(clauses) 4788 return result 4789 4790 elif aType == 'swap': 4791 if len(action) != 2: 4792 raise ValueError( 4793 "Wrong number of parts for 'swap' action: {action!r}" 4794 ) 4795 _, fcName = action 4796 return f"swap to focal context {fcName!r}" 4797 4798 elif aType == 'focalize': 4799 if len(action) != 2: 4800 raise ValueError( 4801 "Wrong number of parts for 'focalize' action: {action!r}" 4802 ) 4803 _, fcName = action 4804 return f"create new focal context {fcName!r}" 4805 4806 else: 4807 raise RuntimeError( 4808 "Missing case for exploration action type: {action[0]!r}" 4809 ) 4810 4811 4812DecisionType = Literal[ 4813 "pending", 4814 "active", 4815 "unintended", 4816 "imposed", 4817 "consequence" 4818] 4819""" 4820The types for decisions are: 4821- 'pending': A decision that hasn't been made yet. 4822- 'active': A decision made actively and consciously (the default). 4823- 'unintended': A decision was made but the execution of that decision 4824 resulted in a different action than the one intended (note that we 4825 don't currently record the original intent). TODO: that? 4826- 'imposed': A course of action was changed/taken, but no conscious 4827 decision was made, meaning that the action was imposed by external 4828 circumstances. 4829- 'consequence': A different course of action resulted in a follow-up 4830 consequence that wasn't part of the original intent. 4831""" 4832 4833 4834class Situation(NamedTuple): 4835 """ 4836 Holds all of the pieces of an exploration's state at a single 4837 exploration step, including: 4838 4839 - 'graph': The `DecisionGraph` for that step. Continuity between 4840 graphs can be established because they use the same `DecisionID` 4841 for unchanged nodes. 4842 - 'state': The game `State` for that step, including common and 4843 active `FocalContext`s which determine both what capabilities 4844 are active in the step and which decision point(s) the player 4845 may select an option at. 4846 - 'type': The `DecisionType` for the decision made at this 4847 situation. 4848 - 'taken': an `ExplorationAction` specifying what action was taken, 4849 or `None` for situations where an action has not yet been 4850 decided on (distinct from `(`noAction`,)` for waiting). The 4851 effects of that action are represented by the following 4852 `Situation` in the `DiscreteExploration`. Note that the final 4853 situation in an exploration will also use `('noAction',)` as the 4854 'taken' value to indicate that either no further choices are 4855 possible (e.g., at an ending), or it will use `None` to indicate 4856 that no choice has been made yet. 4857 - 'saves': A dictionary mapping save-slot names to (graph, state) 4858 pairs for saved states. 4859 - 'tags': A dictionary of tag-name: tag-value information for this 4860 step, allowing custom tags with custom values to be added. 4861 - 'annotations': A list of `Annotation` strings allowing custom 4862 annotations to be applied to a situation. 4863 """ 4864 graph: 'DecisionGraph' 4865 state: State 4866 type: DecisionType 4867 action: Optional[ExplorationAction] 4868 saves: Dict[SaveSlot, Tuple['DecisionGraph', State]] 4869 tags: Dict[Tag, TagValue] 4870 annotations: List[Annotation] 4871 4872 4873#-----------------------------# 4874# Situation support functions # 4875#-----------------------------# 4876 4877def contextForTransition( 4878 situation: Situation, 4879 decision: AnyDecisionSpecifier, 4880 transition: Transition 4881) -> RequirementContext: 4882 """ 4883 Given a `Situation` along with an `AnyDecisionSpecifier` and a 4884 `Transition` that together identify a particular transition of 4885 interest, returns the appropriate `RequirementContext` to use to 4886 evaluate requirements and resolve consequences for that transition, 4887 which involves the state & graph from the specified situation, along 4888 with the two ends of that transition as the search-from location. 4889 """ 4890 return RequirementContext( 4891 graph=situation.graph, 4892 state=situation.state, 4893 searchFrom=situation.graph.bothEnds(decision, transition) 4894 ) 4895 4896 4897def genericContextForSituation( 4898 situation: Situation, 4899 searchFrom: Optional[Set[DecisionID]] = None 4900) -> RequirementContext: 4901 """ 4902 Turns a `Situation` into a `RequirementContext` without a specific 4903 transition as the origin (use `contextForTransition` if there's a 4904 relevant transition). By default, the `searchFrom` part of the 4905 requirement context will be the set of active decisions in the 4906 situation, but the search-from part can be overridden by supplying 4907 an explicit `searchFrom` set of decision IDs here. 4908 """ 4909 if searchFrom is None: 4910 searchFrom = combinedDecisionSet(situation.state) 4911 4912 return RequirementContext( 4913 state=situation.state, 4914 graph=situation.graph, 4915 searchFrom=searchFrom 4916 ) 4917 4918 4919def hasCapabilityOrEquivalent( 4920 capability: Capability, 4921 context: RequirementContext, 4922 dontRecurse: Optional[ 4923 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 4924 ] = None 4925): 4926 """ 4927 Determines whether a capability should be considered obtained for the 4928 purposes of requirements, given an entire game state and an 4929 equivalences dictionary which maps capabilities and/or 4930 mechanism/state pairs to sets of requirements that when fulfilled 4931 should count as activating that capability or mechanism state. 4932 """ 4933 if dontRecurse is None: 4934 dontRecurse = set() 4935 4936 if ( 4937 capability in context.state['common']['capabilities']['capabilities'] 4938 or capability in ( 4939 context.state['contexts'] 4940 [context.state['activeContext']] 4941 ['capabilities'] 4942 ['capabilities'] 4943 ) 4944 ): 4945 return True # Capability is explicitly obtained 4946 elif capability in dontRecurse: 4947 return False # Treat circular requirements as unsatisfied 4948 elif not context.graph.hasAnyEquivalents(capability): 4949 # No equivalences to check 4950 return False 4951 else: 4952 # Need to check for a satisfied equivalence 4953 subDont = set(dontRecurse) # Where not to recurse 4954 subDont.add(capability) 4955 # equivalences for this capability 4956 options = context.graph.allEquivalents(capability) 4957 for req in options: 4958 if req.satisfied(context, subDont): 4959 return True 4960 4961 return False 4962 4963 4964def stateOfMechanism( 4965 ctx: RequirementContext, 4966 mechanism: AnyMechanismSpecifier 4967) -> MechanismState: 4968 """ 4969 Returns the current state of the specified mechanism, returning 4970 `DEFAULT_MECHANISM_STATE` if that mechanism doesn't yet have an 4971 assigned state. 4972 """ 4973 mID = ctx.graph.resolveMechanism(mechanism, ctx.searchFrom) 4974 4975 return ctx.state['mechanisms'].get( 4976 mID, 4977 DEFAULT_MECHANISM_STATE 4978 ) 4979 4980 4981def mechanismInStateOrEquivalent( 4982 mechanism: AnyMechanismSpecifier, 4983 reqState: MechanismState, 4984 context: RequirementContext, 4985 dontRecurse: Optional[ 4986 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 4987 ] = None 4988): 4989 """ 4990 Determines whether a mechanism should be considered as being in the 4991 given state for the purposes of requirements, given an entire game 4992 state and an equivalences dictionary which maps capabilities and/or 4993 mechanism/state pairs to sets of requirements that when fulfilled 4994 should count as activating that capability or mechanism state. 4995 4996 The `dontRecurse` set of capabilities and/or mechanisms indicates 4997 requirements which should not be considered for alternate 4998 fulfillment during recursion. 4999 5000 Mechanisms with unspecified state are considered to be in the 5001 `DEFAULT_MECHANISM_STATE`, but mechanisms which don't exist are not 5002 considered to be in any state (i.e., this will always return False). 5003 """ 5004 if dontRecurse is None: 5005 dontRecurse = set() 5006 5007 mID = context.graph.resolveMechanism(mechanism, context.searchFrom) 5008 5009 currentState = stateOfMechanism(context, mID) 5010 if currentState == reqState: 5011 return True # Mechanism is explicitly in the target state 5012 elif (mID, reqState) in dontRecurse: 5013 return False # Treat circular requirements as unsatisfied 5014 elif not context.graph.hasAnyEquivalents((mID, reqState)): 5015 return False # If there are no equivalences, nothing to check 5016 else: 5017 # Need to check for a satisfied equivalence 5018 subDont = set(dontRecurse) # Where not to recurse 5019 subDont.add((mID, reqState)) 5020 # equivalences for this capability 5021 options = context.graph.allEquivalents((mID, reqState)) 5022 for req in options: 5023 if req.satisfied(context, subDont): 5024 return True 5025 5026 return False 5027 5028 5029def combinedTokenCount(state: State, tokenType: Token) -> TokenCount: 5030 """ 5031 Returns the token count for a particular token type for a state, 5032 combining tokens from the common and active `FocalContext`s. 5033 """ 5034 return ( 5035 state['common']['capabilities']['tokens'].get(tokenType, 0) 5036 + state[ 5037 'contexts' 5038 ][state['activeContext']]['capabilities']['tokens'].get(tokenType, 0) 5039 ) 5040 5041 5042def explorationStatusOf( 5043 situation: Situation, 5044 decision: AnyDecisionSpecifier 5045) -> ExplorationStatus: 5046 """ 5047 Returns the exploration status of the specified decision in the 5048 given situation, or the `DEFAULT_EXPLORATION_STATUS` if no status 5049 has been set for that decision. 5050 """ 5051 dID = situation.graph.resolveDecision(decision) 5052 return situation.state['exploration'].get( 5053 dID, 5054 DEFAULT_EXPLORATION_STATUS 5055 ) 5056 5057 5058def setExplorationStatus( 5059 situation: Situation, 5060 decision: AnyDecisionSpecifier, 5061 status: ExplorationStatus, 5062 upgradeOnly: bool = False 5063) -> None: 5064 """ 5065 Sets the exploration status of the specified decision in the 5066 given situation. If `upgradeOnly` is set to True (default is False) 5067 then the exploration status will be changed only if the new status 5068 counts as more-explored than the old one (see `moreExplored`). 5069 """ 5070 dID = situation.graph.resolveDecision(decision) 5071 eMap = situation.state['exploration'] 5072 if upgradeOnly: 5073 status = moreExplored( 5074 status, 5075 eMap.get(dID, 'unknown') 5076 ) 5077 eMap[dID] = status 5078 5079 5080def hasBeenVisited( 5081 situation: Situation, 5082 decision: AnyDecisionSpecifier 5083) -> bool: 5084 """ 5085 Returns `True` if the specified decision has an exploration status 5086 which counts as having been visited (see `base.statusVisited`). Note 5087 that this works differently from `DecisionGraph.isConfirmed` which 5088 just checks for the 'unconfirmed' tag. 5089 """ 5090 return statusVisited(explorationStatusOf(situation, decision)) 5091 5092 5093class IndexTooFarError(IndexError): 5094 """ 5095 An index error that also holds a number specifying how far beyond 5096 the end of the valid indices the given index was. If you are '0' 5097 beyond the end that means you're at the next element after the end. 5098 """ 5099 def __init__(self, msg, beyond=0): 5100 """ 5101 You need a message and can also include a 'beyond' value 5102 (default is 0). 5103 """ 5104 self.msg = msg 5105 self.beyond = beyond 5106 5107 def __str__(self): 5108 return self.msg + f" ({self.beyond} beyond sequence)" 5109 5110 def __repr(self): 5111 return f"IndexTooFarError({repr(self.msg)}, {repr(self.beyond)})" 5112 5113 5114def countParts( 5115 consequence: Union[Consequence, Challenge, Condition, Effect] 5116) -> int: 5117 """ 5118 Returns the number of parts the given consequence has for 5119 depth-first indexing purposes. The consequence itself counts as a 5120 part, plus each `Challenge`, `Condition`, and `Effect` within it, 5121 along with the part counts of any sub-`Consequence`s in challenges 5122 or conditions. 5123 5124 For example: 5125 5126 >>> countParts([]) 5127 1 5128 >>> countParts([effect(gain='jump'), effect(lose='jump')]) 5129 3 5130 >>> c = [ # 1 5131 ... challenge( # 2 5132 ... skills=BestSkill('skill'), 5133 ... level=4, 5134 ... success=[], # 3 5135 ... failure=[effect(lose=('money', 10))], # 4, 5 5136 ... outcome=True 5137 ... ), 5138 ... condition( # 6 5139 ... ReqCapability('jump'), 5140 ... [], # 7 5141 ... [effect(gain='jump')] # 8, 9 5142 ... ), 5143 ... effect(set=('door', 'open')) # 10 5144 ... ] 5145 >>> countParts(c) 5146 10 5147 >>> countParts(c[0]) 5148 4 5149 >>> countParts(c[1]) 5150 4 5151 >>> countParts(c[2]) 5152 1 5153 >>> # (last part of the 10 is the outer list itself) 5154 >>> c = [ # index 0 5155 ... effect(gain='happy'), # index 1 5156 ... challenge( # index 2 5157 ... skills=BestSkill('strength'), 5158 ... success=[effect(gain='winner')] # indices 3 & 4 5159 ... # failure is implicit; gets index 5 5160 ... ) # level defaults to 0 5161 ... ] 5162 >>> countParts(c) 5163 6 5164 >>> countParts(c[0]) 5165 1 5166 >>> countParts(c[1]) 5167 4 5168 >>> countParts(c[1]['success']) 5169 2 5170 >>> countParts(c[1]['failure']) 5171 1 5172 """ 5173 total = 1 5174 if isinstance(consequence, list): 5175 for part in consequence: 5176 total += countParts(part) 5177 elif isinstance(consequence, dict): 5178 if 'skills' in consequence: # it's a Challenge 5179 consequence = cast(Challenge, consequence) 5180 total += ( 5181 countParts(consequence['success']) 5182 + countParts(consequence['failure']) 5183 ) 5184 elif 'condition' in consequence: # it's a Condition 5185 consequence = cast(Condition, consequence) 5186 total += ( 5187 countParts(consequence['consequence']) 5188 + countParts(consequence['alternative']) 5189 ) 5190 elif 'value' in consequence: # it's an Effect 5191 pass # counted already 5192 else: # bad dict 5193 raise TypeError( 5194 f"Invalid consequence: items must be Effects," 5195 f" Challenges, or Conditions (got a dictionary without" 5196 f" 'skills', 'value', or 'condition' keys)." 5197 f"\nGot consequence: {repr(consequence)}" 5198 ) 5199 else: 5200 raise TypeError( 5201 f"Invalid consequence: must be an Effect, Challenge, or" 5202 f" Condition, or a list of those." 5203 f"\nGot part: {repr(consequence)}" 5204 ) 5205 5206 return total 5207 5208 5209def walkParts( 5210 consequence: Union[Consequence, Challenge, Condition, Effect], 5211 startIndex: int = 0 5212) -> Generator[ 5213 Tuple[int, Union[Consequence, Challenge, Condition, Effect]], 5214 None, 5215 None 5216]: 5217 """ 5218 Yields tuples containing all indices and the associated 5219 `Consequence`s in the given consequence tree, in depth-first 5220 traversal order. 5221 5222 A `startIndex` other than 0 may be supplied and the indices yielded 5223 will start there. 5224 5225 For example: 5226 5227 >>> list(walkParts([])) 5228 [(0, [])] 5229 >>> e = [] 5230 >>> list(walkParts(e))[0][1] is e 5231 True 5232 >>> c = [effect(gain='jump'), effect(lose='jump')] 5233 >>> list(walkParts(c)) == [ 5234 ... (0, c), 5235 ... (1, c[0]), 5236 ... (2, c[1]), 5237 ... ] 5238 True 5239 >>> c = [ # 1 5240 ... challenge( # 2 5241 ... skills=BestSkill('skill'), 5242 ... level=4, 5243 ... success=[], # 3 5244 ... failure=[effect(lose=('money', 10))], # 4, 5 5245 ... outcome=True 5246 ... ), 5247 ... condition( # 6 5248 ... ReqCapability('jump'), 5249 ... [], # 7 5250 ... [effect(gain='jump')] # 8, 9 5251 ... ), 5252 ... effect(set=('door', 'open')) # 10 5253 ... ] 5254 >>> list(walkParts(c)) == [ 5255 ... (0, c), 5256 ... (1, c[0]), 5257 ... (2, c[0]['success']), 5258 ... (3, c[0]['failure']), 5259 ... (4, c[0]['failure'][0]), 5260 ... (5, c[1]), 5261 ... (6, c[1]['consequence']), 5262 ... (7, c[1]['alternative']), 5263 ... (8, c[1]['alternative'][0]), 5264 ... (9, c[2]), 5265 ... ] 5266 True 5267 """ 5268 index = startIndex 5269 yield (index, consequence) 5270 index += 1 5271 if isinstance(consequence, list): 5272 for part in consequence: 5273 for (subIndex, subItem) in walkParts(part, index): 5274 yield (subIndex, subItem) 5275 index = subIndex + 1 5276 elif isinstance(consequence, dict) and 'skills' in consequence: 5277 # a Challenge 5278 challenge = cast(Challenge, consequence) 5279 for (subIndex, subItem) in walkParts(challenge['success'], index): 5280 yield (subIndex, subItem) 5281 index = subIndex + 1 5282 for (subIndex, subItem) in walkParts(challenge['failure'], index): 5283 yield (subIndex, subItem) 5284 elif isinstance(consequence, dict) and 'condition' in consequence: 5285 # a Condition 5286 condition = cast(Condition, consequence) 5287 for (subIndex, subItem) in walkParts( 5288 condition['consequence'], 5289 index 5290 ): 5291 yield (subIndex, subItem) 5292 index = subIndex + 1 5293 for (subIndex, subItem) in walkParts( 5294 condition['alternative'], 5295 index 5296 ): 5297 yield (subIndex, subItem) 5298 elif isinstance(consequence, dict) and 'value' in consequence: 5299 # an Effect; we already yielded it above 5300 pass 5301 else: 5302 raise TypeError( 5303 f"Invalid consequence: items must be lists, Effects," 5304 f" Challenges, or Conditions.\nGot part:" 5305 f" {repr(consequence)}" 5306 ) 5307 5308 5309def consequencePart( 5310 consequence: Consequence, 5311 index: int 5312) -> Union[Consequence, Challenge, Condition, Effect]: 5313 """ 5314 Given a `Consequence`, returns the part at the specified index, in 5315 depth-first traversal order, including the consequence itself at 5316 index 0. Raises an `IndexTooFarError` if the index is beyond the end 5317 of the tree; the 'beyond' value of the error will indicate how many 5318 indices beyond the end it was, with 0 for an index that's just 5319 beyond the end. 5320 5321 For example: 5322 5323 >>> c = [] 5324 >>> consequencePart(c, 0) is c 5325 True 5326 >>> try: 5327 ... consequencePart(c, 1) 5328 ... except IndexTooFarError as e: 5329 ... e.beyond 5330 0 5331 >>> try: 5332 ... consequencePart(c, 2) 5333 ... except IndexTooFarError as e: 5334 ... e.beyond 5335 1 5336 >>> c = [effect(gain='jump'), effect(lose='jump')] 5337 >>> consequencePart(c, 0) is c 5338 True 5339 >>> consequencePart(c, 1) is c[0] 5340 True 5341 >>> consequencePart(c, 2) is c[1] 5342 True 5343 >>> try: 5344 ... consequencePart(c, 3) 5345 ... except IndexTooFarError as e: 5346 ... e.beyond 5347 0 5348 >>> try: 5349 ... consequencePart(c, 4) 5350 ... except IndexTooFarError as e: 5351 ... e.beyond 5352 1 5353 >>> c = [ 5354 ... challenge( 5355 ... skills=BestSkill('skill'), 5356 ... level=4, 5357 ... success=[], 5358 ... failure=[effect(lose=('money', 10))], 5359 ... outcome=True 5360 ... ), 5361 ... condition(ReqCapability('jump'), [], [effect(gain='jump')]), 5362 ... effect(set=('door', 'open')) 5363 ... ] 5364 >>> consequencePart(c, 0) is c 5365 True 5366 >>> consequencePart(c, 1) is c[0] 5367 True 5368 >>> consequencePart(c, 2) is c[0]['success'] 5369 True 5370 >>> consequencePart(c, 3) is c[0]['failure'] 5371 True 5372 >>> consequencePart(c, 4) is c[0]['failure'][0] 5373 True 5374 >>> consequencePart(c, 5) is c[1] 5375 True 5376 >>> consequencePart(c, 6) is c[1]['consequence'] 5377 True 5378 >>> consequencePart(c, 7) is c[1]['alternative'] 5379 True 5380 >>> consequencePart(c, 8) is c[1]['alternative'][0] 5381 True 5382 >>> consequencePart(c, 9) is c[2] 5383 True 5384 >>> consequencePart(c, 10) 5385 Traceback (most recent call last): 5386 ... 5387 exploration.base.IndexTooFarError... 5388 >>> try: 5389 ... consequencePart(c, 10) 5390 ... except IndexTooFarError as e: 5391 ... e.beyond 5392 0 5393 >>> try: 5394 ... consequencePart(c, 11) 5395 ... except IndexTooFarError as e: 5396 ... e.beyond 5397 1 5398 >>> try: 5399 ... consequencePart(c, 14) 5400 ... except IndexTooFarError as e: 5401 ... e.beyond 5402 4 5403 """ 5404 if index == 0: 5405 return consequence 5406 index -= 1 5407 for part in consequence: 5408 if index == 0: 5409 return part 5410 else: 5411 index -= 1 5412 if not isinstance(part, dict): 5413 raise TypeError( 5414 f"Invalid consequence: items in the list must be" 5415 f" Effects, Challenges, or Conditions." 5416 f"\nGot part: {repr(part)}" 5417 ) 5418 elif 'skills' in part: # it's a Challenge 5419 part = cast(Challenge, part) 5420 try: 5421 return consequencePart(part['success'], index) 5422 except IndexTooFarError as e: 5423 index = e.beyond 5424 try: 5425 return consequencePart(part['failure'], index) 5426 except IndexTooFarError as e: 5427 index = e.beyond 5428 elif 'condition' in part: # it's a Condition 5429 part = cast(Condition, part) 5430 try: 5431 return consequencePart(part['consequence'], index) 5432 except IndexTooFarError as e: 5433 index = e.beyond 5434 try: 5435 return consequencePart(part['alternative'], index) 5436 except IndexTooFarError as e: 5437 index = e.beyond 5438 elif 'value' in part: # it's an Effect 5439 pass # if index was 0, we would have returned this part already 5440 else: # bad dict 5441 raise TypeError( 5442 f"Invalid consequence: items in the list must be" 5443 f" Effects, Challenges, or Conditions (got a dictionary" 5444 f" without 'skills', 'value', or 'condition' keys)." 5445 f"\nGot part: {repr(part)}" 5446 ) 5447 5448 raise IndexTooFarError( 5449 "Part index beyond end of consequence.", 5450 index 5451 ) 5452 5453 5454def lookupEffect( 5455 situation: Situation, 5456 effect: EffectSpecifier 5457) -> Effect: 5458 """ 5459 Looks up an effect within a situation. 5460 """ 5461 graph = situation.graph 5462 root = graph.getConsequence(effect[0], effect[1]) 5463 try: 5464 result = consequencePart(root, effect[2]) 5465 except IndexTooFarError: 5466 raise IndexError( 5467 f"Invalid effect specifier (consequence has too few parts):" 5468 f" {effect}" 5469 ) 5470 5471 if not isinstance(result, dict) or 'value' not in result: 5472 raise IndexError( 5473 f"Invalid effect specifier (part is not an Effect):" 5474 f" {effect}\nGot a/an {type(result)}:" 5475 f"\n {result}" 5476 ) 5477 5478 return cast(Effect, result) 5479 5480 5481def triggerCount( 5482 situation: Situation, 5483 effect: EffectSpecifier 5484) -> int: 5485 """ 5486 Looks up the trigger count for the specified effect in the given 5487 situation. This includes times the effect has been triggered but 5488 didn't actually do anything because of its delay and/or charges 5489 values. 5490 """ 5491 return situation.state['effectCounts'].get(effect, 0) 5492 5493 5494def incrementTriggerCount( 5495 situation: Situation, 5496 effect: EffectSpecifier, 5497 add: int = 1 5498) -> None: 5499 """ 5500 Adds one (or the specified `add` value) to the trigger count for the 5501 specified effect in the given situation. 5502 """ 5503 counts = situation.state['effectCounts'] 5504 if effect in counts: 5505 counts[effect] += add 5506 else: 5507 counts[effect] = add 5508 5509 5510def doTriggerEffect( 5511 situation: Situation, 5512 effect: EffectSpecifier 5513) -> Tuple[Effect, Optional[int]]: 5514 """ 5515 Looks up the trigger count for the given effect, adds one, and then 5516 returns a tuple with the effect, plus the effective trigger count or 5517 `None`, returning `None` if the effect's charges or delay values 5518 indicate that based on its new trigger count, it should not actually 5519 fire, and otherwise returning a modified trigger count that takes 5520 delay into account. 5521 5522 For example, if an effect has 2 delay and 3 charges and has been 5523 activated once, it will not actually trigger (since its delay value 5524 is still playing out). Once it hits the third attempted trigger, it 5525 will activate with an effective activation count of 1, since that's 5526 the first time it actually applies. Of course, on the 6th and 5527 subsequent activation attempts, it will once more cease to trigger 5528 because it will be out of charges. 5529 """ 5530 counts = situation.state['effectCounts'] 5531 thisCount = counts.get(effect, 0) 5532 counts[effect] = thisCount + 1 # increment the total count 5533 5534 # Get charges and delay values 5535 effectDetails = lookupEffect(situation, effect) 5536 delay = effectDetails['delay'] or 0 5537 charges = effectDetails['charges'] 5538 5539 delayRemaining = delay - thisCount 5540 if delayRemaining > 0: 5541 return (effectDetails, None) 5542 else: 5543 thisCount -= delay 5544 5545 if charges is None: 5546 return (effectDetails, thisCount) 5547 else: 5548 chargesRemaining = charges - thisCount 5549 if chargesRemaining >= 0: 5550 return (effectDetails, thisCount) 5551 else: 5552 return (effectDetails, None) 5553 5554 5555#------------------# 5556# Position support # 5557#------------------# 5558 5559def resolvePosition( 5560 situation: Situation, 5561 posSpec: Union[Tuple[ContextSpecifier, Domain], FocalPointSpecifier] 5562) -> Optional[DecisionID]: 5563 """ 5564 Given a tuple containing either a specific context plus a specific 5565 domain (which must be singular-focalized) or a full 5566 `FocalPointSpecifier`, this function returns the decision ID implied 5567 by the given specifier within the given situation, or `None` if the 5568 specifier is valid but the position for that specifier is `None` 5569 (including when the domain is not-yet-encountered). For 5570 singular-focalized domains, this is just the position value for that 5571 domain. For plural-focalized domains, you need to provide a 5572 `FocalPointSpecifier` and it's the position of that focal point. 5573 """ 5574 fpName: Optional[FocalPointName] = None 5575 if len(posSpec) == 2: 5576 posSpec = cast(Tuple[ContextSpecifier, Domain], posSpec) 5577 whichContext, domain = posSpec 5578 elif len(posSpec) == 3: 5579 posSpec = cast(FocalPointSpecifier, posSpec) 5580 whichContext, domain, fpName = posSpec 5581 else: 5582 raise ValueError( 5583 f"Invalid position specifier {repr(posSpec)}. Must be a" 5584 f" length-2 or length-3 tuple." 5585 ) 5586 5587 state = situation.state 5588 if whichContext == 'common': 5589 targetContext = state['common'] 5590 else: 5591 targetContext = state['contexts'][state['activeContext']] 5592 focalization = getDomainFocalization(targetContext, domain) 5593 5594 if fpName is None: 5595 if focalization != 'singular': 5596 raise ValueError( 5597 f"Cannot resolve position {repr(posSpec)} because the" 5598 f" domain {repr(domain)} is not singular-focalized." 5599 ) 5600 result = targetContext['activeDecisions'].get(domain) 5601 assert isinstance(result, DecisionID) 5602 return result 5603 else: 5604 if focalization != 'plural': 5605 raise ValueError( 5606 f"Cannot resolve position {repr(posSpec)} because a" 5607 f" focal point name was specified but the domain" 5608 f" {repr(domain)} is not plural-focalized." 5609 ) 5610 fpMap = targetContext['activeDecisions'].get(domain, {}) 5611 # Double-check types for map itself and at least one entry 5612 assert isinstance(fpMap, dict) 5613 if len(fpMap) > 0: 5614 exKey = next(iter(fpMap)) 5615 exVal = fpMap[exKey] 5616 assert isinstance(exKey, FocalPointName) 5617 assert exVal is None or isinstance(exVal, DecisionID) 5618 if fpName not in fpMap: 5619 raise ValueError( 5620 f"Cannot resolve position {repr(posSpec)} because no" 5621 f" focal point with name {repr(fpName)} exists in" 5622 f" domain {repr(domain)} for the {whichContext}" 5623 f" context." 5624 ) 5625 return fpMap[fpName] 5626 5627 5628def updatePosition( 5629 situation: Situation, 5630 newPosition: DecisionID, 5631 inCommon: ContextSpecifier = "active", 5632 moveWhich: Optional[FocalPointName] = None 5633) -> None: 5634 """ 5635 Given a Situation, updates the position information in that 5636 situation to represent updated player focalization. This can be as 5637 simple as a move from one virtual decision to an adjacent one, or as 5638 complicated as a cross-domain move where the previous decision point 5639 remains active and a specific focal point among a plural-focalized 5640 domain gets updated. 5641 5642 The exploration status of the destination will be set to 'exploring' 5643 if it had been an unexplored status, and the 'visiting' tag in the 5644 `DecisionGraph` will be added (set to 1). 5645 5646 TODO: Examples 5647 """ 5648 graph = situation.graph 5649 state = situation.state 5650 destDomain = graph.domainFor(newPosition) 5651 5652 # Set the primary decision of the state 5653 state['primaryDecision'] = newPosition 5654 5655 if inCommon == 'common': 5656 targetContext = state['common'] 5657 else: 5658 targetContext = state['contexts'][state['activeContext']] 5659 5660 # Figure out focalization type and active decision(s) 5661 fType = getDomainFocalization(targetContext, destDomain) 5662 domainActiveMap = targetContext['activeDecisions'] 5663 if destDomain in domainActiveMap: 5664 active = domainActiveMap[destDomain] 5665 else: 5666 if fType == 'singular': 5667 active = domainActiveMap.setdefault(destDomain, None) 5668 elif fType == 'plural': 5669 active = domainActiveMap.setdefault(destDomain, {}) 5670 else: 5671 assert fType == 'spreading' 5672 active = domainActiveMap.setdefault(destDomain, set()) 5673 5674 if fType == 'plural': 5675 assert isinstance(active, dict) 5676 if len(active) > 0: 5677 exKey = next(iter(active)) 5678 exVal = active[exKey] 5679 assert isinstance(exKey, FocalPointName) 5680 assert exVal is None or isinstance(exVal, DecisionID) 5681 if moveWhich is None and len(active) > 1: 5682 raise ValueError( 5683 f"Invalid position update: move is going to decision" 5684 f" {graph.identityOf(newPosition)} in domain" 5685 f" {repr(destDomain)}, but it did not specify which" 5686 f" focal point to move, and that domain has plural" 5687 f" focalization with more than one focal point." 5688 ) 5689 elif moveWhich is None: 5690 moveWhich = list(active)[0] 5691 5692 # Actually move the specified focal point 5693 active[moveWhich] = newPosition 5694 5695 elif moveWhich is not None: 5696 raise ValueError( 5697 f"Invalid position update: move going to decision" 5698 f" {graph.identityOf(newPosition)} in domain" 5699 f" {repr(destDomain)}, specified that focal point" 5700 f" {repr(moveWhich)} should be moved, but that domain does" 5701 f" not have plural focalization, so it does not have" 5702 f" multiple focal points to move." 5703 ) 5704 5705 elif fType == 'singular': 5706 # Update the single position: 5707 domainActiveMap[destDomain] = newPosition 5708 5709 elif fType == 'spreading': 5710 # Add the new position: 5711 assert isinstance(active, set) 5712 active.add(newPosition) 5713 5714 else: 5715 raise ValueError(f"Invalid focalization value: {repr(fType)}") 5716 5717 graph.untagDecision(newPosition, 'unconfirmed') 5718 if not hasBeenVisited(situation, newPosition): 5719 setExplorationStatus( 5720 situation, 5721 newPosition, 5722 'exploring', 5723 upgradeOnly=True 5724 ) 5725 5726 5727#----------------# 5728# Layout support # 5729#----------------# 5730 5731LayoutPosition: 'TypeAlias' = Tuple[float, float] 5732""" 5733An (x, y) pair in unspecified coordinates. 5734""" 5735 5736 5737Layout: 'TypeAlias' = Dict[DecisionID, LayoutPosition] 5738""" 5739Maps one or more decision IDs to `LayoutPosition`s for those decisions. 5740""" 5741 5742#--------------------------------# 5743# Geographic exploration support # 5744#--------------------------------# 5745 5746PointID: 'TypeAlias' = int 5747 5748Coords: 'TypeAlias' = Sequence[float] 5749 5750AnyPoint: 'TypeAlias' = Union[PointID, Coords] 5751 5752Feature: 'TypeAlias' = str 5753""" 5754Each feature in a `FeatureGraph` gets a globally unique id, but also has 5755an explorer-assigned name. These names may repeat themselves (especially 5756in different regions) so a region-based address, possibly with a 5757creation-order numeral, can be used to specify a feature exactly even 5758without using its ID. Any string can be used, but for ease of parsing 5759and conversion between formats, sticking to alphanumerics plus 5760underscores is usually desirable. 5761""" 5762 5763FeatureID: 'TypeAlias' = int 5764""" 5765Features in a feature graph have unique integer identifiers that are 5766assigned automatically in order of creation. 5767""" 5768 5769Part: 'TypeAlias' = str 5770""" 5771Parts of a feature are identified using strings. Standard part names 5772include 'middle', compass directions, and top/bottom. To include both a 5773compass direction and a vertical position, put the vertical position 5774first and separate with a dash, like 'top-north'. Temporal positions 5775like start/end may also apply in some cases. 5776""" 5777 5778 5779class FeatureSpecifier(NamedTuple): 5780 """ 5781 There are several ways to specify a feature within a `FeatureGraph`: 5782 Simplest is to just include the `FeatureID` directly (in that case 5783 the domain must be `None` and the 'within' sequence must be empty). 5784 A specific domain and/or a sequence of containing features (starting 5785 from most-external to most-internal) may also be specified when a 5786 string is used as the feature itself, to help disambiguate (when an 5787 ambiguous `FeatureSpecifier` is used, 5788 `AmbiguousFeatureSpecifierError` may arise in some cases). For any 5789 feature, a part may also be specified indicating which part of the 5790 feature is being referred to; this can be `None` when not referring 5791 to any specific sub-part. 5792 """ 5793 domain: Optional[Domain] 5794 within: Sequence[Feature] 5795 feature: Union[Feature, FeatureID] 5796 part: Optional[Part] 5797 5798 5799def feature( 5800 name: Feature, 5801 part: Optional[Part] = None, 5802 domain: Optional[Domain] = None, 5803 within: Optional[Sequence[Feature]] = None 5804) -> FeatureSpecifier: 5805 """ 5806 Builds a `FeatureSpecifier` with some defaults. The default domain 5807 is `None`, and by default the feature has an empty 'within' field and 5808 its part field is `None`. 5809 """ 5810 if within is None: 5811 within = [] 5812 return FeatureSpecifier( 5813 domain=domain, 5814 within=within, 5815 feature=name, 5816 part=part 5817 ) 5818 5819 5820AnyFeatureSpecifier: 'TypeAlias' = Union[ 5821 FeatureID, 5822 Feature, 5823 FeatureSpecifier 5824] 5825""" 5826A type for locations where a feature may be specified multiple different 5827ways: directly by ID, by full feature specifier, or by a string 5828identifying a feature name. You can use `normalizeFeatureSpecifier` to 5829convert one of these to a `FeatureSpecifier`. 5830""" 5831 5832 5833def normalizeFeatureSpecifier(spec: AnyFeatureSpecifier) -> FeatureSpecifier: 5834 """ 5835 Turns an `AnyFeatureSpecifier` into a `FeatureSpecifier`. Note that 5836 it does not do parsing from a complex string. Use 5837 `parsing.ParseFormat.parseFeatureSpecifier` for that. 5838 5839 It will turn a feature specifier with an int-convertible feature name 5840 into a feature-ID-based specifier, discarding any domain and/or zone 5841 parts. 5842 5843 TODO: Issue a warning if parts are discarded? 5844 """ 5845 if isinstance(spec, (FeatureID, Feature)): 5846 return FeatureSpecifier( 5847 domain=None, 5848 within=[], 5849 feature=spec, 5850 part=None 5851 ) 5852 elif isinstance(spec, FeatureSpecifier): 5853 try: 5854 fID = int(spec.feature) 5855 return FeatureSpecifier(None, [], fID, spec.part) 5856 except ValueError: 5857 return spec 5858 else: 5859 raise TypeError( 5860 f"Invalid feature specifier type: {type(spec)}" 5861 ) 5862 5863 5864class MetricSpace: 5865 """ 5866 TODO 5867 Represents a variable-dimensional coordinate system within which 5868 locations can be identified by coordinates. May (or may not) include 5869 a reference to one or more images which are visual representation(s) 5870 of the space. 5871 """ 5872 def __init__(self, name: str): 5873 self.name = name 5874 5875 self.points: Dict[PointID, Coords] = {} 5876 # Holds all IDs and their corresponding coordinates as key/value 5877 # pairs 5878 5879 self.nextID: PointID = 0 5880 # ID numbers should not be repeated or reused 5881 5882 def addPoint(self, coords: Coords) -> PointID: 5883 """ 5884 Given a sequence (list/array/etc) of int coordinates, creates a 5885 point and adds it to the metric space object 5886 5887 >>> ms = MetricSpace("test") 5888 >>> ms.addPoint([2, 3]) 5889 0 5890 >>> #expected result 5891 >>> ms.addPoint([2, 7, 0]) 5892 1 5893 """ 5894 thisID = self.nextID 5895 5896 self.nextID += 1 5897 5898 self.points[thisID] = coords # creates key value pair 5899 5900 return thisID 5901 5902 # How do we "add" things to the metric space? What data structure 5903 # is it? dictionary 5904 5905 def removePoint(self, thisID: PointID) -> None: 5906 """ 5907 Given the ID of a point/coord, checks the dictionary 5908 (points) for that key and removes the key/value pair from 5909 it. 5910 5911 >>> ms = MetricSpace("test") 5912 >>> ms.addPoint([2, 3]) 5913 0 5914 >>> ms.removePoint(0) 5915 >>> ms.removePoint(0) 5916 Traceback (most recent call last): 5917 ... 5918 KeyError... 5919 >>> #expected result should be a caught KeyNotFound exception 5920 """ 5921 self.points.pop(thisID) 5922 5923 def distance(self, origin: AnyPoint, dest: AnyPoint) -> float: 5924 """ 5925 Given an orgin point and destination point, returns the 5926 distance between the two points as a float. 5927 5928 >>> ms = MetricSpace("test") 5929 >>> ms.addPoint([4, 0]) 5930 0 5931 >>> ms.addPoint([1, 0]) 5932 1 5933 >>> ms.distance(0, 1) 5934 3.0 5935 >>> p1 = ms.addPoint([4, 3]) 5936 >>> p2 = ms.addPoint([4, 9]) 5937 >>> ms.distance(p1, p2) 5938 6.0 5939 >>> ms.distance([8, 6], [4, 6]) 5940 4.0 5941 >>> ms.distance([1, 1], [1, 1]) 5942 0.0 5943 >>> ms.distance([-2, -3], [-5, -7]) 5944 5.0 5945 >>> ms.distance([2.5, 3.7], [4.9, 6.1]) 5946 3.394112549695428 5947 """ 5948 if isinstance(origin, PointID): 5949 coord1 = self.points[origin] 5950 else: 5951 coord1 = origin 5952 5953 if isinstance(dest, PointID): 5954 coord2 = self.points[dest] 5955 else: 5956 coord2 = dest 5957 5958 inside = 0.0 5959 5960 for dim in range(max(len(coord1), len(coord2))): 5961 if dim < len(coord1): 5962 val1 = coord1[dim] 5963 else: 5964 val1 = 0 5965 if dim < len(coord2): 5966 val2 = coord2[dim] 5967 else: 5968 val2 = 0 5969 5970 inside += (val2 - val1)**2 5971 5972 result = math.sqrt(inside) 5973 return result 5974 5975 def NDCoords( 5976 self, 5977 point: AnyPoint, 5978 numDimension: int 5979 ) -> Coords: 5980 """ 5981 Given a 2D set of coordinates (x, y), converts them to the desired 5982 dimension 5983 5984 >>> ms = MetricSpace("test") 5985 >>> ms.NDCoords([5, 9], 3) 5986 [5, 9, 0] 5987 >>> ms.NDCoords([3, 1], 1) 5988 [3] 5989 """ 5990 if isinstance(point, PointID): 5991 coords = self.points[point] 5992 else: 5993 coords = point 5994 5995 seqLength = len(coords) 5996 5997 if seqLength != numDimension: 5998 5999 newCoords: Coords 6000 6001 if seqLength < numDimension: 6002 6003 newCoords = [item for item in coords] 6004 6005 for i in range(numDimension - seqLength): 6006 newCoords.append(0) 6007 6008 else: 6009 newCoords = coords[:numDimension] 6010 6011 return newCoords 6012 6013 def lastID(self) -> PointID: 6014 """ 6015 Returns the most updated ID of the metricSpace instance. The nextID 6016 field is always 1 more than the last assigned ID. Assumes that there 6017 has at least been one ID assigned to a point as a key value pair 6018 in the dictionary. Returns 0 if that is not the case. Does not 6019 consider possible counting errors if a point has been removed from 6020 the dictionary. The last ID does not neccessarily equal the number 6021 of points in the metricSpace (or in the dictionary). 6022 6023 >>> ms = MetricSpace("test") 6024 >>> ms.lastID() 6025 0 6026 >>> ms.addPoint([2, 3]) 6027 0 6028 >>> ms.addPoint([2, 7, 0]) 6029 1 6030 >>> ms.addPoint([2, 7]) 6031 2 6032 >>> ms.lastID() 6033 2 6034 >>> ms.removePoint(2) 6035 >>> ms.lastID() 6036 2 6037 """ 6038 if self.nextID < 1: 6039 return self.nextID 6040 return self.nextID - 1 6041 6042 6043def featurePart(spec: AnyFeatureSpecifier, part: Part) -> FeatureSpecifier: 6044 """ 6045 Returns a new feature specifier (and/or normalizes to one) that 6046 contains the specified part in the 'part' slot. If the provided 6047 feature specifier already contains a 'part', that will be replaced. 6048 6049 For example: 6050 6051 >>> featurePart('town', 'north') 6052 FeatureSpecifier(domain=None, within=[], feature='town', part='north') 6053 >>> featurePart(5, 'top') 6054 FeatureSpecifier(domain=None, within=[], feature=5, part='top') 6055 >>> featurePart( 6056 ... FeatureSpecifier('dom', ['one', 'two'], 'three', 'middle'), 6057 ... 'top' 6058 ... ) 6059 FeatureSpecifier(domain='dom', within=['one', 'two'], feature='three',\ 6060 part='top') 6061 >>> featurePart(FeatureSpecifier(None, ['region'], 'place', None), 'top') 6062 FeatureSpecifier(domain=None, within=['region'], feature='place',\ 6063 part='top') 6064 """ 6065 spec = normalizeFeatureSpecifier(spec) 6066 return FeatureSpecifier(spec.domain, spec.within, spec.feature, part) 6067 6068 6069FeatureType = Literal[ 6070 'node', 6071 'path', 6072 'edge', 6073 'region', 6074 'landmark', 6075 'affordance', 6076 'entity' 6077] 6078""" 6079The different types of features that a `FeatureGraph` can have: 6080 60811. Nodes, representing destinations, and/or intersections. A node is 6082 something that one can be "at" and possibly "in." 60832. Paths, connecting nodes and/or other elements. Also used to represent 6084 access points (like doorways between regions) even when they don't 6085 have length. 60863. Edges, separating regions and/or impeding movement (but a door is also 6087 a kind of edge). 60884. Regions, enclosing other elements and/or regions. Besides via 6089 containment, region-region connections are mediated by nodes, paths, 6090 and/or edges. 60915. Landmarks, which are recognizable and possibly visible from afar. 60926. Affordances, which are exploration-relevant location-specific actions 6093 that can be taken, such as a lever that can be pulled. Affordances 6094 may require positioning within multiple domains, but should only be 6095 marked in the most-relevant domain, with cross-domain linkages for 6096 things like observability. Note that the other spatial object types 6097 have their own natural affordances; this is used to mark affordances 6098 beyond those. Each affordance can have a list of `Consequence`s to 6099 indicate what happens when it is activated. 61007. Entities, which can be interacted with, such as an NPC which can be 6101 talked to. Can also be used to represent the player's avatar in a 6102 particular domain. Can have adjacent (touching) affordances to 6103 represent specific interaction options, and may have nodes which 6104 represent options for deeper interaction, but has a generic 6105 'interact' affordance as well. In general, adjacent affordances 6106 should be used to represent options for interaction that are 6107 triggerable directly within the explorable space, such as the fact 6108 that an NPC can be pushed or picked up or the like. In contrast, 6109 interaction options accessible via opening an interaction menu 6110 should be represented by a 'hasOptions' link to a node (typically in 6111 a separate domain) which has some combination of affordances and/or 6112 further interior nodes representing sub-menus. Sub-menus gated on 6113 some kind of requirement can list those requirements for entry. 6114""" 6115 6116FeatureRelationshipType = Literal[ 6117 'contains', 6118 'within', 6119 'touches', 6120 'observable', 6121 'positioned', 6122 'entranceFor', 6123 'enterTo', 6124 'optionsFor', 6125 'hasOptions', 6126 'interacting', 6127 'triggeredBy', 6128 'triggers', 6129] 6130""" 6131The possible relationships between features in a `FeatureGraph`: 6132 6133- 'contains', specifies that one element contains another. Regions can 6134 contain other elements, including other regions, and nodes can 6135 contain regions (but only indirectly other elements). A region 6136 contained by a node represents some kind of interior space for that 6137 node, and this can be used for fractal detail levels (e.g., a town is 6138 a node on the overworld but when you enter it it's a full region 6139 inside, to contrast with a town as a sub-region of the overworld with 6140 no special enter/exit mechanics). The opposite relation is 'within'. 6141- 'touches' specifies that two elements touch each other. Not used for 6142 regions directly (an edge, path, or node should intercede). The 6143 relationship is reciprocal, but not transitive. 6144- 'observable' specifies that the target element is observable (visible 6145 or otherwise perceivable) from the source element. Can be tagged to 6146 indicate things like partial observability and/or 6147 difficult-to-observe elements. By default, things that touch each 6148 other are considered mutually observable, even without an 6149 'observable' relation being added. 6150- 'positioned' to indicate a specific relative position of two objects, 6151 with tags on the edge used to indicate what the relationship is. 6152 E.g., "the table is 10 feet northwest of the chair" has multiple 6153 possible representations, one of which is a 'positioned' relation 6154 from the table to the chair, with the 'direction' tag set to 6155 'southeast' and the 'distance' tag set to '10 feet'. Note that a 6156 `MetricSpace` may also be used to track coordinate positions of 6157 things; annotating every possible position relationship is not 6158 expected. 6159- 'entranceFor' to indicate which feature contained inside of a node is 6160 enterable from outside the node (possibly from a specific part of 6161 the outside of the node). 'enterTo' is the reciprocal relationship. 6162 'entranceFor' applies from the interior region to the exterior node, 6163 while 'enterTo' goes the other way. Note that you cannot use two 6164 different part specifiers to mark the *same* region as enter-able 6165 from two parts of the same node: each pair of nodes can only have 6166 one 'enteranceFor'/'enterTo' connection between them. 6167- 'optionsFor' to indicate which node associated with an entity holds 6168 the set of options for interaction with that entity. Such nodes are 6169 typically placed within a separate domain from the main exploration 6170 space. The same node could be the options for multiple entities. The 6171 reciprocal is 'hasOptions'. In both cases, a part specifier may be 6172 used to indicate more specifically how the interaction is initiated, 6173 but note that a given pair of entities cannot have multiple 6174 'optionsFor'/'hasOption' links between them. You could have multiple 6175 separate nodes that are 'optionsFor' the same entity with different 6176 parts (or even with no part specified for either, although that 6177 would create ambiguity in terms of action outcomes). 6178- 'interacting' to indicate when one feature is taking action relative 6179 to another. This relationship will have an 'action' tag which will 6180 contain a `FeatureAction` dictionary that specifies the relationship 6181 target as its 'subject'. This does not have a reciprocal, and is 6182 normal ephemeral. 6183- 'triggeredBy' to indicate when some kind of action with a feature 6184 triggers an affordance. The reciprocal is 'triggers'. The link tag 6185 'triggerInfo' will specify: 6186 * 'action': the action whose use trips the trigger (one of the 6187 `FeatureAffordance`s) 6188 * 'directions' (optional): A set of directions, one of which must 6189 match the specified direction of a `FeatureAction` for the 6190 trigger to trip. When this key is not present, no direction 6191 filtering is applied. 6192 * 'parts' (optional): A set of part specifiers, one of which must 6193 match the specified action part for the trigger to trip. When 6194 this key is not present, no part filtering is applied. 6195 * 'entityTags' (optional): A set of entity tags, any of which must 6196 match a tag on an interacting entity for the trigger to trip. 6197 Items in the set may also be tuples of multiple tags, in which 6198 case all items in the tuple must match for the entity to 6199 qualify. 6200 6201Note that any of these relationships can be tagged as 'temporary' to 6202imply malleability. For example, a bus node could be temporarily 'at' a 6203bus stop node and 'within' a corresponding region, but then those 6204relationships could change when it moves on. 6205""" 6206 6207FREL_RECIPROCALS: Dict[ 6208 FeatureRelationshipType, 6209 FeatureRelationshipType 6210] = { 6211 "contains": "within", 6212 "within": "contains", 6213 "touches": "touches", 6214 "entranceFor": "enterTo", 6215 "enterTo": "entranceFor", 6216 "optionsFor": "hasOptions", 6217 "hasOptions": "optionsFor", 6218 "triggeredBy": "triggers", 6219 "triggers": "triggeredBy", 6220} 6221""" 6222The reciprocal feature relation types for each `FeatureRelationshipType` 6223which has a required reciprocal. 6224""" 6225 6226 6227class FeatureDecision(TypedDict): 6228 """ 6229 Represents a decision made during exploration, including the 6230 position(s) at which the explorer made the decision, which 6231 feature(s) were most relevant to the decision and what course of 6232 action was decided upon (see `FeatureAction`). Has the following 6233 slots: 6234 6235 - 'type': The type of decision (see `exploration.core.DecisionType`). 6236 - 'domains': A set of domains which are active during the decision, 6237 as opposed to domains which may be unfocused or otherwise 6238 inactive. 6239 - 'focus': An optional single `FeatureSpecifier` which represents the 6240 focal character or object for a decision. May be `None` e.g. in 6241 cases where a menu is in focus. Note that the 'positions' slot 6242 determines which positions are relevant to the decision, 6243 potentially separately from the focus but usually overlapping it. 6244 - 'positions': A dictionary mapping `core.Domain`s to sets of 6245 `FeatureSpecifier`s representing the player's position(s) in 6246 each domain. Some domains may function like tech trees, where 6247 the set of positions only expands over time. Others may function 6248 like a single avatar in a virtual world, where there is only one 6249 position. Still others might function like a group of virtual 6250 avatars, with multiple positions that can be updated 6251 independently. 6252 - 'intention': A `FeatureAction` indicating the action taken or 6253 attempted next as a result of the decision. 6254 """ 6255 # TODO: HERE 6256 pass 6257 6258 6259FeatureAffordance = Literal[ 6260 'approach', 6261 'recede', 6262 'follow', 6263 'cross', 6264 'enter', 6265 'exit', 6266 'explore', 6267 'scrutinize', 6268 'do', 6269 'interact', 6270 'focus', 6271] 6272""" 6273The list of verbs that can be used to express actions taken in relation 6274to features in a feature graph: 6275 6276- 'approach' and 'recede' apply to nodes, paths, edges, regions, and 6277 landmarks, and indicate movement towards or away from the feature. 6278- 'follow' applies to paths and edges, and indicates travelling along. 6279 May be bundled with a direction indicator, although this can 6280 sometimes be inferred (e.g., if you're starting at a node that's 6281 touching one end of a path). For edges, a side-indicator may also be 6282 included. A destination-indicator can be used to indicate where 6283 along the item you end up (according to which other feature touching 6284 it you arrive at). 6285- 'cross' applies to nodes, paths, edges, and regions, and may include a 6286 destination indicator when there are multiple possible destinations 6287 on the other side of the target from the current position. 6288- 'enter' and 'exit' apply to regions and nodes, and indicate going 6289 inside of or coming out of the feature. The 'entranceFor' and 6290 'enterTo' relations are used to indicate where you'll end up when 6291 entering a node, note that there can be multiple of these attached 6292 to different parts of the node. A destination indicator can also be 6293 specified on the action. 6294- 'explore' applies to regions, nodes, and paths, and edges, and 6295 indicates a general lower-fractal-level series of actions taken to 6296 gain more complete knowledge about the target. 6297- 'scrutinize' applies to any feature and indicates carefully probing 6298 the details of the feature to learn more about it (e.g., to look for 6299 a secret). 6300- 'do' applies to affordances, and indicates performing whatever special 6301 action they represent. 6302- 'interact' applies to entities, and indicates some kind of generic 6303 interaction with the entity. For more specific interactions, you can 6304 do one of two things: 6305 1. Place affordances touching or within the entity. 6306 2. Use an 'optionsFor' link to indicate which node (typically in a 6307 separate domain) represents the options made available by an 6308 interaction. 6309- 'focus' applies to any kind of node, but usually entities. It 6310 represents changing the focal object/character for the player. 6311 However, note that focus shifts often happen without this affordance 6312 being involved, such as when entering a menu. 6313""" 6314 6315FEATURE_TYPE_AFFORDANCES: Dict[FeatureAffordance, Set[FeatureType]] = { 6316 'approach': {'node', 'path', 'edge', 'region', 'landmark', 'entity'}, 6317 'recede': {'node', 'path', 'edge', 'region', 'landmark', 'entity'}, 6318 'follow': {'edge', 'path', 'entity'}, 6319 'cross': {'node', 'path', 'edge', 'region'}, 6320 'enter': {'node', 'region'}, 6321 'exit': {'node', 'region'}, 6322 'explore': {'node', 'path', 'edge', 'region'}, 6323 'scrutinize': { 6324 'node', 'path', 'edge', 'region', 'landmark', 'affordance', 6325 'entity' 6326 }, 6327 'do': {'affordance'}, 6328 'interact': {'node', 'entity'}, 6329} 6330""" 6331The mapping from affordances to the sets of feature types those 6332affordances apply to. 6333""" 6334 6335 6336class FeatureEffect(TypedDict): 6337 """ 6338 Similar to `Effect` but with more options for how to manipulate the 6339 game state. This represents a single concrete change to either 6340 internal game state, or to the feature graph. Multiple changes 6341 (possibly with random factors involved) can be represented by a 6342 `Consequence`; a `FeatureEffect` is used as a leaf in a `Consequence` 6343 tree. 6344 """ 6345 type: Literal[ 6346 'gain', 6347 'lose', 6348 'toggle', 6349 'deactivate', 6350 'move', 6351 'focus', 6352 'initiate' 6353 'foreground', 6354 'background', 6355 ] 6356 value: Union[ 6357 Capability, 6358 Tuple[Token, int], 6359 List[Capability], 6360 None 6361 ] 6362 charges: Optional[int] 6363 delay: Optional[int] 6364 6365 6366def featureEffect( 6367 #applyTo: ContextSpecifier = 'active', 6368 #gain: Optional[Union[ 6369 # Capability, 6370 # Tuple[Token, TokenCount], 6371 # Tuple[Literal['skill'], Skill, Level] 6372 #]] = None, 6373 #lose: Optional[Union[ 6374 # Capability, 6375 # Tuple[Token, TokenCount], 6376 # Tuple[Literal['skill'], Skill, Level] 6377 #]] = None, 6378 #set: Optional[Union[ 6379 # Tuple[Token, TokenCount], 6380 # Tuple[AnyMechanismSpecifier, MechanismState], 6381 # Tuple[Literal['skill'], Skill, Level] 6382 #]] = None, 6383 #toggle: Optional[Union[ 6384 # Tuple[AnyMechanismSpecifier, List[MechanismState]], 6385 # List[Capability] 6386 #]] = None, 6387 #deactivate: Optional[bool] = None, 6388 #edit: Optional[List[List[commands.Command]]] = None, 6389 #goto: Optional[Union[ 6390 # AnyDecisionSpecifier, 6391 # Tuple[AnyDecisionSpecifier, FocalPointName] 6392 #]] = None, 6393 #bounce: Optional[bool] = None, 6394 #delay: Optional[int] = None, 6395 #charges: Optional[int] = None, 6396 **kwargs 6397): 6398 # TODO: HERE 6399 return effect(**kwargs) 6400 6401# TODO: FeatureConsequences? 6402 6403 6404class FeatureAction(TypedDict): 6405 """ 6406 Indicates an action decided on by a `FeatureDecision`. Has the 6407 following slots: 6408 6409 - 'subject': the main feature (an `AnyFeatureSpecifier`) that 6410 performs the action (usually an 'entity'). 6411 - 'object': the main feature (an `AnyFeatureSpecifier`) with which 6412 the affordance is performed. 6413 - 'affordance': the specific `FeatureAffordance` indicating the type 6414 of action. 6415 - 'direction': The general direction of movement (especially when 6416 the affordance is `follow`). This can be either a direction in 6417 an associated `MetricSpace`, or it can be defined towards or 6418 away from the destination specified. If a destination but no 6419 direction is provided, the direction is assumed to be towards 6420 that destination. 6421 - 'part': The part within/along a feature for movement (e.g., which 6422 side of an edge are you on, or which part of a region are you 6423 traveling through). 6424 - 'destination': The destination of the action (when known ahead of 6425 time). For example, moving along a path towards a particular 6426 feature touching that path, or entering a node into a particular 6427 feature within that node. Note that entering of regions can be 6428 left implicit: if you enter a region to get to a landmark within 6429 it, noting that as approaching the landmark is more appropriate 6430 than noting that as entering the region with the landmark as the 6431 destination. The system can infer what regions you're in by 6432 which feature you're at. 6433 - 'outcome': A `Consequence` list/tree indicating one or more 6434 outcomes, possibly involving challenges. Note that the actual 6435 outcomes of an action may be altered by triggers; the outcomes 6436 listed here are the default outcomes if no triggers are tripped. 6437 6438 The 'direction', 'part', and/or 'destination' may each be None, 6439 depending on the type of affordance and/or amount of detail desired. 6440 """ 6441 subject: AnyFeatureSpecifier 6442 object: AnyFeatureSpecifier 6443 affordance: FeatureAffordance 6444 direction: Optional[Part] 6445 part: Optional[Part] 6446 destination: Optional[AnyFeatureSpecifier] 6447 outcome: Consequence
Default domain value for use when a domain is needed but not specified.
Default focal context name for use when a focal context name is needed but not specified.
Default state we assume in situations where a mechanism hasn't been assigned a state.
Default exploration status we assume when no exploration status has been set.
Default save slot to use when saving or reverting and the slot isn't specified.
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.
A type alias: A zone as part of a DecisionGraph
is identified using
its name.
Zones contain decisions and/or other zones; one zone may be contained by multiple other zones, but a zone may not contain itself or otherwise form a containment loop.
Note that zone names must be globally unique within a DecisionGraph
,
and by extension, two zones with the same name at different steps of a
DiscreteExploration
are assumed to represent the same space.
The empty string is used to mean "default zone" in a few places, so it should not be used as a real zone name.
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
.
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 Zone
s, and ultimately, they will have
different DecisionID
s.
207class DecisionSpecifier(NamedTuple): 208 """ 209 A decision specifier attempts to uniquely identify a decision by 210 name, rather than by ID. See `AnyDecisionSpecifier` for a type which 211 can also be an ID. 212 213 Ambiguity is possible if two decisions share the same name; the 214 decision specifier provides two means of disambiguation: a domain 215 may be specified, and a zone may be specified; if either is 216 specified only decisions within that domain and/or zone will match, 217 but of course there could still be multiple decisions that match 218 those criteria that still share names, in which case many operations 219 will end up raising an `AmbiguousDecisionSpecifierError`. 220 """ 221 domain: Optional[Domain] 222 zone: Optional[Zone] 223 name: DecisionName
A decision specifier attempts to uniquely identify a decision by
name, rather than by ID. See AnyDecisionSpecifier
for a type which
can also be an ID.
Ambiguity is possible if two decisions share the same name; the
decision specifier provides two means of disambiguation: a domain
may be specified, and a zone may be specified; if either is
specified only decisions within that domain and/or zone will match,
but of course there could still be multiple decisions that match
those criteria that still share names, in which case many operations
will end up raising an AmbiguousDecisionSpecifierError
.
Create new instance of DecisionSpecifier(domain, zone, name)
Inherited Members
- builtins.tuple
- index
- count
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.
235class InvalidDecisionSpecifierError(ValueError): 236 """ 237 An error used when a decision specifier is in the wrong format. 238 """
An error used when a decision specifier is in the wrong format.
Inherited Members
- builtins.ValueError
- ValueError
- builtins.BaseException
- with_traceback
- add_note
- args
241class InvalidMechanismSpecifierError(ValueError): 242 """ 243 An error used when a mechanism specifier is invalid. 244 """
An error used when a mechanism specifier is invalid.
Inherited Members
- builtins.ValueError
- ValueError
- builtins.BaseException
- with_traceback
- add_note
- args
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.
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.
Either a Transition
or a TransitionWithOutcomes
.
277def nameAndOutcomes(transition: AnyTransition) -> TransitionWithOutcomes: 278 """ 279 Returns a `TransitionWithOutcomes` when given either one of those 280 already or a base `Transition`. Outcomes will be an empty list when 281 given a transition alone. Checks that the type actually matches. 282 """ 283 if isinstance(transition, Transition): 284 return (transition, []) 285 else: 286 if not isinstance(transition, tuple) or len(transition) != 2: 287 raise TypeError( 288 f"Transition with outcomes must be a length-2 tuple." 289 f" Got: {transition!r}" 290 ) 291 name, outcomes = transition 292 if not isinstance(name, Transition): 293 raise TypeError( 294 f"Transition name must be a string." 295 f" Got: {name!r}" 296 ) 297 if ( 298 not isinstance(outcomes, list) 299 or not all(isinstance(x, bool) for x in outcomes) 300 ): 301 raise TypeError( 302 f"Transition outcomes must be a list of booleans." 303 f" Got: {outcomes!r}" 304 ) 305 return transition
Returns a TransitionWithOutcomes
when given either one of those
already or a base Transition
. Outcomes will be an empty list when
given a transition alone. Checks that the type actually matches.
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 Mechanism
s, which are zone-local
by default.
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 ')'.
A token count is just an integer.
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.
A challenge or skill level is just an integer.
A type alias: mechanism IDs are integers. See MechanismName
and
MechanismState
.
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 Requirement
s 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 MechanismID
s 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 FocalContext
s 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.
415class MechanismSpecifier(NamedTuple): 416 """ 417 Specifies a mechanism either just by name, or with domain and/or 418 zone and/or decision name hints. 419 """ 420 domain: Optional[Domain] 421 zone: Optional[Zone] 422 decision: Optional[DecisionName] 423 name: MechanismName
Specifies a mechanism either just by name, or with domain and/or zone and/or decision name hints.
Create new instance of MechanismSpecifier(domain, zone, decision, name)
Inherited Members
- builtins.tuple
- index
- count
426def mechanismAt( 427 name: MechanismName, 428 domain: Optional[Domain] = None, 429 zone: Optional[Zone] = None, 430 decision: Optional[DecisionName] = None 431) -> MechanismSpecifier: 432 """ 433 Builds a `MechanismSpecifier` using `None` default hints but 434 accepting `domain`, `zone`, and/or `decision` hints. 435 """ 436 return MechanismSpecifier(domain, zone, decision, name)
Builds a MechanismSpecifier
using None
default hints but
accepting domain
, zone
, and/or decision
hints.
Can be a mechanism ID, mechanism name, or a mechanism specifier.
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.
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.
471class CapabilitySet(TypedDict): 472 """ 473 Represents a set of capabilities, including boolean on/off 474 `Capability` names, countable `Token`s accumulated, and 475 integer-leveled skills. It has three slots: 476 477 - 'capabilities': A set representing which `Capability`s this 478 `CapabilitySet` includes. 479 - 'tokens': A dictionary mapping `Token` types to integers 480 representing how many of that token type this `CapabilitySet` has 481 accumulated. 482 - 'skills': A dictionary mapping `Skill` types to `Level` integers, 483 representing what skill levels this `CapabilitySet` has. 484 """ 485 capabilities: Set[Capability] 486 tokens: Dict[Token, TokenCount] 487 skills: Dict[Skill, Level]
Represents a set of capabilities, including boolean on/off
Capability
names, countable Token
s accumulated, and
integer-leveled skills. It has three slots:
- 'capabilities': A set representing which
Capability
s thisCapabilitySet
includes. - 'tokens': A dictionary mapping
Token
types to integers representing how many of that token type thisCapabilitySet
has accumulated. - 'skills': A dictionary mapping
Skill
types toLevel
integers, representing what skill levels thisCapabilitySet
has.
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
DecisionID
s. 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
DecisionID
s, and when a transition is chosen, the decision on the other side is added to the set if it wasn't already present.
The name of a particular focal point in 'plural' DomainFocalization
.
Used when it's necessary to specify whether the common or the active
FocalContext
is being referenced and/or updated.
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
).
556class FocalContext(TypedDict): 557 """ 558 Focal contexts identify an avatar or similar situation where the player 559 has certain capabilities available (a `CapabilitySet`) and may also have 560 position information in one or more `Domain`s (see `State` and 561 `DomainFocalization`). Normally, only a single `FocalContext` is needed, 562 but situations where the player swaps between capability sets and/or 563 positions sometimes call for more. 564 565 At each decision step, only a single `FocalContext` is active, and the 566 capabilities of that context (plus capabilities of the 'common' 567 context) determine what transitions are traversable. At the same time, 568 the set of reachable transitions is determined by the focal context's 569 per-domain position information, including its per-domain 570 `DomainFocalization` type. 571 572 The slots are: 573 574 - 'capabilities': A `CapabilitySet` representing what capabilities, 575 tokens, and skills this context has. Note that capabilities from 576 the common `FocalContext` are added to these to determine what 577 transition requirements are met in a given step. 578 - 'focalization': A mapping from `Domain`s to `DomainFocalization` 579 specifying how this context is focalized in each domain. 580 - 'activeDomains': A set of `Domain`s indicating which `Domain`(s) are 581 active for this focal context right now. 582 - 'activeDecisions': A mapping from `Domain`s to either single 583 `DecisionID`s, dictionaries mapping `FocalPointName`s to 584 optional `DecisionID`s, or sets of `DecisionID`s. Which one is 585 used depends on the `DomainFocalization` of this context for 586 that domain. May also be `None` for domains in which no 587 decisions are active (and in 'plural'-focalization lists, 588 individual entries may be `None`). Active decisions from the 589 common `FocalContext` are also considered active at each step. 590 """ 591 capabilities: CapabilitySet 592 focalization: Dict[Domain, DomainFocalization] 593 activeDomains: Set[Domain] 594 activeDecisions: Dict[ 595 Domain, 596 Union[ 597 None, 598 DecisionID, 599 Dict[FocalPointName, Optional[DecisionID]], 600 Set[DecisionID] 601 ] 602 ]
Focal contexts identify an avatar or similar situation where the player
has certain capabilities available (a CapabilitySet
) and may also have
position information in one or more Domain
s (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 commonFocalContext
are added to these to determine what transition requirements are met in a given step. - 'focalization': A mapping from
Domain
s toDomainFocalization
specifying how this context is focalized in each domain. - 'activeDomains': A set of
Domain
s indicating whichDomain
(s) are active for this focal context right now. - 'activeDecisions': A mapping from
Domain
s to either singleDecisionID
s, dictionaries mappingFocalPointName
s to optionalDecisionID
s, or sets ofDecisionID
s. Which one is used depends on theDomainFocalization
of this context for that domain. May also beNone
for domains in which no decisions are active (and in 'plural'-focalization lists, individual entries may beNone
). Active decisions from the commonFocalContext
are also considered active at each step.
FocalContext
s 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.
613def getDomainFocalization( 614 context: FocalContext, 615 domain: Domain, 616 defaultFocalization: DomainFocalization = 'singular' 617) -> DomainFocalization: 618 """ 619 Fetches the focalization value for the given domain in the given 620 focal context, setting it to the provided default first if that 621 focal context didn't have an entry for that domain yet. 622 """ 623 return context['focalization'].setdefault(domain, defaultFocalization)
Fetches the focalization value for the given domain in the given focal context, setting it to the provided default first if that focal context didn't have an entry for that domain yet.
626class State(TypedDict): 627 """ 628 Represents a game state, including certain exploration-relevant 629 information, plus possibly extra custom information. Has the 630 following slots: 631 632 - 'common': A single `FocalContext` containing capability and position 633 information which is always active in addition to the current 634 `FocalContext`'s information. 635 - 'contexts': A dictionary mapping strings to `FocalContext`s, which 636 store capability and position information. 637 - 'activeContext': A string naming the currently-active 638 `FocalContext` (a key of the 'contexts' slot). 639 - 'primaryDecision': A `DecisionID` (or `None`) indicating the 640 primary decision that is being considered in this state. Whereas 641 the focalization structures can and often will indicate multiple 642 active decisions, whichever decision the player just arrived at 643 via the transition selected in a previous state will be the most 644 relevant, and we track that here. Of course, for some states 645 (like a pre-starting initial state) there is no primary 646 decision. 647 - 'mechanisms': A dictionary mapping `Mechanism` IDs to 648 `MechanismState` strings. 649 - 'exploration': A dictionary mapping decision IDs to exploration 650 statuses, which tracks how much knowledge the player has of 651 different decisions. 652 - 'effectCounts': A dictionary mapping `EffectSpecifier`s to 653 integers specifying how many times that effect has been 654 triggered since the beginning of the exploration (including 655 times that the actual effect was not applied due to delays 656 and/or charges. This is used to figure out when effects with 657 charges and/or delays should be applied. 658 - 'deactivated': A set of (`DecisionID`, `Transition`) tuples 659 specifying which transitions have been deactivated. This is used 660 in addition to transition requirements to figure out which 661 transitions are traversable. 662 - 'custom': An arbitrary sub-dictionary representing any kind of 663 custom game state. In most cases, things can be reasonably 664 approximated via capabilities and tokens and custom game state is 665 not needed. 666 """ 667 common: FocalContext 668 contexts: Dict[FocalContextName, FocalContext] 669 activeContext: FocalContextName 670 primaryDecision: Optional[DecisionID] 671 mechanisms: Dict[MechanismID, MechanismState] 672 exploration: Dict[DecisionID, 'ExplorationStatus'] 673 effectCounts: Dict[EffectSpecifier, int] 674 deactivated: Set[Tuple[DecisionID, Transition]] 675 custom: dict
Represents a game state, including certain exploration-relevant information, plus possibly extra custom information. Has the following slots:
- 'common': A single
FocalContext
containing capability and position information which is always active in addition to the currentFocalContext
's information. - 'contexts': A dictionary mapping strings to
FocalContext
s, which store capability and position information. - 'activeContext': A string naming the currently-active
FocalContext
(a key of the 'contexts' slot). - 'primaryDecision': A
DecisionID
(orNone
) 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 toMechanismState
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
EffectSpecifier
s 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.
682def idOrDecisionSpecifier( 683 ds: DecisionSpecifier 684) -> Union[DecisionSpecifier, int]: 685 """ 686 Given a decision specifier which might use a name that's convertible 687 to an integer ID, returns the appropriate ID if so, and the original 688 decision specifier if not, raising an 689 `InvalidDecisionSpecifierError` if given a specifier with a 690 convertible name that also has other parts. 691 """ 692 try: 693 dID = int(ds.name) 694 except ValueError: 695 return ds 696 697 if ds.domain is None and ds.zone is None: 698 return dID 699 else: 700 raise InvalidDecisionSpecifierError( 701 f"Specifier {ds} has an ID name but also includes" 702 f" domain and/or zone information." 703 )
Given a decision specifier which might use a name that's convertible
to an integer ID, returns the appropriate ID if so, and the original
decision specifier if not, raising an
InvalidDecisionSpecifierError
if given a specifier with a
convertible name that also has other parts.
706def spliceDecisionSpecifiers( 707 base: DecisionSpecifier, 708 default: DecisionSpecifier 709) -> DecisionSpecifier: 710 """ 711 Copies domain and/or zone info from the `default` specifier into the 712 `base` specifier, returning a new `DecisionSpecifier` without 713 modifying either argument. Info is only copied where the `base` 714 specifier has a missing value, although if the base specifier has a 715 domain but no zone and the domain is different from that of the 716 default specifier, no zone info is copied. 717 718 For example: 719 720 >>> d1 = DecisionSpecifier('main', 'zone', 'name') 721 >>> d2 = DecisionSpecifier('niam', 'enoz', 'eman') 722 >>> spliceDecisionSpecifiers(d1, d2) 723 DecisionSpecifier(domain='main', zone='zone', name='name') 724 >>> spliceDecisionSpecifiers(d2, d1) 725 DecisionSpecifier(domain='niam', zone='enoz', name='eman') 726 >>> d3 = DecisionSpecifier(None, None, 'three') 727 >>> spliceDecisionSpecifiers(d3, d1) 728 DecisionSpecifier(domain='main', zone='zone', name='three') 729 >>> spliceDecisionSpecifiers(d3, d2) 730 DecisionSpecifier(domain='niam', zone='enoz', name='three') 731 >>> d4 = DecisionSpecifier('niam', None, 'four') 732 >>> spliceDecisionSpecifiers(d4, d1) # diff domain -> no zone 733 DecisionSpecifier(domain='niam', zone=None, name='four') 734 >>> spliceDecisionSpecifiers(d4, d2) # same domian -> copy zone 735 DecisionSpecifier(domain='niam', zone='enoz', name='four') 736 >>> d5 = DecisionSpecifier(None, 'cone', 'five') 737 >>> spliceDecisionSpecifiers(d4, d5) # None domain -> copy zone 738 DecisionSpecifier(domain='niam', zone='cone', name='four') 739 """ 740 newDomain = base.domain 741 if newDomain is None: 742 newDomain = default.domain 743 newZone = base.zone 744 if ( 745 newZone is None 746 and (newDomain == default.domain or default.domain is None) 747 ): 748 newZone = default.zone 749 750 return DecisionSpecifier(domain=newDomain, zone=newZone, name=base.name)
Copies domain and/or zone info from the default
specifier into the
base
specifier, returning a new DecisionSpecifier
without
modifying either argument. Info is only copied where the base
specifier has a missing value, although if the base specifier has a
domain but no zone and the domain is different from that of the
default specifier, no zone info is copied.
For example:
>>> d1 = DecisionSpecifier('main', 'zone', 'name')
>>> d2 = DecisionSpecifier('niam', 'enoz', 'eman')
>>> spliceDecisionSpecifiers(d1, d2)
DecisionSpecifier(domain='main', zone='zone', name='name')
>>> spliceDecisionSpecifiers(d2, d1)
DecisionSpecifier(domain='niam', zone='enoz', name='eman')
>>> d3 = DecisionSpecifier(None, None, 'three')
>>> spliceDecisionSpecifiers(d3, d1)
DecisionSpecifier(domain='main', zone='zone', name='three')
>>> spliceDecisionSpecifiers(d3, d2)
DecisionSpecifier(domain='niam', zone='enoz', name='three')
>>> d4 = DecisionSpecifier('niam', None, 'four')
>>> spliceDecisionSpecifiers(d4, d1) # diff domain -> no zone
DecisionSpecifier(domain='niam', zone=None, name='four')
>>> spliceDecisionSpecifiers(d4, d2) # same domian -> copy zone
DecisionSpecifier(domain='niam', zone='enoz', name='four')
>>> d5 = DecisionSpecifier(None, 'cone', 'five')
>>> spliceDecisionSpecifiers(d4, d5) # None domain -> copy zone
DecisionSpecifier(domain='niam', zone='cone', name='four')
753def mergeCapabilitySets(A: CapabilitySet, B: CapabilitySet) -> CapabilitySet: 754 """ 755 Merges two capability sets into a new one, where all capabilities in 756 either original set are active, and token counts and skill levels are 757 summed. 758 759 Example: 760 761 >>> cs1 = { 762 ... 'capabilities': {'fly', 'whistle'}, 763 ... 'tokens': {'subway': 3}, 764 ... 'skills': {'agility': 1, 'puzzling': 3}, 765 ... } 766 >>> cs2 = { 767 ... 'capabilities': {'dig', 'fly'}, 768 ... 'tokens': {'subway': 1, 'goat': 2}, 769 ... 'skills': {'agility': -1}, 770 ... } 771 >>> ms = mergeCapabilitySets(cs1, cs2) 772 >>> ms['capabilities'] == {'fly', 'whistle', 'dig'} 773 True 774 >>> ms['tokens'] == {'subway': 4, 'goat': 2} 775 True 776 >>> ms['skills'] == {'agility': 0, 'puzzling': 3} 777 True 778 """ 779 # Set up our result 780 result: CapabilitySet = { 781 'capabilities': set(), 782 'tokens': {}, 783 'skills': {} 784 } 785 786 # Merge capabilities 787 result['capabilities'].update(A['capabilities']) 788 result['capabilities'].update(B['capabilities']) 789 790 # Merge tokens 791 tokensA = A['tokens'] 792 tokensB = B['tokens'] 793 resultTokens = result['tokens'] 794 for tType, val in tokensA.items(): 795 if tType not in resultTokens: 796 resultTokens[tType] = val 797 else: 798 resultTokens[tType] += val 799 for tType, val in tokensB.items(): 800 if tType not in resultTokens: 801 resultTokens[tType] = val 802 else: 803 resultTokens[tType] += val 804 805 # Merge skills 806 skillsA = A['skills'] 807 skillsB = B['skills'] 808 resultSkills = result['skills'] 809 for skill, level in skillsA.items(): 810 if skill not in resultSkills: 811 resultSkills[skill] = level 812 else: 813 resultSkills[skill] += level 814 for skill, level in skillsB.items(): 815 if skill not in resultSkills: 816 resultSkills[skill] = level 817 else: 818 resultSkills[skill] += level 819 820 return result
Merges two capability sets into a new one, where all capabilities in either original set are active, and token counts and skill levels are summed.
Example:
>>> cs1 = {
... 'capabilities': {'fly', 'whistle'},
... 'tokens': {'subway': 3},
... 'skills': {'agility': 1, 'puzzling': 3},
... }
>>> cs2 = {
... 'capabilities': {'dig', 'fly'},
... 'tokens': {'subway': 1, 'goat': 2},
... 'skills': {'agility': -1},
... }
>>> ms = mergeCapabilitySets(cs1, cs2)
>>> ms['capabilities'] == {'fly', 'whistle', 'dig'}
True
>>> ms['tokens'] == {'subway': 4, 'goat': 2}
True
>>> ms['skills'] == {'agility': 0, 'puzzling': 3}
True
823def emptyFocalContext() -> FocalContext: 824 """ 825 Returns a completely empty focal context, which has no capabilities 826 and which has no associated domains. 827 """ 828 return { 829 'capabilities': { 830 'capabilities': set(), 831 'tokens': {}, 832 'skills': {} 833 }, 834 'focalization': {}, 835 'activeDomains': set(), 836 'activeDecisions': {} 837 }
Returns a completely empty focal context, which has no capabilities and which has no associated domains.
840def basicFocalContext( 841 domain: Optional[Domain] = None, 842 focalization: DomainFocalization = 'singular' 843): 844 """ 845 Returns a basic focal context, which has no capabilities and which 846 uses the given focalization (default 'singular') for a single 847 domain with the given name (default `DEFAULT_DOMAIN`) which is 848 active but which has no position specified. 849 """ 850 if domain is None: 851 domain = DEFAULT_DOMAIN 852 return { 853 'capabilities': { 854 'capabilities': set(), 855 'tokens': {}, 856 'skills': {} 857 }, 858 'focalization': {domain: focalization}, 859 'activeDomains': {domain}, 860 'activeDecisions': {domain: None} 861 }
Returns a basic focal context, which has no capabilities and which
uses the given focalization (default 'singular') for a single
domain with the given name (default DEFAULT_DOMAIN
) which is
active but which has no position specified.
864def emptyState() -> State: 865 """ 866 Returns an empty `State` dictionary. The empty dictionary uses 867 `DEFAULT_FOCAL_CONTEXT_NAME` as the name of the active 868 `FocalContext`. 869 """ 870 return { 871 'common': emptyFocalContext(), 872 'contexts': {DEFAULT_FOCAL_CONTEXT_NAME: basicFocalContext()}, 873 'activeContext': DEFAULT_FOCAL_CONTEXT_NAME, 874 'primaryDecision': None, 875 'mechanisms': {}, 876 'exploration': {}, 877 'effectCounts': {}, 878 'deactivated': set(), 879 'custom': {} 880 }
Returns an empty State
dictionary. The empty dictionary uses
DEFAULT_FOCAL_CONTEXT_NAME
as the name of the active
FocalContext
.
883def basicState( 884 context: Optional[FocalContextName] = None, 885 domain: Optional[Domain] = None, 886 focalization: DomainFocalization = 'singular' 887) -> State: 888 """ 889 Returns a `State` dictionary with a newly created single active 890 focal context that uses the given name (default 891 `DEFAULT_FOCAL_CONTEXT_NAME`). This context is created using 892 `basicFocalContext` with the given domain and focalization as 893 arguments (defaults `DEFAULT_DOMAIN` and 'singular'). 894 """ 895 if context is None: 896 context = DEFAULT_FOCAL_CONTEXT_NAME 897 return { 898 'common': emptyFocalContext(), 899 'contexts': {context: basicFocalContext(domain, focalization)}, 900 'activeContext': context, 901 'primaryDecision': None, 902 'mechanisms': {}, 903 'exploration': {}, 904 'effectCounts': {}, 905 'deactivated': set(), 906 'custom': {} 907 }
Returns a State
dictionary with a newly created single active
focal context that uses the given name (default
DEFAULT_FOCAL_CONTEXT_NAME
). This context is created using
basicFocalContext
with the given domain and focalization as
arguments (defaults DEFAULT_DOMAIN
and 'singular').
910def effectiveCapabilitySet(state: State) -> CapabilitySet: 911 """ 912 Given a `baseTypes.State` object, computes the effective capability 913 set for that state, which merges capabilities and tokens from the 914 common `baseTypes.FocalContext` with those of the active one. 915 916 Returns a `CapabilitySet`. 917 """ 918 # Grab relevant contexts 919 commonContext = state['common'] 920 activeContext = state['contexts'][state['activeContext']] 921 922 # Extract capability dictionaries 923 commonCapabilities = commonContext['capabilities'] 924 activeCapabilities = activeContext['capabilities'] 925 926 return mergeCapabilitySets( 927 commonCapabilities, 928 activeCapabilities 929 )
Given a baseTypes.State
object, computes the effective capability
set for that state, which merges capabilities and tokens from the
common baseTypes.FocalContext
with those of the active one.
Returns a CapabilitySet
.
932def combinedDecisionSet(state: State) -> Set[DecisionID]: 933 """ 934 Given a `State` object, computes the active decision set for that 935 state, which is the set of decisions at which the player can make an 936 immediate decision. This depends on the 'common' `FocalContext` as 937 well as the active focal context, and of course each `FocalContext` 938 may specify separate active decisions for different domains, separate 939 sets of active domains, etc. See `FocalContext` and 940 `DomainFocalization` for more details, as well as `activeDecisionSet`. 941 942 Returns a set of `DecisionID`s. 943 """ 944 commonContext = state['common'] 945 activeContext = state['contexts'][state['activeContext']] 946 result = set() 947 for ctx in (commonContext, activeContext): 948 result |= activeDecisionSet(ctx) 949 950 return result
Given a State
object, computes the active decision set for that
state, which is the set of decisions at which the player can make an
immediate decision. This depends on the 'common' FocalContext
as
well as the active focal context, and of course each FocalContext
may specify separate active decisions for different domains, separate
sets of active domains, etc. See FocalContext
and
DomainFocalization
for more details, as well as activeDecisionSet
.
Returns a set of DecisionID
s.
953def activeDecisionSet(context: FocalContext) -> Set[DecisionID]: 954 """ 955 Given a `FocalContext`, returns the set of all `DecisionID`s which 956 are active in that focal context. This includes only decisions which 957 are in active domains. 958 959 For example: 960 961 >>> fc = emptyFocalContext() 962 >>> activeDecisionSet(fc) 963 set() 964 >>> fc['focalization'] = { 965 ... 'Si': 'singular', 966 ... 'Pl': 'plural', 967 ... 'Sp': 'spreading' 968 ... } 969 >>> fc['activeDomains'] = {'Si'} 970 >>> fc['activeDecisions'] = { 971 ... 'Si': 0, 972 ... 'Pl': {'one': 1, 'two': 2}, 973 ... 'Sp': {3, 4} 974 ... } 975 >>> activeDecisionSet(fc) 976 {0} 977 >>> fc['activeDomains'] = {'Si', 'Pl'} 978 >>> sorted(activeDecisionSet(fc)) 979 [0, 1, 2] 980 >>> fc['activeDomains'] = {'Pl'} 981 >>> sorted(activeDecisionSet(fc)) 982 [1, 2] 983 >>> fc['activeDomains'] = {'Sp'} 984 >>> sorted(activeDecisionSet(fc)) 985 [3, 4] 986 >>> fc['activeDomains'] = {'Si', 'Pl', 'Sp'} 987 >>> sorted(activeDecisionSet(fc)) 988 [0, 1, 2, 3, 4] 989 """ 990 result = set() 991 decisionsMap = context['activeDecisions'] 992 for domain in context['activeDomains']: 993 activeGroup = decisionsMap[domain] 994 if activeGroup is None: 995 pass 996 elif isinstance(activeGroup, DecisionID): 997 result.add(activeGroup) 998 elif isinstance(activeGroup, dict): 999 for x in activeGroup.values(): 1000 if x is not None: 1001 result.add(x) 1002 elif isinstance(activeGroup, set): 1003 result.update(activeGroup) 1004 else: 1005 raise TypeError( 1006 f"The FocalContext {repr(context)} has an invalid" 1007 f" active group for domain {repr(domain)}." 1008 f"\nGroup is: {repr(activeGroup)}" 1009 ) 1010 1011 return result
Given a FocalContext
, returns the set of all DecisionID
s 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]
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.
1073def moreExplored( 1074 a: ExplorationStatus, 1075 b: ExplorationStatus 1076) -> ExplorationStatus: 1077 """ 1078 Returns whichever of the two exploration statuses counts as 'more 1079 explored'. 1080 """ 1081 eArgs = get_args(ExplorationStatus) 1082 try: 1083 aIndex = eArgs.index(a) 1084 except ValueError: 1085 raise ValueError( 1086 f"Status {a!r} is not a valid exploration status. Must be" 1087 f" one of: {eArgs!r}" 1088 ) 1089 try: 1090 bIndex = eArgs.index(b) 1091 except ValueError: 1092 raise ValueError( 1093 f"Status {b!r} is not a valid exploration status. Must be" 1094 f" one of: {eArgs!r}" 1095 ) 1096 if aIndex > bIndex: 1097 return a 1098 else: 1099 return b
Returns whichever of the two exploration statuses counts as 'more explored'.
1102def statusVisited(status: ExplorationStatus) -> bool: 1103 """ 1104 Returns true or false depending on whether the provided status 1105 indicates a decision has been visited or not. The 'exploring' and 1106 'explored' statuses imply a decision has been visisted, but other 1107 statuses do not. 1108 """ 1109 return status in ('exploring', 'explored')
Returns true or false depending on whether the provided status indicates a decision has been visited or not. The 'exploring' and 'explored' statuses imply a decision has been visisted, but other statuses do not.
Parts of a FocalContext
that can be restored. Used in revertedState
.
Parts of a focal context CapabilitySet
that can be restored. Used in
revertedState
.
Parts of a FocalContext
besides the capability set that we can restore.
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.
1141def revertedState( 1142 currentStuff: Tuple['DecisionGraph', State], 1143 savedStuff: Tuple['DecisionGraph', State], 1144 revisionAspects: Set[str] 1145) -> Tuple['DecisionGraph', State]: 1146 """ 1147 Given two (graph, state) pairs, as well as a set of reversion aspect 1148 strings, returns a (graph, state) pair representing the reverted 1149 graph and state. The provided graphs and states will not be 1150 modified, and the return value will not include references to them, 1151 so modifying the returned state will not modify the original or 1152 saved states or graphs. 1153 1154 If the `revisionAspects` set is empty, then all aspects except 1155 skills, exploration statuses, and the graph will be reverted. 1156 1157 Note that the reversion process can lead to impossible states if the 1158 wrong combination of reversion aspects is used (e.g., reverting the 1159 graph but not focal context position information might lead to 1160 positions that refer to decisions which do not exist). 1161 1162 Valid reversion aspect strings are: 1163 - "common-capabilities", "common-tokens", "common-skills," 1164 "common-positions" or just "common" for all four. These 1165 control the parts of the common context's `CapabilitySet` 1166 that get reverted, as well as whether the focalization, 1167 active domains, and active decisions get reverted (those 1168 three as "positions"). 1169 - "c-*NAME*-capabilities" as well as -tokens, -skills, 1170 -positions, and without a suffix, where *NAME* is the name of 1171 a specific focal context. 1172 - "all-capabilities" as well as -tokens, -skills, -positions, 1173 and -contexts, reverting the relevant part of all focal 1174 contexts except the common one, with "all-contexts" reverting 1175 every part of all non-common focal contexts. 1176 - "current-capabilities" as well as -tokens, -skills, -positions, 1177 and without a suffix, for the currently-active focal context. 1178 - "primary" which reverts the primary decision (some positions should 1179 also be reverted in this case). 1180 - "mechanisms" which reverts mechanism states. 1181 - "exploration" which reverts the exploration state of decisions 1182 (note that the `DecisionGraph` also stores "unconfirmed" tags 1183 which are NOT affected by a revert unless "graph" is specified). 1184 - "effects" which reverts the record of how many times transition 1185 effects have been triggered, plus whether transitions have 1186 been disabled or not. 1187 - "custom" which reverts custom state. 1188 - "graph" reverts the graph itself (but this is usually not 1189 desired). This will still preserve the next-ID value for 1190 assigning new nodes, so that nodes created in a reverted graph 1191 will not re-use IDs from nodes created before the reversion. 1192 - "-*NAME*" where *NAME* is a custom reversion specification 1193 defined using `core.DecisionGraph.reversionType` and available 1194 in the "current" decision graph (note the dash is required 1195 before the custom name). This allows complex reversion systems 1196 to be set up once and referenced repeatedly. Any strings 1197 specified along with a custom reversion type will revert the 1198 specified state in addition to what the custom reversion type 1199 specifies. 1200 1201 For example: 1202 1203 >>> from . import core 1204 >>> g = core.DecisionGraph.example("simple") # A - B - C triangle 1205 >>> g.setTransitionRequirement('B', 'next', ReqCapability('helmet')) 1206 >>> g.addAction( 1207 ... 'A', 1208 ... 'getHelmet', 1209 ... consequence=[effect(gain='helmet'), effect(deactivate=True)] 1210 ... ) 1211 >>> s0 = basicState() 1212 >>> fc0 = s0['contexts']['main'] 1213 >>> fc0['activeDecisions']['main'] = 0 # A 1214 >>> s1 = basicState() 1215 >>> fc1 = s1['contexts']['main'] 1216 >>> fc1['capabilities']['capabilities'].add('helmet') 1217 >>> fc1['activeDecisions']['main'] = 1 # B 1218 >>> s1['exploration'] = {0: 'explored', 1: 'exploring', 2: 'unknown'} 1219 >>> s1['effectCounts'] = {(0, 'getHelmet', 1): 1} 1220 >>> s1['deactivated'] = {(0, "getHelmet")} 1221 >>> # Basic reversion of everything except graph & exploration 1222 >>> rg, rs = revertedState((g, s1), (g, s0), set()) 1223 >>> rg == g 1224 True 1225 >>> rg is g 1226 False 1227 >>> rs == s0 1228 False 1229 >>> rs is s0 1230 False 1231 >>> rs['contexts'] == s0['contexts'] 1232 True 1233 >>> rs['exploration'] == s1['exploration'] 1234 True 1235 >>> rs['effectCounts'] = s0['effectCounts'] 1236 >>> rs['deactivated'] = s0['deactivated'] 1237 >>> # Reverting capabilities but not position, exploration, or effects 1238 >>> rg, rs = revertedState((g, s1), (g, s0), {"current-capabilities"}) 1239 >>> rg == g 1240 True 1241 >>> rs == s0 or rs == s1 1242 False 1243 >>> s1['contexts']['main']['capabilities']['capabilities'] 1244 {'helmet'} 1245 >>> s0['contexts']['main']['capabilities']['capabilities'] 1246 set() 1247 >>> rs['contexts']['main']['capabilities']['capabilities'] 1248 set() 1249 >>> s1['contexts']['main']['activeDecisions']['main'] 1250 1 1251 >>> s0['contexts']['main']['activeDecisions']['main'] 1252 0 1253 >>> rs['contexts']['main']['activeDecisions']['main'] 1254 1 1255 >>> # Restore position and effects; that's all that wasn't reverted 1256 >>> rs['contexts']['main']['activeDecisions']['main'] = 0 1257 >>> rs['exploration'] = {} 1258 >>> rs['effectCounts'] = {} 1259 >>> rs['deactivated'] = set() 1260 >>> rs == s0 1261 True 1262 >>> # Reverting position but not state 1263 >>> rg, rs = revertedState((g, s1), (g, s0), {"current-positions"}) 1264 >>> rg == g 1265 True 1266 >>> s1['contexts']['main']['capabilities']['capabilities'] 1267 {'helmet'} 1268 >>> s0['contexts']['main']['capabilities']['capabilities'] 1269 set() 1270 >>> rs['contexts']['main']['capabilities']['capabilities'] 1271 {'helmet'} 1272 >>> s1['contexts']['main']['activeDecisions']['main'] 1273 1 1274 >>> s0['contexts']['main']['activeDecisions']['main'] 1275 0 1276 >>> rs['contexts']['main']['activeDecisions']['main'] 1277 0 1278 >>> # Reverting based on specific focal context name 1279 >>> rg2, rs2 = revertedState((g, s1), (g, s0), {"c-main-positions"}) 1280 >>> rg2 == rg 1281 True 1282 >>> rs2 == rs 1283 True 1284 >>> # Test of graph reversion 1285 >>> import copy 1286 >>> g2 = copy.deepcopy(g) 1287 >>> g2.addDecision('D') 1288 3 1289 >>> g2.addTransition(2, 'alt', 'D', 'return') 1290 >>> rg, rs = revertedState((g2, s1), (g, s0), {'graph'}) 1291 >>> rg == g 1292 True 1293 >>> rg is g 1294 False 1295 1296 TODO: More tests for various other reversion aspects 1297 TODO: Implement per-token-type / per-capability / per-mechanism / 1298 per-skill reversion. 1299 """ 1300 # Expand custom references 1301 expandedAspects = set() 1302 queue = list(revisionAspects) 1303 if len(queue) == 0: 1304 queue = [ # don't revert skills, exploration, and graph 1305 "common-capabilities", 1306 "common-tokens", 1307 "common-positions", 1308 "all-capabilities", 1309 "all-tokens", 1310 "all-positions", 1311 "mechanisms", 1312 "primary", 1313 "effects", 1314 "custom" 1315 ] # we do not include "graph" or "exploration" here... 1316 customLookup = currentStuff[0].reversionTypes 1317 while len(queue) > 0: 1318 aspect = queue.pop(0) 1319 if aspect.startswith('-'): 1320 customName = aspect[1:] 1321 if customName not in customLookup: 1322 raise ValueError( 1323 f"Custom reversion type {aspect[1:]!r} is invalid" 1324 f" because that reversion type has not been" 1325 f" defined. Defined types are:" 1326 f"\n{list(customLookup.keys())}" 1327 ) 1328 queue.extend(customLookup[customName]) 1329 else: 1330 expandedAspects.add(aspect) 1331 1332 # Further expand focal-context-part collectives 1333 if "common" in expandedAspects: 1334 expandedAspects.add("common-capabilities") 1335 expandedAspects.add("common-tokens") 1336 expandedAspects.add("common-skills") 1337 expandedAspects.add("common-positions") 1338 1339 if "all-contexts" in expandedAspects: 1340 expandedAspects.add("all-capabilities") 1341 expandedAspects.add("all-tokens") 1342 expandedAspects.add("all-skills") 1343 expandedAspects.add("all-positions") 1344 1345 if "current" in expandedAspects: 1346 expandedAspects.add("current-capabilities") 1347 expandedAspects.add("current-tokens") 1348 expandedAspects.add("current-skills") 1349 expandedAspects.add("current-positions") 1350 1351 # Figure out things to revert that are specific to named focal 1352 # contexts 1353 perFC: Dict[FocalContextName, Set[RestoreFCPart]] = {} 1354 currentFCName = currentStuff[1]['activeContext'] 1355 for aspect in expandedAspects: 1356 # For current- stuff, look up current context name 1357 if aspect.startswith("current"): 1358 found = False 1359 part: RestoreFCPart 1360 for part in get_args(RestoreFCPart): 1361 if aspect == f"current-{part}": 1362 perFC.setdefault(currentFCName, set()).add(part) 1363 found = True 1364 if not found and aspect != "current": 1365 raise RuntimeError(f"Invalid reversion aspect: {aspect!r}") 1366 elif aspect.startswith("c-"): 1367 if aspect.endswith("-capabilities"): 1368 fcName = aspect[2:-13] 1369 perFC.setdefault(fcName, set()).add("capabilities") 1370 elif aspect.endswith("-tokens"): 1371 fcName = aspect[2:-7] 1372 perFC.setdefault(fcName, set()).add("tokens") 1373 elif aspect.endswith("-skills"): 1374 fcName = aspect[2:-7] 1375 perFC.setdefault(fcName, set()).add("skills") 1376 elif aspect.endswith("-positions"): 1377 fcName = aspect[2:-10] 1378 perFC.setdefault(fcName, set()).add("positions") 1379 else: 1380 fcName = aspect[2:] 1381 forThis = perFC.setdefault(fcName, set()) 1382 forThis.add("capabilities") 1383 forThis.add("tokens") 1384 forThis.add("skills") 1385 forThis.add("positions") 1386 1387 currentState = currentStuff[1] 1388 savedState = savedStuff[1] 1389 1390 # Expand all-FC reversions to per-FC entries for each FC in both 1391 # current and prior states 1392 allFCs = set(currentState['contexts']) | set(savedState['contexts']) 1393 for part in get_args(RestoreFCPart): 1394 if f"all-{part}" in expandedAspects: 1395 for fcName in allFCs: 1396 perFC.setdefault(fcName, set()).add(part) 1397 1398 # Revert graph or not 1399 if "graph" in expandedAspects: 1400 resultGraph = copy.deepcopy(savedStuff[0]) 1401 # Patch nextID to avoid spurious ID matches 1402 resultGraph.nextID = currentStuff[0].nextID 1403 else: 1404 resultGraph = copy.deepcopy(currentStuff[0]) 1405 1406 # Start from non-reverted state copy 1407 resultState = copy.deepcopy(currentState) 1408 1409 # Revert primary decision or not 1410 if "primary" in expandedAspects: 1411 resultState['primaryDecision'] = savedState['primaryDecision'] 1412 1413 # Revert specified aspects of the common focal context 1414 savedCommon = savedState['common'] 1415 capKey: RestoreCapabilityPart 1416 for capKey in get_args(RestoreCapabilityPart): 1417 if f"common-{part}" in expandedAspects: 1418 resultState['common']['capabilities'][capKey] = copy.deepcopy( 1419 savedCommon['capabilities'][capKey] 1420 ) 1421 if "common-positions" in expandedAspects: 1422 fcKey: RestoreFCKey 1423 for fcKey in get_args(RestoreFCKey): 1424 resultState['common'][fcKey] = copy.deepcopy(savedCommon[fcKey]) 1425 1426 # Update focal context parts for named focal contexts: 1427 savedContextMap = savedState['contexts'] 1428 for fcName, restore in perFC.items(): 1429 thisFC = resultState['contexts'].setdefault( 1430 fcName, 1431 emptyFocalContext() 1432 ) # Create FC by name if it didn't exist already 1433 thatFC = savedContextMap.get(fcName) 1434 if thatFC is None: # what if it's a new one? 1435 if restore == set(get_args(RestoreFCPart)): 1436 # If we're restoring everything and the context didn't 1437 # exist in the prior state, delete it in the restored 1438 # state 1439 del resultState['contexts'][fcName] 1440 else: 1441 # Otherwise update parts of it to be blank since prior 1442 # state didn't have any info 1443 for part in restore: 1444 if part == "positions": 1445 thisFC['focalization'] = {} 1446 thisFC['activeDomains'] = set() 1447 thisFC['activeDecisions'] = {} 1448 elif part == "capabilities": 1449 thisFC['capabilities'][part] = set() 1450 else: 1451 thisFC['capabilities'][part] = {} 1452 else: # same context existed in saved data; update parts 1453 for part in restore: 1454 if part == "positions": 1455 for fcKey in get_args(RestoreFCKey): # typed above 1456 thisFC[fcKey] = copy.deepcopy(thatFC[fcKey]) 1457 else: 1458 thisFC['capabilities'][part] = copy.deepcopy( 1459 thatFC['capabilities'][part] 1460 ) 1461 1462 # Revert mechanisms, exploration, and/or custom state if specified 1463 statePart: RestoreStatePart 1464 for statePart in get_args(RestoreStatePart): 1465 if statePart in expandedAspects: 1466 resultState[statePart] = copy.deepcopy(savedState[statePart]) 1467 1468 # Revert effect tracking if specified 1469 if "effects" in expandedAspects: 1470 resultState['effectCounts'] = copy.deepcopy( 1471 savedState['effectCounts'] 1472 ) 1473 resultState['deactivated'] = copy.deepcopy(savedState['deactivated']) 1474 1475 return (resultGraph, resultState)
Given two (graph, state) pairs, as well as a set of reversion aspect strings, returns a (graph, state) pair representing the reverted graph and state. The provided graphs and states will not be modified, and the return value will not include references to them, so modifying the returned state will not modify the original or saved states or graphs.
If the revisionAspects
set is empty, then all aspects except
skills, exploration statuses, and the graph will be reverted.
Note that the reversion process can lead to impossible states if the wrong combination of reversion aspects is used (e.g., reverting the graph but not focal context position information might lead to positions that refer to decisions which do not exist).
Valid reversion aspect strings are:
- "common-capabilities", "common-tokens", "common-skills,"
"common-positions" or just "common" for all four. These
control the parts of the common context's
CapabilitySet
that get reverted, as well as whether the focalization, active domains, and active decisions get reverted (those three as "positions"). - "c-NAME-capabilities" as well as -tokens, -skills, -positions, and without a suffix, where NAME is the name of a specific focal context.
- "all-capabilities" as well as -tokens, -skills, -positions, and -contexts, reverting the relevant part of all focal contexts except the common one, with "all-contexts" reverting every part of all non-common focal contexts.
- "current-capabilities" as well as -tokens, -skills, -positions, and without a suffix, for the currently-active focal context.
- "primary" which reverts the primary decision (some positions should also be reverted in this case).
- "mechanisms" which reverts mechanism states.
- "exploration" which reverts the exploration state of decisions
(note that the
DecisionGraph
also stores "unconfirmed" tags which are NOT affected by a revert unless "graph" is specified). - "effects" which reverts the record of how many times transition effects have been triggered, plus whether transitions have been disabled or not.
- "custom" which reverts custom state.
- "graph" reverts the graph itself (but this is usually not desired). This will still preserve the next-ID value for assigning new nodes, so that nodes created in a reverted graph will not re-use IDs from nodes created before the reversion.
- "-NAME" where NAME is a custom reversion specification
defined using
core.DecisionGraph.reversionType
and available in the "current" decision graph (note the dash is required before the custom name). This allows complex reversion systems to be set up once and referenced repeatedly. Any strings specified along with a custom reversion type will revert the specified state in addition to what the custom reversion type specifies.
For example:
>>> from . import core
>>> g = core.DecisionGraph.example("simple") # A - B - C triangle
>>> g.setTransitionRequirement('B', 'next', ReqCapability('helmet'))
>>> g.addAction(
... 'A',
... 'getHelmet',
... consequence=[effect(gain='helmet'), effect(deactivate=True)]
... )
>>> s0 = basicState()
>>> fc0 = s0['contexts']['main']
>>> fc0['activeDecisions']['main'] = 0 # A
>>> s1 = basicState()
>>> fc1 = s1['contexts']['main']
>>> fc1['capabilities']['capabilities'].add('helmet')
>>> fc1['activeDecisions']['main'] = 1 # B
>>> s1['exploration'] = {0: 'explored', 1: 'exploring', 2: 'unknown'}
>>> s1['effectCounts'] = {(0, 'getHelmet', 1): 1}
>>> s1['deactivated'] = {(0, "getHelmet")}
>>> # Basic reversion of everything except graph & exploration
>>> rg, rs = revertedState((g, s1), (g, s0), set())
>>> rg == g
True
>>> rg is g
False
>>> rs == s0
False
>>> rs is s0
False
>>> rs['contexts'] == s0['contexts']
True
>>> rs['exploration'] == s1['exploration']
True
>>> rs['effectCounts'] = s0['effectCounts']
>>> rs['deactivated'] = s0['deactivated']
>>> # Reverting capabilities but not position, exploration, or effects
>>> rg, rs = revertedState((g, s1), (g, s0), {"current-capabilities"})
>>> rg == g
True
>>> rs == s0 or rs == s1
False
>>> s1['contexts']['main']['capabilities']['capabilities']
{'helmet'}
>>> s0['contexts']['main']['capabilities']['capabilities']
set()
>>> rs['contexts']['main']['capabilities']['capabilities']
set()
>>> s1['contexts']['main']['activeDecisions']['main']
1
>>> s0['contexts']['main']['activeDecisions']['main']
0
>>> rs['contexts']['main']['activeDecisions']['main']
1
>>> # Restore position and effects; that's all that wasn't reverted
>>> rs['contexts']['main']['activeDecisions']['main'] = 0
>>> rs['exploration'] = {}
>>> rs['effectCounts'] = {}
>>> rs['deactivated'] = set()
>>> rs == s0
True
>>> # Reverting position but not state
>>> rg, rs = revertedState((g, s1), (g, s0), {"current-positions"})
>>> rg == g
True
>>> s1['contexts']['main']['capabilities']['capabilities']
{'helmet'}
>>> s0['contexts']['main']['capabilities']['capabilities']
set()
>>> rs['contexts']['main']['capabilities']['capabilities']
{'helmet'}
>>> s1['contexts']['main']['activeDecisions']['main']
1
>>> s0['contexts']['main']['activeDecisions']['main']
0
>>> rs['contexts']['main']['activeDecisions']['main']
0
>>> # Reverting based on specific focal context name
>>> rg2, rs2 = revertedState((g, s1), (g, s0), {"c-main-positions"})
>>> rg2 == rg
True
>>> rs2 == rs
True
>>> # Test of graph reversion
>>> import copy
>>> g2 = copy.deepcopy(g)
>>> g2.addDecision('D')
3
>>> g2.addTransition(2, 'alt', 'D', 'return')
>>> rg, rs = revertedState((g2, s1), (g, s0), {'graph'})
>>> rg == g
True
>>> rg is g
False
TODO: More tests for various other reversion aspects TODO: Implement per-token-type / per-capability / per-mechanism / per-skill reversion.
1482class RequirementContext(NamedTuple): 1483 """ 1484 The context necessary to check whether a requirement is satisfied or 1485 not. Also used for computing effective skill levels for 1486 `SkillCombination`s. Includes a `State` that specifies `Capability` 1487 and `Token` states, a `DecisionGraph` (which includes equivalences), 1488 and a set of `DecisionID`s to use as the starting place for finding 1489 mechanisms by name. 1490 """ 1491 state: State 1492 graph: 'DecisionGraph' 1493 searchFrom: Set[DecisionID]
The context necessary to check whether a requirement is satisfied or
not. Also used for computing effective skill levels for
SkillCombination
s. Includes a State
that specifies Capability
and Token
states, a DecisionGraph
(which includes equivalences),
and a set of DecisionID
s to use as the starting place for finding
mechanisms by name.
Create new instance of RequirementContext(state, graph, searchFrom)
Inherited Members
- builtins.tuple
- index
- count
1496def getSkillLevel(state: State, skill: Skill) -> Level: 1497 """ 1498 Given a `State` and a `Skill`, looks up that skill in both the 1499 common and active `FocalContext`s of the state, and adds those 1500 numbers together to get an effective skill level for that skill. 1501 Note that `SkillCombination`s can be used to set up more complex 1502 logic for skill combinations across different skills; if adding 1503 levels isn't desired between `FocalContext`s, use different skill 1504 names. 1505 1506 If the skill isn't mentioned, the level will count as 0. 1507 """ 1508 commonContext = state['common'] 1509 activeContext = state['contexts'][state['activeContext']] 1510 return ( 1511 commonContext['capabilities']['skills'].get(skill, 0) 1512 + activeContext['capabilities']['skills'].get(skill, 0) 1513 )
Given a State
and a Skill
, looks up that skill in both the
common and active FocalContext
s of the state, and adds those
numbers together to get an effective skill level for that skill.
Note that SkillCombination
s can be used to set up more complex
logic for skill combinations across different skills; if adding
levels isn't desired between FocalContext
s, use different skill
names.
If the skill isn't mentioned, the level will count as 0.
The types that effects can use. See Effect
for details.
A union of all possible effect types.
1554class Effect(TypedDict): 1555 """ 1556 Represents one effect of a transition on the decision graph and/or 1557 game state. The `type` slot is an `EffectType` that indicates what 1558 type of effect it is, and determines what the `value` slot will hold. 1559 The `charges` slot is normally `None`, but when set to an integer, 1560 the effect will only trigger that many times, subtracting one charge 1561 each time until it reaches 0, after which the effect will remain but 1562 be ignored. The `delay` slot is also normally `None`, but when set to 1563 an integer, the effect won't trigger but will instead subtract one 1564 from the delay until it reaches zero, at which point it will start to 1565 trigger (and use up charges if there are any). The 'applyTo' slot 1566 should be either 'common' or 'active' (a `ContextSpecifier`) and 1567 determines which focal context the effect applies to. 1568 1569 The `value` values for each `type` are: 1570 1571 - `'gain'`: A `Capability`, (`Token`, `TokenCount`) pair, or 1572 ('skill', `Skill`, `Level`) triple indicating a capability 1573 gained, some tokens acquired, or skill levels gained. 1574 - `'lose'`: A `Capability`, (`Token`, `TokenCount`) pair, or 1575 ('skill', `Skill`, `Level`) triple indicating a capability lost, 1576 some tokens spent, or skill levels lost. Note that the literal 1577 string 'skill' is added to disambiguate skills from tokens. 1578 - `'set'`: A (`Token`, `TokenCount`) pair, a (`MechanismSpecifier`, 1579 `MechanismState`) pair, or a ('skill', `Skill`, `Level`) triple 1580 indicating the new number of tokens, new mechanism state, or new 1581 skill level to establish. Ignores the old count/level, unlike 1582 'gain' and 'lose.' 1583 - `'toggle'`: A list of capabilities which will be toggled on one 1584 after the other, toggling the rest off, OR, a tuple containing a 1585 mechanism name followed by a list of states to be set one after 1586 the other. Does not work for tokens or skills. If a `Capability` 1587 list only has one item, it will be toggled on or off depending 1588 on whether the player currently has that capability or not, 1589 otherwise, whichever capability in the toggle list is currently 1590 active will determine which one gets activated next (the 1591 subsequent one in the list, wrapping from the end to the start). 1592 Note that equivalences are NOT considered when determining which 1593 capability to turn on, and ALL capabilities in the toggle list 1594 except the next one to turn on are turned off. Also, if none of 1595 the capabilities in the list is currently activated, the first 1596 one will be. For mechanisms, `DEFAULT_MECHANISM_STATE` will be 1597 used as the default state if only one state is provided, since 1598 mechanisms can't be "not in a state." `Mechanism` toggles 1599 function based on the current mechanism state; if it's not in 1600 the list they set the first given state. 1601 - `'deactivate'`: `None`. When the effect is activated, the 1602 transition it applies on will be added to the deactivated set in 1603 the current state. This effect type ignores the 'applyTo' value 1604 since it does not make changes to a `FocalContext`. 1605 - `'edit'`: A list of lists of `Command`s, with each list to be 1606 applied in succession on every subsequent activation of the 1607 transition (like toggle). These can use extra variables '$@' to 1608 refer to the source decision of the transition the edit effect is 1609 attached to, '$@d' to refer to the destination decision, '$@t' to 1610 refer to the transition, and '$@r' to refer to its reciprocal. 1611 Commands are powerful and might edit more than just the 1612 specified focal context. 1613 TODO: How to handle list-of-lists format? 1614 - `'goto'`: Either an `AnyDecisionSpecifier` specifying where the 1615 player should end up, or an (`AnyDecisionSpecifier`, 1616 `FocalPointName`) specifying both where they should end up and 1617 which focal point in the relevant domain should be moved. If 1618 multiple 'goto' values are present on different effects of a 1619 transition, they each trigger in turn (and e.g., might activate 1620 multiple decision points in a spreading-focalized domain). Every 1621 transition has a destination, so 'goto' is not necessary: use it 1622 only when an attempt to take a transition is diverted (and 1623 normally, in conjunction with 'charges', 'delay', and/or as an 1624 effect that's behind a `Challenge` or `Conditional`). If a goto 1625 specifies a destination in a plural-focalized domain, but does 1626 not include a focal point name, then the focal point which was 1627 taking the transition will be the one to move. If that 1628 information is not available, the first focal point created in 1629 that domain will be moved by default. Note that when using 1630 something other than a destination ID as the 1631 `AnyDecisionSpecifier`, it's up to you to ensure that the 1632 specifier is not ambiguous, otherwise taking the transition will 1633 crash the program. 1634 - `'bounce'`: Value will be `None`. Prevents the normal position 1635 update associated with a transition that this effect applies to. 1636 Normally, a transition should be marked with an appropriate 1637 requirement to prevent access, even in cases where access seems 1638 possible until tested (just add the requirement on a step after 1639 the transition is observed where relevant). However, 'bounce' can 1640 be used in cases where there's a challenge to fail, for example. 1641 `bounce` is redundant with `goto`: if a `goto` effect applies on 1642 a certain transition, the presence or absence of `bounce` on the 1643 same transition is ignored, since the new position will be 1644 specified by the `goto` value anyways. 1645 - `'follow'`: Value will be a `Transition` name. A transition with 1646 that name must exist at the destination of the action, and when 1647 the follow effect triggers, the player will immediately take 1648 that transition (triggering any consequences it has) after 1649 arriving at their normal destination (so the exploration status 1650 of the normal destination will also be updated). This can result 1651 in an infinite loop if two 'follow' effects imply transitions 1652 which trigger each other, so don't do that. 1653 - `'save'`: Value will be a string indicating a save-slot name. 1654 Indicates a save point, which can be returned to using a 1655 'revertTo' `ExplorationAction`. The entire game state and current 1656 graph is recorded, including effects of the current consequence 1657 before, but not after, the 'save' effect. However, the graph 1658 configuration is not restored by default (see 'revert'). A revert 1659 effect may specify only parts of the state to revert. 1660 1661 TODO: 1662 'focus', 1663 'foreground', 1664 'background', 1665 """ 1666 type: EffectType 1667 applyTo: ContextSpecifier 1668 value: AnyEffectValue 1669 charges: Optional[int] 1670 delay: Optional[int] 1671 hidden: bool
Represents one effect of a transition on the decision graph and/or
game state. The type
slot is an EffectType
that indicates what
type of effect it is, and determines what the value
slot will hold.
The charges
slot is normally None
, but when set to an integer,
the effect will only trigger that many times, subtracting one charge
each time until it reaches 0, after which the effect will remain but
be ignored. The delay
slot is also normally None
, but when set to
an integer, the effect won't trigger but will instead subtract one
from the delay until it reaches zero, at which point it will start to
trigger (and use up charges if there are any). The 'applyTo' slot
should be either 'common' or 'active' (a ContextSpecifier
) and
determines which focal context the effect applies to.
The value
values for each type
are:
'gain'
: ACapability
, (Token
,TokenCount
) pair, or ('skill',Skill
,Level
) triple indicating a capability gained, some tokens acquired, or skill levels gained.'lose'
: ACapability
, (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 aCapability
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 aFocalContext
.'edit'
: A list of lists ofCommand
s, 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 anAnyDecisionSpecifier
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 aChallenge
orConditional
). 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 theAnyDecisionSpecifier
, it's up to you to ensure that the specifier is not ambiguous, otherwise taking the transition will crash the program.'bounce'
: Value will beNone
. 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 withgoto
: if agoto
effect applies on a certain transition, the presence or absence ofbounce
on the same transition is ignored, since the new position will be specified by thegoto
value anyways.'follow'
: Value will be aTransition
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',
1674def effect( 1675 *, 1676 applyTo: ContextSpecifier = 'active', 1677 gain: Optional[Union[ 1678 Capability, 1679 Tuple[Token, TokenCount], 1680 Tuple[Literal['skill'], Skill, Level] 1681 ]] = None, 1682 lose: Optional[Union[ 1683 Capability, 1684 Tuple[Token, TokenCount], 1685 Tuple[Literal['skill'], Skill, Level] 1686 ]] = None, 1687 set: Optional[Union[ 1688 Tuple[Token, TokenCount], 1689 Tuple[AnyMechanismSpecifier, MechanismState], 1690 Tuple[Literal['skill'], Skill, Level] 1691 ]] = None, 1692 toggle: Optional[Union[ 1693 Tuple[AnyMechanismSpecifier, List[MechanismState]], 1694 List[Capability] 1695 ]] = None, 1696 deactivate: Optional[bool] = None, 1697 edit: Optional[List[List[commands.Command]]] = None, 1698 goto: Optional[Union[ 1699 AnyDecisionSpecifier, 1700 Tuple[AnyDecisionSpecifier, FocalPointName] 1701 ]] = None, 1702 bounce: Optional[bool] = None, 1703 follow: Optional[Transition] = None, 1704 save: Optional[SaveSlot] = None, 1705 delay: Optional[int] = None, 1706 charges: Optional[int] = None, 1707 hidden: bool = False 1708) -> Effect: 1709 """ 1710 Factory for a transition effect which includes default values so you 1711 can just specify effect types that are relevant to a particular 1712 situation. You may not supply values for more than one of 1713 gain/lose/set/toggle/deactivate/edit/goto/bounce, since which one 1714 you use determines the effect type. 1715 """ 1716 tCount = len([ 1717 x 1718 for x in ( 1719 gain, 1720 lose, 1721 set, 1722 toggle, 1723 deactivate, 1724 edit, 1725 goto, 1726 bounce, 1727 follow, 1728 save 1729 ) 1730 if x is not None 1731 ]) 1732 if tCount == 0: 1733 raise ValueError( 1734 "You must specify one of gain, lose, set, toggle, deactivate," 1735 " edit, goto, bounce, follow, or save." 1736 ) 1737 elif tCount > 1: 1738 raise ValueError( 1739 f"You may only specify one of gain, lose, set, toggle," 1740 f" deactivate, edit, goto, bounce, follow, or save" 1741 f" (you provided values for {tCount} of those)." 1742 ) 1743 1744 result: Effect = { 1745 'type': 'edit', 1746 'applyTo': applyTo, 1747 'value': [], 1748 'delay': delay, 1749 'charges': charges, 1750 'hidden': hidden 1751 } 1752 1753 if gain is not None: 1754 result['type'] = 'gain' 1755 result['value'] = gain 1756 elif lose is not None: 1757 result['type'] = 'lose' 1758 result['value'] = lose 1759 elif set is not None: 1760 result['type'] = 'set' 1761 if ( 1762 len(set) == 2 1763 and isinstance(set[0], MechanismName) 1764 and isinstance(set[1], MechanismState) 1765 ): 1766 result['value'] = ( 1767 MechanismSpecifier(None, None, None, set[0]), 1768 set[1] 1769 ) 1770 else: 1771 result['value'] = set 1772 elif toggle is not None: 1773 result['type'] = 'toggle' 1774 result['value'] = toggle 1775 elif deactivate is not None: 1776 result['type'] = 'deactivate' 1777 result['value'] = None 1778 elif edit is not None: 1779 result['type'] = 'edit' 1780 result['value'] = edit 1781 elif goto is not None: 1782 result['type'] = 'goto' 1783 result['value'] = goto 1784 elif bounce is not None: 1785 result['type'] = 'bounce' 1786 result['value'] = None 1787 elif follow is not None: 1788 result['type'] = 'follow' 1789 result['value'] = follow 1790 elif save is not None: 1791 result['type'] = 'save' 1792 result['value'] = save 1793 else: 1794 raise RuntimeError( 1795 "No effect specified in effect function & check failed." 1796 ) 1797 1798 return result
Factory for a transition effect which includes default values so you can just specify effect types that are relevant to a particular situation. You may not supply values for more than one of gain/lose/set/toggle/deactivate/edit/goto/bounce, since which one you use determines the effect type.
1801class SkillCombination: 1802 """ 1803 Represents which skill(s) are used for a `Challenge`, including under 1804 what circumstances different skills might apply using 1805 `Requirement`s. This is an abstract class, use the subclasses 1806 `BestSkill`, `WorstSkill`, `CombinedSkill`, `InverseSkill`, and/or 1807 `ConditionalSkill` to represent a specific situation. To represent a 1808 single required skill, use a `BestSkill` or `CombinedSkill` with 1809 that skill as the only skill. 1810 1811 Use `SkillCombination.effectiveLevel` to figure out the effective 1812 level of the entire requirement in a given situation. Note that 1813 levels from the common and active `FocalContext`s are added together 1814 whenever a specific skill level is referenced. 1815 1816 Some examples: 1817 1818 >>> from . import core 1819 >>> ctx = RequirementContext(emptyState(), core.DecisionGraph(), set()) 1820 >>> ctx.state['common']['capabilities']['skills']['brawn'] = 1 1821 >>> ctx.state['common']['capabilities']['skills']['brains'] = 3 1822 >>> ctx.state['common']['capabilities']['skills']['luck'] = -1 1823 1824 1. To represent using just the 'brains' skill, you would use: 1825 1826 `BestSkill('brains')` 1827 1828 >>> sr = BestSkill('brains') 1829 >>> sr.effectiveLevel(ctx) 1830 3 1831 1832 If a skill isn't listed, its level counts as 0: 1833 1834 >>> sr = BestSkill('agility') 1835 >>> sr.effectiveLevel(ctx) 1836 0 1837 1838 To represent using the higher of 'brains' or 'brawn' you'd use: 1839 1840 `BestSkill('brains', 'brawn')` 1841 1842 >>> sr = BestSkill('brains', 'brawn') 1843 >>> sr.effectiveLevel(ctx) 1844 3 1845 1846 The zero default only applies if an unknown skill is in the mix: 1847 1848 >>> sr = BestSkill('luck') 1849 >>> sr.effectiveLevel(ctx) 1850 -1 1851 >>> sr = BestSkill('luck', 'agility') 1852 >>> sr.effectiveLevel(ctx) 1853 0 1854 1855 2. To represent using the lower of 'brains' or 'brawn' you'd use: 1856 1857 `WorstSkill('brains', 'brawn')` 1858 1859 >>> sr = WorstSkill('brains', 'brawn') 1860 >>> sr.effectiveLevel(ctx) 1861 1 1862 1863 3. To represent using 'brawn' if the focal context has the 'brawny' 1864 capability, but brains if not, use: 1865 1866 ``` 1867 ConditionalSkill( 1868 ReqCapability('brawny'), 1869 'brawn', 1870 'brains' 1871 ) 1872 ``` 1873 1874 >>> sr = ConditionalSkill( 1875 ... ReqCapability('brawny'), 1876 ... 'brawn', 1877 ... 'brains' 1878 ... ) 1879 >>> sr.effectiveLevel(ctx) 1880 3 1881 >>> brawny = copy.deepcopy(ctx) 1882 >>> brawny.state['common']['capabilities']['capabilities'].add( 1883 ... 'brawny' 1884 ... ) 1885 >>> sr.effectiveLevel(brawny) 1886 1 1887 1888 If the player can still choose to use 'brains' even when they 1889 have the 'brawny' capability, you would do: 1890 1891 >>> sr = ConditionalSkill( 1892 ... ReqCapability('brawny'), 1893 ... BestSkill('brawn', 'brains'), 1894 ... 'brains' 1895 ... ) 1896 >>> sr.effectiveLevel(ctx) 1897 3 1898 >>> sr.effectiveLevel(brawny) # can still use brains if better 1899 3 1900 1901 4. To represent using the combined level of the 'brains' and 1902 'brawn' skills, you would use: 1903 1904 `CombinedSkill('brains', 'brawn')` 1905 1906 >>> sr = CombinedSkill('brains', 'brawn') 1907 >>> sr.effectiveLevel(ctx) 1908 4 1909 1910 5. Skill names can be replaced by entire sub-`SkillCombination`s in 1911 any position, so more complex forms are possible: 1912 1913 >>> sr = BestSkill(CombinedSkill('brains', 'luck'), 'brawn') 1914 >>> sr.effectiveLevel(ctx) 1915 2 1916 >>> sr = BestSkill( 1917 ... ConditionalSkill( 1918 ... ReqCapability('brawny'), 1919 ... 'brawn', 1920 ... 'brains', 1921 ... ), 1922 ... CombinedSkill('brains', 'luck') 1923 ... ) 1924 >>> sr.effectiveLevel(ctx) 1925 3 1926 >>> sr.effectiveLevel(brawny) 1927 2 1928 """ 1929 def effectiveLevel(self, context: 'RequirementContext') -> Level: 1930 """ 1931 Returns the effective `Level` of the skill combination, given 1932 the situation specified by the provided `RequirementContext`. 1933 """ 1934 raise NotImplementedError( 1935 "SkillCombination is an abstract class. Use one of its" 1936 " subclsases instead." 1937 ) 1938 1939 def __eq__(self, other: Any) -> bool: 1940 raise NotImplementedError( 1941 "SkillCombination is an abstract class and cannot be compared." 1942 ) 1943 1944 def __hash__(self) -> int: 1945 raise NotImplementedError( 1946 "SkillCombination is an abstract class and cannot be hashed." 1947 ) 1948 1949 def walk(self) -> Generator[ 1950 Union['SkillCombination', Skill, Level], 1951 None, 1952 None 1953 ]: 1954 """ 1955 Yields this combination and each sub-part in depth-first 1956 traversal order. 1957 """ 1958 raise NotImplementedError( 1959 "SkillCombination is an abstract class and cannot be walked." 1960 ) 1961 1962 def unparse(self) -> str: 1963 """ 1964 Returns a string that `SkillCombination.parse` would turn back 1965 into a `SkillCombination` equivalent to this one. For example: 1966 1967 >>> BestSkill('brains').unparse() 1968 'best(brains)' 1969 >>> WorstSkill('brains', 'brawn').unparse() 1970 'worst(brains, brawn)' 1971 >>> CombinedSkill( 1972 ... ConditionalSkill(ReqTokens('orb', 3), 'brains', 0), 1973 ... InverseSkill('luck') 1974 ... ).unparse() 1975 'sum(if(orb*3, brains, 0), ~luck)' 1976 """ 1977 raise NotImplementedError( 1978 "SkillCombination is an abstract class and cannot be" 1979 " unparsed." 1980 )
Represents which skill(s) are used for a Challenge
, including under
what circumstances different skills might apply using
Requirement
s. 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 FocalContext
s 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
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
To represent using the lower of 'brains' or 'brawn' you'd use:
WorstSkill('brains', 'brawn')
>>> sr = WorstSkill('brains', 'brawn') >>> sr.effectiveLevel(ctx) 1
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
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
Skill names can be replaced by entire sub-
SkillCombination
s 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
1929 def effectiveLevel(self, context: 'RequirementContext') -> Level: 1930 """ 1931 Returns the effective `Level` of the skill combination, given 1932 the situation specified by the provided `RequirementContext`. 1933 """ 1934 raise NotImplementedError( 1935 "SkillCombination is an abstract class. Use one of its" 1936 " subclsases instead." 1937 )
Returns the effective Level
of the skill combination, given
the situation specified by the provided RequirementContext
.
1949 def walk(self) -> Generator[ 1950 Union['SkillCombination', Skill, Level], 1951 None, 1952 None 1953 ]: 1954 """ 1955 Yields this combination and each sub-part in depth-first 1956 traversal order. 1957 """ 1958 raise NotImplementedError( 1959 "SkillCombination is an abstract class and cannot be walked." 1960 )
Yields this combination and each sub-part in depth-first traversal order.
1962 def unparse(self) -> str: 1963 """ 1964 Returns a string that `SkillCombination.parse` would turn back 1965 into a `SkillCombination` equivalent to this one. For example: 1966 1967 >>> BestSkill('brains').unparse() 1968 'best(brains)' 1969 >>> WorstSkill('brains', 'brawn').unparse() 1970 'worst(brains, brawn)' 1971 >>> CombinedSkill( 1972 ... ConditionalSkill(ReqTokens('orb', 3), 'brains', 0), 1973 ... InverseSkill('luck') 1974 ... ).unparse() 1975 'sum(if(orb*3, brains, 0), ~luck)' 1976 """ 1977 raise NotImplementedError( 1978 "SkillCombination is an abstract class and cannot be" 1979 " unparsed." 1980 )
Returns a string that SkillCombination.parse
would turn back
into a SkillCombination
equivalent to this one. For example:
>>> BestSkill('brains').unparse()
'best(brains)'
>>> WorstSkill('brains', 'brawn').unparse()
'worst(brains, brawn)'
>>> CombinedSkill(
... ConditionalSkill(ReqTokens('orb', 3), 'brains', 0),
... InverseSkill('luck')
... ).unparse()
'sum(if(orb*3, brains, 0), ~luck)'
1983class BestSkill(SkillCombination): 1984 def __init__( 1985 self, 1986 *skills: Union[SkillCombination, Skill, Level] 1987 ): 1988 """ 1989 Given one or more `SkillCombination` sub-items and/or skill 1990 names or levels, represents a situation where the highest 1991 effective level among the sub-items is used. Skill names 1992 translate to the player's level for that skill (with 0 as a 1993 default) while level numbers translate to that number. 1994 """ 1995 if len(skills) == 0: 1996 raise ValueError( 1997 "Cannot create a `BestSkill` with 0 sub-skills." 1998 ) 1999 self.skills = skills 2000 2001 def __eq__(self, other: Any) -> bool: 2002 return isinstance(other, BestSkill) and other.skills == self.skills 2003 2004 def __hash__(self) -> int: 2005 result = 1829 2006 for sk in self.skills: 2007 result += hash(sk) 2008 return result 2009 2010 def __repr__(self) -> str: 2011 subs = ', '.join(repr(sk) for sk in self.skills) 2012 return "BestSkill(" + subs + ")" 2013 2014 def walk(self) -> Generator[ 2015 Union[SkillCombination, Skill, Level], 2016 None, 2017 None 2018 ]: 2019 yield self 2020 for sub in self.skills: 2021 if isinstance(sub, (Skill, Level)): 2022 yield sub 2023 else: 2024 yield from sub.walk() 2025 2026 def effectiveLevel(self, ctx: 'RequirementContext') -> Level: 2027 """ 2028 Determines the effective level of each sub-skill-combo and 2029 returns the highest of those. 2030 """ 2031 result = None 2032 level: Level 2033 if len(self.skills) == 0: 2034 raise RuntimeError("Invalid BestSkill: has zero sub-skills.") 2035 for sk in self.skills: 2036 if isinstance(sk, Level): 2037 level = sk 2038 elif isinstance(sk, Skill): 2039 level = getSkillLevel(ctx.state, sk) 2040 elif isinstance(sk, SkillCombination): 2041 level = sk.effectiveLevel(ctx) 2042 else: 2043 raise RuntimeError( 2044 f"Invalid BestSkill: found sub-skill '{repr(sk)}'" 2045 f" which is not a skill name string, level integer," 2046 f" or SkillCombination." 2047 ) 2048 if result is None or result < level: 2049 result = level 2050 2051 assert result is not None 2052 return result 2053 2054 def unparse(self): 2055 result = "best(" 2056 for sk in self.skills: 2057 if isinstance(sk, SkillCombination): 2058 result += sk.unparse() 2059 else: 2060 result += str(sk) 2061 result += ', ' 2062 return result[:-2] + ')'
Represents which skill(s) are used for a Challenge
, including under
what circumstances different skills might apply using
Requirement
s. 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 FocalContext
s 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
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
To represent using the lower of 'brains' or 'brawn' you'd use:
WorstSkill('brains', 'brawn')
>>> sr = WorstSkill('brains', 'brawn') >>> sr.effectiveLevel(ctx) 1
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
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
Skill names can be replaced by entire sub-
SkillCombination
s 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
1984 def __init__( 1985 self, 1986 *skills: Union[SkillCombination, Skill, Level] 1987 ): 1988 """ 1989 Given one or more `SkillCombination` sub-items and/or skill 1990 names or levels, represents a situation where the highest 1991 effective level among the sub-items is used. Skill names 1992 translate to the player's level for that skill (with 0 as a 1993 default) while level numbers translate to that number. 1994 """ 1995 if len(skills) == 0: 1996 raise ValueError( 1997 "Cannot create a `BestSkill` with 0 sub-skills." 1998 ) 1999 self.skills = skills
Given one or more SkillCombination
sub-items and/or skill
names or levels, represents a situation where the highest
effective level among the sub-items is used. Skill names
translate to the player's level for that skill (with 0 as a
default) while level numbers translate to that number.
2014 def walk(self) -> Generator[ 2015 Union[SkillCombination, Skill, Level], 2016 None, 2017 None 2018 ]: 2019 yield self 2020 for sub in self.skills: 2021 if isinstance(sub, (Skill, Level)): 2022 yield sub 2023 else: 2024 yield from sub.walk()
Yields this combination and each sub-part in depth-first traversal order.
2026 def effectiveLevel(self, ctx: 'RequirementContext') -> Level: 2027 """ 2028 Determines the effective level of each sub-skill-combo and 2029 returns the highest of those. 2030 """ 2031 result = None 2032 level: Level 2033 if len(self.skills) == 0: 2034 raise RuntimeError("Invalid BestSkill: has zero sub-skills.") 2035 for sk in self.skills: 2036 if isinstance(sk, Level): 2037 level = sk 2038 elif isinstance(sk, Skill): 2039 level = getSkillLevel(ctx.state, sk) 2040 elif isinstance(sk, SkillCombination): 2041 level = sk.effectiveLevel(ctx) 2042 else: 2043 raise RuntimeError( 2044 f"Invalid BestSkill: found sub-skill '{repr(sk)}'" 2045 f" which is not a skill name string, level integer," 2046 f" or SkillCombination." 2047 ) 2048 if result is None or result < level: 2049 result = level 2050 2051 assert result is not None 2052 return result
Determines the effective level of each sub-skill-combo and returns the highest of those.
2054 def unparse(self): 2055 result = "best(" 2056 for sk in self.skills: 2057 if isinstance(sk, SkillCombination): 2058 result += sk.unparse() 2059 else: 2060 result += str(sk) 2061 result += ', ' 2062 return result[:-2] + ')'
Returns a string that SkillCombination.parse
would turn back
into a SkillCombination
equivalent to this one. For example:
>>> BestSkill('brains').unparse()
'best(brains)'
>>> WorstSkill('brains', 'brawn').unparse()
'worst(brains, brawn)'
>>> CombinedSkill(
... ConditionalSkill(ReqTokens('orb', 3), 'brains', 0),
... InverseSkill('luck')
... ).unparse()
'sum(if(orb*3, brains, 0), ~luck)'
2065class WorstSkill(SkillCombination): 2066 def __init__( 2067 self, 2068 *skills: Union[SkillCombination, Skill, Level] 2069 ): 2070 """ 2071 Given one or more `SkillCombination` sub-items and/or skill 2072 names or levels, represents a situation where the lowest 2073 effective level among the sub-items is used. Skill names 2074 translate to the player's level for that skill (with 0 as a 2075 default) while level numbers translate to that number. 2076 """ 2077 if len(skills) == 0: 2078 raise ValueError( 2079 "Cannot create a `WorstSkill` with 0 sub-skills." 2080 ) 2081 self.skills = skills 2082 2083 def __eq__(self, other: Any) -> bool: 2084 return isinstance(other, WorstSkill) and other.skills == self.skills 2085 2086 def __hash__(self) -> int: 2087 result = 7182 2088 for sk in self.skills: 2089 result += hash(sk) 2090 return result 2091 2092 def __repr__(self) -> str: 2093 subs = ', '.join(repr(sk) for sk in self.skills) 2094 return "WorstSkill(" + subs + ")" 2095 2096 def walk(self) -> Generator[ 2097 Union[SkillCombination, Skill, Level], 2098 None, 2099 None 2100 ]: 2101 yield self 2102 for sub in self.skills: 2103 if isinstance(sub, (Skill, Level)): 2104 yield sub 2105 else: 2106 yield from sub.walk() 2107 2108 def effectiveLevel(self, ctx: 'RequirementContext') -> Level: 2109 """ 2110 Determines the effective level of each sub-skill-combo and 2111 returns the lowest of those. 2112 """ 2113 result = None 2114 level: Level 2115 if len(self.skills) == 0: 2116 raise RuntimeError("Invalid WorstSkill: has zero sub-skills.") 2117 for sk in self.skills: 2118 if isinstance(sk, Level): 2119 level = sk 2120 elif isinstance(sk, Skill): 2121 level = getSkillLevel(ctx.state, sk) 2122 elif isinstance(sk, SkillCombination): 2123 level = sk.effectiveLevel(ctx) 2124 else: 2125 raise RuntimeError( 2126 f"Invalid WorstSkill: found sub-skill '{repr(sk)}'" 2127 f" which is not a skill name string, level integer," 2128 f" or SkillCombination." 2129 ) 2130 if result is None or result > level: 2131 result = level 2132 2133 assert result is not None 2134 return result 2135 2136 def unparse(self): 2137 result = "worst(" 2138 for sk in self.skills: 2139 if isinstance(sk, SkillCombination): 2140 result += sk.unparse() 2141 else: 2142 result += str(sk) 2143 result += ', ' 2144 return result[:-2] + ')'
Represents which skill(s) are used for a Challenge
, including under
what circumstances different skills might apply using
Requirement
s. 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 FocalContext
s 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
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
To represent using the lower of 'brains' or 'brawn' you'd use:
WorstSkill('brains', 'brawn')
>>> sr = WorstSkill('brains', 'brawn') >>> sr.effectiveLevel(ctx) 1
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
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
Skill names can be replaced by entire sub-
SkillCombination
s 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
2066 def __init__( 2067 self, 2068 *skills: Union[SkillCombination, Skill, Level] 2069 ): 2070 """ 2071 Given one or more `SkillCombination` sub-items and/or skill 2072 names or levels, represents a situation where the lowest 2073 effective level among the sub-items is used. Skill names 2074 translate to the player's level for that skill (with 0 as a 2075 default) while level numbers translate to that number. 2076 """ 2077 if len(skills) == 0: 2078 raise ValueError( 2079 "Cannot create a `WorstSkill` with 0 sub-skills." 2080 ) 2081 self.skills = skills
Given one or more SkillCombination
sub-items and/or skill
names or levels, represents a situation where the lowest
effective level among the sub-items is used. Skill names
translate to the player's level for that skill (with 0 as a
default) while level numbers translate to that number.
2096 def walk(self) -> Generator[ 2097 Union[SkillCombination, Skill, Level], 2098 None, 2099 None 2100 ]: 2101 yield self 2102 for sub in self.skills: 2103 if isinstance(sub, (Skill, Level)): 2104 yield sub 2105 else: 2106 yield from sub.walk()
Yields this combination and each sub-part in depth-first traversal order.
2108 def effectiveLevel(self, ctx: 'RequirementContext') -> Level: 2109 """ 2110 Determines the effective level of each sub-skill-combo and 2111 returns the lowest of those. 2112 """ 2113 result = None 2114 level: Level 2115 if len(self.skills) == 0: 2116 raise RuntimeError("Invalid WorstSkill: has zero sub-skills.") 2117 for sk in self.skills: 2118 if isinstance(sk, Level): 2119 level = sk 2120 elif isinstance(sk, Skill): 2121 level = getSkillLevel(ctx.state, sk) 2122 elif isinstance(sk, SkillCombination): 2123 level = sk.effectiveLevel(ctx) 2124 else: 2125 raise RuntimeError( 2126 f"Invalid WorstSkill: found sub-skill '{repr(sk)}'" 2127 f" which is not a skill name string, level integer," 2128 f" or SkillCombination." 2129 ) 2130 if result is None or result > level: 2131 result = level 2132 2133 assert result is not None 2134 return result
Determines the effective level of each sub-skill-combo and returns the lowest of those.
2136 def unparse(self): 2137 result = "worst(" 2138 for sk in self.skills: 2139 if isinstance(sk, SkillCombination): 2140 result += sk.unparse() 2141 else: 2142 result += str(sk) 2143 result += ', ' 2144 return result[:-2] + ')'
Returns a string that SkillCombination.parse
would turn back
into a SkillCombination
equivalent to this one. For example:
>>> BestSkill('brains').unparse()
'best(brains)'
>>> WorstSkill('brains', 'brawn').unparse()
'worst(brains, brawn)'
>>> CombinedSkill(
... ConditionalSkill(ReqTokens('orb', 3), 'brains', 0),
... InverseSkill('luck')
... ).unparse()
'sum(if(orb*3, brains, 0), ~luck)'
2147class CombinedSkill(SkillCombination): 2148 def __init__( 2149 self, 2150 *skills: Union[SkillCombination, Skill, Level] 2151 ): 2152 """ 2153 Given one or more `SkillCombination` sub-items and/or skill 2154 names or levels, represents a situation where the sum of the 2155 effective levels of each sub-item is used. Skill names 2156 translate to the player's level for that skill (with 0 as a 2157 default) while level numbers translate to that number. 2158 """ 2159 if len(skills) == 0: 2160 raise ValueError( 2161 "Cannot create a `CombinedSkill` with 0 sub-skills." 2162 ) 2163 self.skills = skills 2164 2165 def __eq__(self, other: Any) -> bool: 2166 return ( 2167 isinstance(other, CombinedSkill) 2168 and other.skills == self.skills 2169 ) 2170 2171 def __hash__(self) -> int: 2172 result = 2871 2173 for sk in self.skills: 2174 result += hash(sk) 2175 return result 2176 2177 def __repr__(self) -> str: 2178 subs = ', '.join(repr(sk) for sk in self.skills) 2179 return "CombinedSkill(" + subs + ")" 2180 2181 def walk(self) -> Generator[ 2182 Union[SkillCombination, Skill, Level], 2183 None, 2184 None 2185 ]: 2186 yield self 2187 for sub in self.skills: 2188 if isinstance(sub, (Skill, Level)): 2189 yield sub 2190 else: 2191 yield from sub.walk() 2192 2193 def effectiveLevel(self, ctx: 'RequirementContext') -> Level: 2194 """ 2195 Determines the effective level of each sub-skill-combo and 2196 returns the sum of those, with 0 as a default. 2197 """ 2198 result = 0 2199 level: Level 2200 if len(self.skills) == 0: 2201 raise RuntimeError( 2202 "Invalid CombinedSkill: has zero sub-skills." 2203 ) 2204 for sk in self.skills: 2205 if isinstance(sk, Level): 2206 level = sk 2207 elif isinstance(sk, Skill): 2208 level = getSkillLevel(ctx.state, sk) 2209 elif isinstance(sk, SkillCombination): 2210 level = sk.effectiveLevel(ctx) 2211 else: 2212 raise RuntimeError( 2213 f"Invalid CombinedSkill: found sub-skill '{repr(sk)}'" 2214 f" which is not a skill name string, level integer," 2215 f" or SkillCombination." 2216 ) 2217 result += level 2218 2219 assert result is not None 2220 return result 2221 2222 def unparse(self): 2223 result = "sum(" 2224 for sk in self.skills: 2225 if isinstance(sk, SkillCombination): 2226 result += sk.unparse() 2227 else: 2228 result += str(sk) 2229 result += ', ' 2230 return result[:-2] + ')'
Represents which skill(s) are used for a Challenge
, including under
what circumstances different skills might apply using
Requirement
s. 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 FocalContext
s 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
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
To represent using the lower of 'brains' or 'brawn' you'd use:
WorstSkill('brains', 'brawn')
>>> sr = WorstSkill('brains', 'brawn') >>> sr.effectiveLevel(ctx) 1
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
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
Skill names can be replaced by entire sub-
SkillCombination
s 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
2148 def __init__( 2149 self, 2150 *skills: Union[SkillCombination, Skill, Level] 2151 ): 2152 """ 2153 Given one or more `SkillCombination` sub-items and/or skill 2154 names or levels, represents a situation where the sum of the 2155 effective levels of each sub-item is used. Skill names 2156 translate to the player's level for that skill (with 0 as a 2157 default) while level numbers translate to that number. 2158 """ 2159 if len(skills) == 0: 2160 raise ValueError( 2161 "Cannot create a `CombinedSkill` with 0 sub-skills." 2162 ) 2163 self.skills = skills
Given one or more SkillCombination
sub-items and/or skill
names or levels, represents a situation where the sum of the
effective levels of each sub-item is used. Skill names
translate to the player's level for that skill (with 0 as a
default) while level numbers translate to that number.
2181 def walk(self) -> Generator[ 2182 Union[SkillCombination, Skill, Level], 2183 None, 2184 None 2185 ]: 2186 yield self 2187 for sub in self.skills: 2188 if isinstance(sub, (Skill, Level)): 2189 yield sub 2190 else: 2191 yield from sub.walk()
Yields this combination and each sub-part in depth-first traversal order.
2193 def effectiveLevel(self, ctx: 'RequirementContext') -> Level: 2194 """ 2195 Determines the effective level of each sub-skill-combo and 2196 returns the sum of those, with 0 as a default. 2197 """ 2198 result = 0 2199 level: Level 2200 if len(self.skills) == 0: 2201 raise RuntimeError( 2202 "Invalid CombinedSkill: has zero sub-skills." 2203 ) 2204 for sk in self.skills: 2205 if isinstance(sk, Level): 2206 level = sk 2207 elif isinstance(sk, Skill): 2208 level = getSkillLevel(ctx.state, sk) 2209 elif isinstance(sk, SkillCombination): 2210 level = sk.effectiveLevel(ctx) 2211 else: 2212 raise RuntimeError( 2213 f"Invalid CombinedSkill: found sub-skill '{repr(sk)}'" 2214 f" which is not a skill name string, level integer," 2215 f" or SkillCombination." 2216 ) 2217 result += level 2218 2219 assert result is not None 2220 return result
Determines the effective level of each sub-skill-combo and returns the sum of those, with 0 as a default.
2222 def unparse(self): 2223 result = "sum(" 2224 for sk in self.skills: 2225 if isinstance(sk, SkillCombination): 2226 result += sk.unparse() 2227 else: 2228 result += str(sk) 2229 result += ', ' 2230 return result[:-2] + ')'
Returns a string that SkillCombination.parse
would turn back
into a SkillCombination
equivalent to this one. For example:
>>> BestSkill('brains').unparse()
'best(brains)'
>>> WorstSkill('brains', 'brawn').unparse()
'worst(brains, brawn)'
>>> CombinedSkill(
... ConditionalSkill(ReqTokens('orb', 3), 'brains', 0),
... InverseSkill('luck')
... ).unparse()
'sum(if(orb*3, brains, 0), ~luck)'
2233class InverseSkill(SkillCombination): 2234 def __init__( 2235 self, 2236 invert: Union[SkillCombination, Skill, Level] 2237 ): 2238 """ 2239 Represents the effective level of the given `SkillCombination`, 2240 the level of the given `Skill`, or just the provided specific 2241 `Level`, except inverted (multiplied by -1). 2242 """ 2243 self.invert = invert 2244 2245 def __eq__(self, other: Any) -> bool: 2246 return ( 2247 isinstance(other, InverseSkill) 2248 and other.invert == self.invert 2249 ) 2250 2251 def __hash__(self) -> int: 2252 return 3193 + hash(self.invert) 2253 2254 def __repr__(self) -> str: 2255 return "InverseSkill(" + repr(self.invert) + ")" 2256 2257 def walk(self) -> Generator[ 2258 Union[SkillCombination, Skill, Level], 2259 None, 2260 None 2261 ]: 2262 yield self 2263 if isinstance(self.invert, SkillCombination): 2264 yield from self.invert.walk() 2265 else: 2266 yield self.invert 2267 2268 def effectiveLevel(self, ctx: 'RequirementContext') -> Level: 2269 """ 2270 Determines whether the requirement is satisfied or not and then 2271 returns the effective level of either the `ifSatisfied` or 2272 `ifNot` skill combination, as appropriate. 2273 """ 2274 if isinstance(self.invert, Level): 2275 return -self.invert 2276 elif isinstance(self.invert, Skill): 2277 return -getSkillLevel(ctx.state, self.invert) 2278 elif isinstance(self.invert, SkillCombination): 2279 return -self.invert.effectiveLevel(ctx) 2280 else: 2281 raise RuntimeError( 2282 f"Invalid InverseSkill: invert value {repr(self.invert)}" 2283 f" The invert value must be a Level (int), a Skill" 2284 f" (str), or a SkillCombination." 2285 ) 2286 2287 def unparse(self): 2288 # TODO: Move these to `parsing` to avoid hard-coded tokens here? 2289 if isinstance(self.invert, SkillCombination): 2290 return '~' + self.invert.unparse() 2291 else: 2292 return '~' + str(self.invert)
Represents which skill(s) are used for a Challenge
, including under
what circumstances different skills might apply using
Requirement
s. 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 FocalContext
s 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
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
To represent using the lower of 'brains' or 'brawn' you'd use:
WorstSkill('brains', 'brawn')
>>> sr = WorstSkill('brains', 'brawn') >>> sr.effectiveLevel(ctx) 1
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
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
Skill names can be replaced by entire sub-
SkillCombination
s 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
2234 def __init__( 2235 self, 2236 invert: Union[SkillCombination, Skill, Level] 2237 ): 2238 """ 2239 Represents the effective level of the given `SkillCombination`, 2240 the level of the given `Skill`, or just the provided specific 2241 `Level`, except inverted (multiplied by -1). 2242 """ 2243 self.invert = invert
Represents the effective level of the given SkillCombination
,
the level of the given Skill
, or just the provided specific
Level
, except inverted (multiplied by -1).
2257 def walk(self) -> Generator[ 2258 Union[SkillCombination, Skill, Level], 2259 None, 2260 None 2261 ]: 2262 yield self 2263 if isinstance(self.invert, SkillCombination): 2264 yield from self.invert.walk() 2265 else: 2266 yield self.invert
Yields this combination and each sub-part in depth-first traversal order.
2268 def effectiveLevel(self, ctx: 'RequirementContext') -> Level: 2269 """ 2270 Determines whether the requirement is satisfied or not and then 2271 returns the effective level of either the `ifSatisfied` or 2272 `ifNot` skill combination, as appropriate. 2273 """ 2274 if isinstance(self.invert, Level): 2275 return -self.invert 2276 elif isinstance(self.invert, Skill): 2277 return -getSkillLevel(ctx.state, self.invert) 2278 elif isinstance(self.invert, SkillCombination): 2279 return -self.invert.effectiveLevel(ctx) 2280 else: 2281 raise RuntimeError( 2282 f"Invalid InverseSkill: invert value {repr(self.invert)}" 2283 f" The invert value must be a Level (int), a Skill" 2284 f" (str), or a SkillCombination." 2285 )
Determines whether the requirement is satisfied or not and then
returns the effective level of either the ifSatisfied
or
ifNot
skill combination, as appropriate.
2287 def unparse(self): 2288 # TODO: Move these to `parsing` to avoid hard-coded tokens here? 2289 if isinstance(self.invert, SkillCombination): 2290 return '~' + self.invert.unparse() 2291 else: 2292 return '~' + str(self.invert)
Returns a string that SkillCombination.parse
would turn back
into a SkillCombination
equivalent to this one. For example:
>>> BestSkill('brains').unparse()
'best(brains)'
>>> WorstSkill('brains', 'brawn').unparse()
'worst(brains, brawn)'
>>> CombinedSkill(
... ConditionalSkill(ReqTokens('orb', 3), 'brains', 0),
... InverseSkill('luck')
... ).unparse()
'sum(if(orb*3, brains, 0), ~luck)'
2295class ConditionalSkill(SkillCombination): 2296 def __init__( 2297 self, 2298 requirement: 'Requirement', 2299 ifSatisfied: Union[SkillCombination, Skill, Level], 2300 ifNot: Union[SkillCombination, Skill, Level] = 0 2301 ): 2302 """ 2303 Given a `Requirement` and two different sub-`SkillCombination`s, 2304 which can also be `Skill` names or fixed `Level`s, represents 2305 situations where which skills come into play depends on what 2306 capabilities the player has. In situations where the given 2307 requirement is satisfied, the `ifSatisfied` combination's 2308 effective level is used, and otherwise the `ifNot` level is 2309 used. By default `ifNot` is just the fixed level 0. 2310 """ 2311 self.requirement = requirement 2312 self.ifSatisfied = ifSatisfied 2313 self.ifNot = ifNot 2314 2315 def __eq__(self, other: Any) -> bool: 2316 return ( 2317 isinstance(other, ConditionalSkill) 2318 and other.requirement == self.requirement 2319 and other.ifSatisfied == self.ifSatisfied 2320 and other.ifNot == self.ifNot 2321 ) 2322 2323 def __hash__(self) -> int: 2324 return ( 2325 1278 2326 + hash(self.requirement) 2327 + hash(self.ifSatisfied) 2328 + hash(self.ifNot) 2329 ) 2330 2331 def __repr__(self) -> str: 2332 return ( 2333 "ConditionalSkill(" 2334 + repr(self.requirement) + ", " 2335 + repr(self.ifSatisfied) + ", " 2336 + repr(self.ifNot) 2337 + ")" 2338 ) 2339 2340 def walk(self) -> Generator[ 2341 Union[SkillCombination, Skill, Level], 2342 None, 2343 None 2344 ]: 2345 yield self 2346 for sub in (self.ifSatisfied, self.ifNot): 2347 if isinstance(sub, SkillCombination): 2348 yield from sub.walk() 2349 else: 2350 yield sub 2351 2352 def effectiveLevel(self, ctx: 'RequirementContext') -> Level: 2353 """ 2354 Determines whether the requirement is satisfied or not and then 2355 returns the effective level of either the `ifSatisfied` or 2356 `ifNot` skill combination, as appropriate. 2357 """ 2358 if self.requirement.satisfied(ctx): 2359 use = self.ifSatisfied 2360 sat = True 2361 else: 2362 use = self.ifNot 2363 sat = False 2364 2365 if isinstance(use, Level): 2366 return use 2367 elif isinstance(use, Skill): 2368 return getSkillLevel(ctx.state, use) 2369 elif isinstance(use, SkillCombination): 2370 return use.effectiveLevel(ctx) 2371 else: 2372 raise RuntimeError( 2373 f"Invalid ConditionalSkill: Requirement was" 2374 f" {'not ' if not sat else ''}satisfied, and the" 2375 f" corresponding skill value was not a level, skill, or" 2376 f" SkillCombination: {repr(use)}" 2377 ) 2378 2379 def unparse(self): 2380 result = f"if({self.requirement.unparse()}, " 2381 if isinstance(self.ifSatisfied, SkillCombination): 2382 result += self.ifSatisfied.unparse() 2383 else: 2384 result += str(self.ifSatisfied) 2385 result += ', ' 2386 if isinstance(self.ifNot, SkillCombination): 2387 result += self.ifNot.unparse() 2388 else: 2389 result += str(self.ifNot) 2390 return result + ')'
Represents which skill(s) are used for a Challenge
, including under
what circumstances different skills might apply using
Requirement
s. 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 FocalContext
s 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
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
To represent using the lower of 'brains' or 'brawn' you'd use:
WorstSkill('brains', 'brawn')
>>> sr = WorstSkill('brains', 'brawn') >>> sr.effectiveLevel(ctx) 1
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
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
Skill names can be replaced by entire sub-
SkillCombination
s 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
2296 def __init__( 2297 self, 2298 requirement: 'Requirement', 2299 ifSatisfied: Union[SkillCombination, Skill, Level], 2300 ifNot: Union[SkillCombination, Skill, Level] = 0 2301 ): 2302 """ 2303 Given a `Requirement` and two different sub-`SkillCombination`s, 2304 which can also be `Skill` names or fixed `Level`s, represents 2305 situations where which skills come into play depends on what 2306 capabilities the player has. In situations where the given 2307 requirement is satisfied, the `ifSatisfied` combination's 2308 effective level is used, and otherwise the `ifNot` level is 2309 used. By default `ifNot` is just the fixed level 0. 2310 """ 2311 self.requirement = requirement 2312 self.ifSatisfied = ifSatisfied 2313 self.ifNot = ifNot
Given a Requirement
and two different sub-SkillCombination
s,
which can also be Skill
names or fixed Level
s, 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.
2340 def walk(self) -> Generator[ 2341 Union[SkillCombination, Skill, Level], 2342 None, 2343 None 2344 ]: 2345 yield self 2346 for sub in (self.ifSatisfied, self.ifNot): 2347 if isinstance(sub, SkillCombination): 2348 yield from sub.walk() 2349 else: 2350 yield sub
Yields this combination and each sub-part in depth-first traversal order.
2352 def effectiveLevel(self, ctx: 'RequirementContext') -> Level: 2353 """ 2354 Determines whether the requirement is satisfied or not and then 2355 returns the effective level of either the `ifSatisfied` or 2356 `ifNot` skill combination, as appropriate. 2357 """ 2358 if self.requirement.satisfied(ctx): 2359 use = self.ifSatisfied 2360 sat = True 2361 else: 2362 use = self.ifNot 2363 sat = False 2364 2365 if isinstance(use, Level): 2366 return use 2367 elif isinstance(use, Skill): 2368 return getSkillLevel(ctx.state, use) 2369 elif isinstance(use, SkillCombination): 2370 return use.effectiveLevel(ctx) 2371 else: 2372 raise RuntimeError( 2373 f"Invalid ConditionalSkill: Requirement was" 2374 f" {'not ' if not sat else ''}satisfied, and the" 2375 f" corresponding skill value was not a level, skill, or" 2376 f" SkillCombination: {repr(use)}" 2377 )
Determines whether the requirement is satisfied or not and then
returns the effective level of either the ifSatisfied
or
ifNot
skill combination, as appropriate.
2379 def unparse(self): 2380 result = f"if({self.requirement.unparse()}, " 2381 if isinstance(self.ifSatisfied, SkillCombination): 2382 result += self.ifSatisfied.unparse() 2383 else: 2384 result += str(self.ifSatisfied) 2385 result += ', ' 2386 if isinstance(self.ifNot, SkillCombination): 2387 result += self.ifNot.unparse() 2388 else: 2389 result += str(self.ifNot) 2390 return result + ')'
Returns a string that SkillCombination.parse
would turn back
into a SkillCombination
equivalent to this one. For example:
>>> BestSkill('brains').unparse()
'best(brains)'
>>> WorstSkill('brains', 'brawn').unparse()
'worst(brains, brawn)'
>>> CombinedSkill(
... ConditionalSkill(ReqTokens('orb', 3), 'brains', 0),
... InverseSkill('luck')
... ).unparse()
'sum(if(orb*3, brains, 0), ~luck)'
2393class Challenge(TypedDict): 2394 """ 2395 Represents a random binary decision between two possible outcomes, 2396 only one of which will actually occur. The 'outcome' can be set to 2397 `True` or `False` to represent that the outcome of the challenge has 2398 been observed, or to `None` (the default) to represent a pending 2399 challenge. The chance of 'success' is determined by the associated 2400 skill(s) and the challenge level, although one or both may be 2401 unknown in which case a variable is used in place of a concrete 2402 value. Probabilities that are of the form 1/2**n or (2**n - 1) / 2403 (2**n) can be represented, the specific formula for the chance of 2404 success is for a challenge with a single skill is: 2405 2406 s = interacting entity's skill level in associated skill 2407 c = challenge level 2408 P(success) = { 2409 1 - 1/2**(1 + s - c) if s > c 2410 1/2 if s == c 2411 1/2**(1 + c - s) if c > s 2412 } 2413 2414 This probability formula is equivalent to the following procedure: 2415 2416 1. Flip one coin, plus one additional coin for each level difference 2417 between the skill and challenge levels. 2418 2. If the skill level is equal to or higher than the challenge 2419 level, the outcome is success if any single coin comes up heads. 2420 3. If the skill level is less than the challenge level, then the 2421 outcome is success only if *all* coins come up heads. 2422 4. If the outcome is not success, it is failure. 2423 2424 Multiple skills can be combined into a `SkillCombination`, which can 2425 use the max or min of several skills, add skill levels together, 2426 and/or have skills which are only relevant when a certain 2427 `Requirement` is satisfied. If a challenge has no skills associated 2428 with it, then the player's skill level counts as 0. 2429 2430 The slots are: 2431 2432 - 'skills': A `SkillCombination` that specifies the relevant 2433 skill(s). 2434 - 'level': An integer specifying the level of the challenge. Along 2435 with the appropriate skill level of the interacting entity, this 2436 determines the probability of success or failure. 2437 - 'success': A `Consequence` which will happen when the outcome is 2438 success. Note that since a `Consequence` can be a `Challenge`, 2439 multi-outcome challenges can be represented by chaining multiple 2440 challenges together. 2441 - 'failure': A `Consequence` which will happen when the outcome is 2442 failure. 2443 - 'outcome': The outcome of the challenge: `True` means success, 2444 `False` means failure, and `None` means "not known (yet)." 2445 """ 2446 skills: SkillCombination 2447 level: Level 2448 success: 'Consequence' 2449 failure: 'Consequence' 2450 outcome: Optional[bool]
Represents a random binary decision between two possible outcomes,
only one of which will actually occur. The 'outcome' can be set to
True
or False
to represent that the outcome of the challenge has
been observed, or to None
(the default) to represent a pending
challenge. The chance of 'success' is determined by the associated
skill(s) and the challenge level, although one or both may be
unknown in which case a variable is used in place of a concrete
value. Probabilities that are of the form 1/2n or (2n - 1) /
(2**n) can be represented, the specific formula for the chance of
success is for a challenge with a single skill is:
s = interacting entity's skill level in associated skill
c = challenge level
P(success) = {
1 - 1/2**(1 + s - c) if s > c
1/2 if s == c
1/2**(1 + c - s) if c > s
}
This probability formula is equivalent to the following procedure:
- Flip one coin, plus one additional coin for each level difference between the skill and challenge levels.
- If the skill level is equal to or higher than the challenge level, the outcome is success if any single coin comes up heads.
- If the skill level is less than the challenge level, then the outcome is success only if all coins come up heads.
- 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 aConsequence
can be aChallenge
, 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, andNone
means "not known (yet)."
2453def challenge( 2454 skills: Optional[SkillCombination] = None, 2455 level: Level = 0, 2456 success: Optional['Consequence'] = None, 2457 failure: Optional['Consequence'] = None, 2458 outcome: Optional[bool] = None 2459): 2460 """ 2461 Factory for `Challenge`s, defaults to empty effects for both success 2462 and failure outcomes, so that you can just provide one or the other 2463 if you need to. Skills defaults to an empty list, the level defaults 2464 to 0 and the outcome defaults to `None` which means "not (yet) 2465 known." 2466 """ 2467 if skills is None: 2468 skills = BestSkill(0) 2469 if success is None: 2470 success = [] 2471 if failure is None: 2472 failure = [] 2473 return { 2474 'skills': skills, 2475 'level': level, 2476 'success': success, 2477 'failure': failure, 2478 'outcome': outcome 2479 }
Factory for Challenge
s, 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."
2482class Condition(TypedDict): 2483 """ 2484 Represents a condition over `Capability`, `Token`, and/or `Mechanism` 2485 states which applies to one or more `Effect`s or `Challenge`s as part 2486 of a `Consequence`. If the specified `Requirement` is satisfied, the 2487 included `Consequence` is treated as if it were part of the 2488 `Consequence` that the `Condition` is inside of, if the requirement 2489 is not satisfied, then the internal `Consequence` is skipped and the 2490 alternate consequence is used instead. Either sub-consequence may of 2491 course be an empty list. 2492 """ 2493 condition: 'Requirement' 2494 consequence: 'Consequence' 2495 alternative: 'Consequence'
Represents a condition over Capability
, Token
, and/or Mechanism
states which applies to one or more Effect
s or Challenge
s 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.
2498def condition( 2499 condition: 'Requirement', 2500 consequence: 'Consequence', 2501 alternative: Optional['Consequence'] = None 2502): 2503 """ 2504 Factory for conditions that just glues the given requirement, 2505 consequence, and alternative together. The alternative defaults to 2506 an empty list if not specified. 2507 """ 2508 if alternative is None: 2509 alternative = [] 2510 return { 2511 'condition': condition, 2512 'consequence': consequence, 2513 'alternative': alternative 2514 }
Factory for conditions that just glues the given requirement, consequence, and alternative together. The alternative defaults to an empty list if not specified.
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 Effect
s 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 Challenge
s 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.
Specifies how challenges should be resolved. See
observeChallengeOutcomes
.
2552def resetChallengeOutcomes(consequence: Consequence) -> None: 2553 """ 2554 Traverses all sub-consequences of the given consequence, setting the 2555 outcomes of any `Challenge`s it encounters to `None`, to prepare for 2556 a fresh call to `observeChallengeOutcomes`. 2557 2558 Resets all outcomes in every branch, regardless of previous 2559 outcomes. 2560 2561 For example: 2562 2563 >>> from . import core 2564 >>> e = core.emptySituation() 2565 >>> c = challenge( 2566 ... success=[effect(gain=('money', 12))], 2567 ... failure=[effect(lose=('money', 10))] 2568 ... ) # skill defaults to 'luck', level to 0, and outcome to None 2569 >>> c['outcome'] is None # default outcome is None 2570 True 2571 >>> r = observeChallengeOutcomes(e, [c], policy='mostLikely') 2572 >>> r[0]['outcome'] 2573 True 2574 >>> c['outcome'] # original outcome is changed from None 2575 True 2576 >>> r[0] is c 2577 True 2578 >>> resetChallengeOutcomes([c]) 2579 >>> c['outcome'] is None # now has been reset 2580 True 2581 >>> r[0]['outcome'] is None # same object... 2582 True 2583 >>> resetChallengeOutcomes(c) # can't reset just a Challenge 2584 Traceback (most recent call last): 2585 ... 2586 TypeError... 2587 >>> r = observeChallengeOutcomes(e, [c], policy='success') 2588 >>> r[0]['outcome'] 2589 True 2590 >>> r = observeChallengeOutcomes(e, [c], policy='failure') 2591 >>> r[0]['outcome'] # wasn't reset 2592 True 2593 >>> resetChallengeOutcomes([c]) # now reset it 2594 >>> c['outcome'] is None 2595 True 2596 >>> r = observeChallengeOutcomes(e, [c], policy='failure') 2597 >>> r[0]['outcome'] # was reset 2598 False 2599 """ 2600 if not isinstance(consequence, list): 2601 raise TypeError( 2602 f"Invalid consequence: must be a list." 2603 f"\nGot: {repr(consequence)}" 2604 ) 2605 2606 for item in consequence: 2607 if not isinstance(item, dict): 2608 raise TypeError( 2609 f"Invalid consequence: items in the list must be" 2610 f" Effects, Challenges, or Conditions." 2611 f"\nGot item: {repr(item)}" 2612 ) 2613 if 'skills' in item: # must be a Challenge 2614 item = cast(Challenge, item) 2615 item['outcome'] = None 2616 # reset both branches 2617 resetChallengeOutcomes(item['success']) 2618 resetChallengeOutcomes(item['failure']) 2619 2620 elif 'value' in item: # an Effect 2621 continue # Effects don't have sub-outcomes 2622 2623 elif 'condition' in item: # a Condition 2624 item = cast(Condition, item) 2625 resetChallengeOutcomes(item['consequence']) 2626 resetChallengeOutcomes(item['alternative']) 2627 2628 else: # bad dict 2629 raise TypeError( 2630 f"Invalid consequence: items in the list must be" 2631 f" Effects, Challenges, or Conditions (got a dictionary" 2632 f" without 'skills', 'value', or 'condition' keys)." 2633 f"\nGot item: {repr(item)}" 2634 )
Traverses all sub-consequences of the given consequence, setting the
outcomes of any Challenge
s 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
2637def observeChallengeOutcomes( 2638 context: RequirementContext, 2639 consequence: Consequence, 2640 location: Optional[Set[DecisionID]] = None, 2641 policy: ChallengePolicy = 'random', 2642 knownOutcomes: Optional[List[bool]] = None, 2643 makeCopy: bool = False 2644) -> Consequence: 2645 """ 2646 Given a `RequirementContext` (for `Capability`, `Token`, and `Skill` 2647 info as well as equivalences in the `DecisionGraph` and a 2648 search-from location for mechanism names) and a `Conseqeunce` to be 2649 observed, sets the 'outcome' value for each `Challenge` in it to 2650 either `True` or `False` by determining an outcome for each 2651 `Challenge` that's relevant (challenges locked behind unsatisfied 2652 `Condition`s or on untaken branches of other challenges are not 2653 given outcomes). `Challenge`s that already have assigned outcomes 2654 re-use those outcomes, call `resetChallengeOutcomes` beforehand if 2655 you want to re-decide each challenge with a new policy, and use the 2656 'specified' policy if you want to ensure only pre-specified outcomes 2657 are used. 2658 2659 Normally, the return value is just the original `consequence` 2660 object. However, if `makeCopy` is set to `True`, a deep copy is made 2661 and returned, so the original is not modified. One potential problem 2662 with this is that effects will be copied in this process, which 2663 means that if they are applied, things like delays and toggles won't 2664 update properly. `makeCopy` should thus normally not be used. 2665 2666 The 'policy' value can be one of the `ChallengePolicy` values. The 2667 default is 'random', in which case the `random.random` function is 2668 used to determine each outcome, based on the probability derived 2669 from the challenge level and the associated skill level. The other 2670 policies are: 2671 2672 - 'mostLikely': the result of each challenge will be whichever 2673 outcome is more likely, with success always happening instead of 2674 failure when the probabilities are 50/50. 2675 - 'fewestEffects`: whichever combination of outcomes leads to the 2676 fewest total number of effects will be chosen (modulo satisfying 2677 requirements of `Condition`s). Note that there's no estimation 2678 of the severity of effects, just the raw number. Ties in terms 2679 of number of effects are broken towards successes. This policy 2680 involves evaluating all possible outcome combinations to figure 2681 out which one has the fewest effects. 2682 - 'success' or 'failure': all outcomes will either succeed, or 2683 fail, as specified. Note that success/failure may cut off some 2684 challenges, so it's not the case that literally every challenge 2685 will succeed/fail; some may be skipped because of the 2686 specified success/failure of a prior challenge. 2687 - 'specified': all outcomes have already been specified, and those 2688 pre-specified outcomes should be used as-is. 2689 2690 2691 In call cases, outcomes specified via `knownOutcomes` take precedence 2692 over the challenge policy. The `knownOutcomes` list will be emptied 2693 out as this function works, but extra consequences beyond what's 2694 needed will be ignored (and left in the list). 2695 2696 Note that there are limits on the resolution of Python's random 2697 number generation; for challenges with extremely high or low levels 2698 relative to the associated skill(s) where the probability of success 2699 is very close to 1 or 0, there may not actually be any chance of 2700 success/failure at all. Typically you can ignore this, because such 2701 cases should not normally come up in practice, and because the odds 2702 of success/failure in those cases are such that to notice the 2703 missing possibility share you'd have to simulate outcomes a 2704 ridiculous number of times. 2705 2706 TODO: Location examples; move some of these to a separate testing 2707 file. 2708 2709 For example: 2710 2711 >>> random.seed(17) 2712 >>> warnings.filterwarnings('error') 2713 >>> from . import core 2714 >>> e = core.emptySituation() 2715 >>> c = challenge( 2716 ... success=[effect(gain=('money', 12))], 2717 ... failure=[effect(lose=('money', 10))] 2718 ... ) # skill defaults to 'luck', level to 0, and outcome to None 2719 >>> c['outcome'] is None # default outcome is None 2720 True 2721 >>> r = observeChallengeOutcomes(e, [c]) 2722 >>> r[0]['outcome'] 2723 False 2724 >>> c['outcome'] # original outcome is changed from None 2725 False 2726 >>> all( 2727 ... observeChallengeOutcomes(e, [c])[0]['outcome'] is False 2728 ... for i in range(20) 2729 ... ) # no reset -> same outcome 2730 True 2731 >>> resetChallengeOutcomes([c]) 2732 >>> observeChallengeOutcomes(e, [c])[0]['outcome'] # Random after reset 2733 False 2734 >>> resetChallengeOutcomes([c]) 2735 >>> observeChallengeOutcomes(e, [c])[0]['outcome'] # Random after reset 2736 False 2737 >>> resetChallengeOutcomes([c]) 2738 >>> observeChallengeOutcomes(e, [c])[0]['outcome'] # Random after reset 2739 True 2740 >>> observeChallengeOutcomes(e, c) # Can't resolve just a Challenge 2741 Traceback (most recent call last): 2742 ... 2743 TypeError... 2744 >>> allSame = [] 2745 >>> for i in range(20): 2746 ... resetChallengeOutcomes([c]) 2747 ... obs = observeChallengeOutcomes(e, [c, c]) 2748 ... allSame.append(obs[0]['outcome'] == obs[1]['outcome']) 2749 >>> allSame == [True]*20 2750 True 2751 >>> different = [] 2752 >>> for i in range(20): 2753 ... resetChallengeOutcomes([c]) 2754 ... obs = observeChallengeOutcomes(e, [c, copy.deepcopy(c)]) 2755 ... different.append(obs[0]['outcome'] == obs[1]['outcome']) 2756 >>> False in different 2757 True 2758 >>> all( # Tie breaks towards success 2759 ... ( 2760 ... resetChallengeOutcomes([c]), 2761 ... observeChallengeOutcomes(e, [c], policy='mostLikely') 2762 ... )[1][0]['outcome'] is True 2763 ... for i in range(20) 2764 ... ) 2765 True 2766 >>> all( # Tie breaks towards success 2767 ... ( 2768 ... resetChallengeOutcomes([c]), 2769 ... observeChallengeOutcomes(e, [c], policy='fewestEffects') 2770 ... )[1][0]['outcome'] is True 2771 ... for i in range(20) 2772 ... ) 2773 True 2774 >>> all( 2775 ... ( 2776 ... resetChallengeOutcomes([c]), 2777 ... observeChallengeOutcomes(e, [c], policy='success') 2778 ... )[1][0]['outcome'] is True 2779 ... for i in range(20) 2780 ... ) 2781 True 2782 >>> all( 2783 ... ( 2784 ... resetChallengeOutcomes([c]), 2785 ... observeChallengeOutcomes(e, [c], policy='failure') 2786 ... )[1][0]['outcome'] is False 2787 ... for i in range(20) 2788 ... ) 2789 True 2790 >>> c['outcome'] = False # Fix the outcome; now policy is ignored 2791 >>> observeChallengeOutcomes(e, [c], policy='success')[0]['outcome'] 2792 False 2793 >>> c = challenge( 2794 ... skills=BestSkill('charisma'), 2795 ... level=8, 2796 ... success=[ 2797 ... challenge( 2798 ... skills=BestSkill('strength'), 2799 ... success=[effect(gain='winner')] 2800 ... ) 2801 ... ], # level defaults to 0 2802 ... failure=[ 2803 ... challenge( 2804 ... skills=BestSkill('strength'), 2805 ... failure=[effect(gain='loser')] 2806 ... ), 2807 ... effect(gain='sad') 2808 ... ] 2809 ... ) 2810 >>> r = observeChallengeOutcomes(e, [c]) # random 2811 >>> r[0]['outcome'] 2812 False 2813 >>> r[0]['failure'][0]['outcome'] # also random 2814 True 2815 >>> r[0]['success'][0]['outcome'] is None # skipped so not assigned 2816 True 2817 >>> resetChallengeOutcomes([c]) 2818 >>> r2 = observeChallengeOutcomes(e, [c]) # random 2819 >>> r[0]['outcome'] 2820 False 2821 >>> r[0]['success'][0]['outcome'] is None # untaken branch no outcome 2822 True 2823 >>> r[0]['failure'][0]['outcome'] # also random 2824 False 2825 >>> def outcomeList(consequence): 2826 ... 'Lists outcomes from each challenge attempted.' 2827 ... result = [] 2828 ... for item in consequence: 2829 ... if 'skills' in item: 2830 ... result.append(item['outcome']) 2831 ... if item['outcome'] is True: 2832 ... result.extend(outcomeList(item['success'])) 2833 ... elif item['outcome'] is False: 2834 ... result.extend(outcomeList(item['failure'])) 2835 ... else: 2836 ... pass # end here 2837 ... return result 2838 >>> def skilled(**skills): 2839 ... 'Create a clone of our Situation with specific skills.' 2840 ... r = copy.deepcopy(e) 2841 ... r.state['common']['capabilities']['skills'].update(skills) 2842 ... return r 2843 >>> resetChallengeOutcomes([c]) 2844 >>> r = observeChallengeOutcomes( # 'mostLikely' policy 2845 ... skilled(charisma=9, strength=1), 2846 ... [c], 2847 ... policy='mostLikely' 2848 ... ) 2849 >>> outcomeList(r) 2850 [True, True] 2851 >>> resetChallengeOutcomes([c]) 2852 >>> outcomeList(observeChallengeOutcomes( 2853 ... skilled(charisma=7, strength=-1), 2854 ... [c], 2855 ... policy='mostLikely' 2856 ... )) 2857 [False, False] 2858 >>> resetChallengeOutcomes([c]) 2859 >>> outcomeList(observeChallengeOutcomes( 2860 ... skilled(charisma=8, strength=-1), 2861 ... [c], 2862 ... policy='mostLikely' 2863 ... )) 2864 [True, False] 2865 >>> resetChallengeOutcomes([c]) 2866 >>> outcomeList(observeChallengeOutcomes( 2867 ... skilled(charisma=7, strength=0), 2868 ... [c], 2869 ... policy='mostLikely' 2870 ... )) 2871 [False, True] 2872 >>> resetChallengeOutcomes([c]) 2873 >>> outcomeList(observeChallengeOutcomes( 2874 ... skilled(charisma=20, strength=10), 2875 ... [c], 2876 ... policy='mostLikely' 2877 ... )) 2878 [True, True] 2879 >>> resetChallengeOutcomes([c]) 2880 >>> outcomeList(observeChallengeOutcomes( 2881 ... skilled(charisma=-10, strength=-10), 2882 ... [c], 2883 ... policy='mostLikely' 2884 ... )) 2885 [False, False] 2886 >>> resetChallengeOutcomes([c]) 2887 >>> outcomeList(observeChallengeOutcomes( 2888 ... e, 2889 ... [c], 2890 ... policy='fewestEffects' 2891 ... )) 2892 [True, False] 2893 >>> resetChallengeOutcomes([c]) 2894 >>> outcomeList(observeChallengeOutcomes( 2895 ... skilled(charisma=-100, strength=100), 2896 ... [c], 2897 ... policy='fewestEffects' 2898 ... )) # unaffected by stats 2899 [True, False] 2900 >>> resetChallengeOutcomes([c]) 2901 >>> outcomeList(observeChallengeOutcomes(e, [c], policy='success')) 2902 [True, True] 2903 >>> resetChallengeOutcomes([c]) 2904 >>> outcomeList(observeChallengeOutcomes(e, [c], policy='failure')) 2905 [False, False] 2906 >>> cc = copy.deepcopy(c) 2907 >>> resetChallengeOutcomes([cc]) 2908 >>> cc['outcome'] = False 2909 >>> outcomeList(observeChallengeOutcomes( 2910 ... skilled(charisma=10, strength=10), 2911 ... [cc], 2912 ... policy='mostLikely' 2913 ... )) # pre-observed outcome won't be changed 2914 [False, True] 2915 >>> resetChallengeOutcomes([cc]) 2916 >>> cc['outcome'] = False 2917 >>> outcomeList(observeChallengeOutcomes( 2918 ... e, 2919 ... [cc], 2920 ... policy='fewestEffects' 2921 ... )) # pre-observed outcome won't be changed 2922 [False, True] 2923 >>> cc['success'][0]['outcome'] is None # not assigned on other branch 2924 True 2925 >>> resetChallengeOutcomes([cc]) 2926 >>> r = observeChallengeOutcomes(e, [cc], policy='fewestEffects') 2927 >>> r[0] is cc # results are aliases, not clones 2928 True 2929 >>> outcomeList(r) 2930 [True, False] 2931 >>> cc['success'][0]['outcome'] # inner outcome now assigned 2932 False 2933 >>> cc['failure'][0]['outcome'] is None # now this is other branch 2934 True 2935 >>> resetChallengeOutcomes([cc]) 2936 >>> r = observeChallengeOutcomes( 2937 ... e, 2938 ... [cc], 2939 ... policy='fewestEffects', 2940 ... makeCopy=True 2941 ... ) 2942 >>> r[0] is cc # now result is a clone 2943 False 2944 >>> outcomeList(r) 2945 [True, False] 2946 >>> observedEffects(genericContextForSituation(e), r) 2947 [] 2948 >>> r[0]['outcome'] # outcome was assigned 2949 True 2950 >>> cc['outcome'] is None # only to the copy, not to the original 2951 True 2952 >>> cn = [ 2953 ... condition( 2954 ... ReqCapability('boost'), 2955 ... [ 2956 ... challenge(success=[effect(gain=('$', 1))]), 2957 ... effect(gain=('$', 2)) 2958 ... ] 2959 ... ), 2960 ... challenge(failure=[effect(gain=('$', 4))]) 2961 ... ] 2962 >>> o = observeChallengeOutcomes(e, cn, policy='fewestEffects') 2963 >>> # Without 'boost', inner challenge does not get an outcome 2964 >>> o[0]['consequence'][0]['outcome'] is None 2965 True 2966 >>> o[1]['outcome'] # avoids effect 2967 True 2968 >>> hasBoost = copy.deepcopy(e) 2969 >>> hasBoost.state['common']['capabilities']['capabilities'].add('boost') 2970 >>> resetChallengeOutcomes(cn) 2971 >>> o = observeChallengeOutcomes(hasBoost, cn, policy='fewestEffects') 2972 >>> o[0]['consequence'][0]['outcome'] # now assigned an outcome 2973 False 2974 >>> o[1]['outcome'] # avoids effect 2975 True 2976 >>> from . import core 2977 >>> e = core.emptySituation() 2978 >>> c = challenge( 2979 ... skills=BestSkill('skill'), 2980 ... level=4, # very unlikely at level 0 2981 ... success=[], 2982 ... failure=[effect(lose=('money', 10))], 2983 ... outcome=True 2984 ... ) # pre-assigned outcome 2985 >>> c['outcome'] # verify 2986 True 2987 >>> r = observeChallengeOutcomes(e, [c], policy='specified') 2988 >>> r[0]['outcome'] 2989 True 2990 >>> c['outcome'] # original outcome is unchanged 2991 True 2992 >>> c['outcome'] = False # the more likely outcome 2993 >>> r = observeChallengeOutcomes(e, [c], policy='specified') 2994 >>> r[0]['outcome'] # re-uses the new outcome 2995 False 2996 >>> c['outcome'] # outcome is unchanged 2997 False 2998 >>> c['outcome'] = True # change it back 2999 >>> r = observeChallengeOutcomes(e, [c], policy='specified') 3000 >>> r[0]['outcome'] # re-use the outcome again 3001 True 3002 >>> c['outcome'] # outcome is unchanged 3003 True 3004 >>> c['outcome'] = None # set it to no info; will crash 3005 >>> r = observeChallengeOutcomes(e, [c], policy='specified') 3006 Traceback (most recent call last): 3007 ... 3008 ValueError... 3009 >>> warnings.filterwarnings('default') 3010 >>> c['outcome'] is None # same after crash 3011 True 3012 >>> r = observeChallengeOutcomes( 3013 ... e, 3014 ... [c], 3015 ... policy='specified', 3016 ... knownOutcomes=[True] 3017 ... ) 3018 >>> r[0]['outcome'] # picked up known outcome 3019 True 3020 >>> c['outcome'] # outcome is changed 3021 True 3022 >>> resetChallengeOutcomes([c]) 3023 >>> c['outcome'] is None # has been reset 3024 True 3025 >>> r = observeChallengeOutcomes( 3026 ... e, 3027 ... [c], 3028 ... policy='specified', 3029 ... knownOutcomes=[True] 3030 ... ) 3031 >>> c['outcome'] # from known outcomes 3032 True 3033 >>> ko = [False] 3034 >>> r = observeChallengeOutcomes( 3035 ... e, 3036 ... [c], 3037 ... policy='specified', 3038 ... knownOutcomes=ko 3039 ... ) 3040 >>> c['outcome'] # from known outcomes 3041 False 3042 >>> ko # known outcomes list gets used up 3043 [] 3044 >>> ko = [False, False] 3045 >>> r = observeChallengeOutcomes( 3046 ... e, 3047 ... [c], 3048 ... policy='specified', 3049 ... knownOutcomes=ko 3050 ... ) # too many outcomes is an error 3051 >>> ko 3052 [False] 3053 """ 3054 if not isinstance(consequence, list): 3055 raise TypeError( 3056 f"Invalid consequence: must be a list." 3057 f"\nGot: {repr(consequence)}" 3058 ) 3059 3060 if knownOutcomes is None: 3061 knownOutcomes = [] 3062 3063 if makeCopy: 3064 result = copy.deepcopy(consequence) 3065 else: 3066 result = consequence 3067 3068 for item in result: 3069 if not isinstance(item, dict): 3070 raise TypeError( 3071 f"Invalid consequence: items in the list must be" 3072 f" Effects, Challenges, or Conditions." 3073 f"\nGot item: {repr(item)}" 3074 ) 3075 if 'skills' in item: # must be a Challenge 3076 item = cast(Challenge, item) 3077 if len(knownOutcomes) > 0: 3078 item['outcome'] = knownOutcomes.pop(0) 3079 if item['outcome'] is not None: 3080 if item['outcome']: 3081 observeChallengeOutcomes( 3082 context, 3083 item['success'], 3084 location=location, 3085 policy=policy, 3086 knownOutcomes=knownOutcomes, 3087 makeCopy=False 3088 ) 3089 else: 3090 observeChallengeOutcomes( 3091 context, 3092 item['failure'], 3093 location=location, 3094 policy=policy, 3095 knownOutcomes=knownOutcomes, 3096 makeCopy=False 3097 ) 3098 else: # need to assign an outcome 3099 if policy == 'specified': 3100 raise ValueError( 3101 f"Challenge has unspecified outcome so the" 3102 f" 'specified' policy cannot be used when" 3103 f" observing its outcomes:" 3104 f"\n{item}" 3105 ) 3106 level = item['skills'].effectiveLevel(context) 3107 against = item['level'] 3108 if level < against: 3109 p = 1 / (2 ** (1 + against - level)) 3110 else: 3111 p = 1 - (1 / (2 ** (1 + level - against))) 3112 if policy == 'random': 3113 if random.random() < p: # success 3114 item['outcome'] = True 3115 else: 3116 item['outcome'] = False 3117 elif policy == 'mostLikely': 3118 if p >= 0.5: 3119 item['outcome'] = True 3120 else: 3121 item['outcome'] = False 3122 elif policy == 'fewestEffects': 3123 # Resolve copies so we don't affect original 3124 subSuccess = observeChallengeOutcomes( 3125 context, 3126 item['success'], 3127 location=location, 3128 policy=policy, 3129 knownOutcomes=knownOutcomes[:], 3130 makeCopy=True 3131 ) 3132 subFailure = observeChallengeOutcomes( 3133 context, 3134 item['failure'], 3135 location=location, 3136 policy=policy, 3137 knownOutcomes=knownOutcomes[:], 3138 makeCopy=True 3139 ) 3140 if ( 3141 len(observedEffects(context, subSuccess)) 3142 <= len(observedEffects(context, subFailure)) 3143 ): 3144 item['outcome'] = True 3145 else: 3146 item['outcome'] = False 3147 elif policy == 'success': 3148 item['outcome'] = True 3149 elif policy == 'failure': 3150 item['outcome'] = False 3151 3152 # Figure out outcomes for sub-consequence if we don't 3153 # already have them... 3154 if item['outcome'] not in (True, False): 3155 raise TypeError( 3156 f"Challenge has invalid outcome type" 3157 f" {type(item['outcome'])} after observation." 3158 f"\nOutcome value: {repr(item['outcome'])}" 3159 ) 3160 3161 if item['outcome']: 3162 observeChallengeOutcomes( 3163 context, 3164 item['success'], 3165 location=location, 3166 policy=policy, 3167 knownOutcomes=knownOutcomes, 3168 makeCopy=False 3169 ) 3170 else: 3171 observeChallengeOutcomes( 3172 context, 3173 item['failure'], 3174 location=location, 3175 policy=policy, 3176 knownOutcomes=knownOutcomes, 3177 makeCopy=False 3178 ) 3179 3180 elif 'value' in item: 3181 continue # Effects do not need success/failure assigned 3182 3183 elif 'condition' in item: # a Condition 3184 if item['condition'].satisfied(context): 3185 observeChallengeOutcomes( 3186 context, 3187 item['consequence'], 3188 location=location, 3189 policy=policy, 3190 knownOutcomes=knownOutcomes, 3191 makeCopy=False 3192 ) 3193 else: 3194 observeChallengeOutcomes( 3195 context, 3196 item['alternative'], 3197 location=location, 3198 policy=policy, 3199 knownOutcomes=knownOutcomes, 3200 makeCopy=False 3201 ) 3202 3203 else: # bad dict 3204 raise TypeError( 3205 f"Invalid consequence: items in the list must be" 3206 f" Effects, Challenges, or Conditions (got a dictionary" 3207 f" without 'skills', 'value', or 'condition' keys)." 3208 f"\nGot item: {repr(item)}" 3209 ) 3210 3211 # Return copy or original, now with options selected 3212 return result
Given a RequirementContext
(for Capability
, Token
, and Skill
info as well as equivalences in the DecisionGraph
and a
search-from location for mechanism names) and a Conseqeunce
to be
observed, sets the 'outcome' value for each Challenge
in it to
either True
or False
by determining an outcome for each
Challenge
that's relevant (challenges locked behind unsatisfied
Condition
s or on untaken branches of other challenges are not
given outcomes). Challenge
s 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 of
Condition`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]
3215class UnassignedOutcomeWarning(Warning): 3216 """ 3217 A warning issued when asking for observed effects of a `Consequence` 3218 whose `Challenge` outcomes have not been fully assigned. 3219 """ 3220 pass
A warning issued when asking for observed effects of a Consequence
whose Challenge
outcomes have not been fully assigned.
Inherited Members
- builtins.Warning
- Warning
- builtins.BaseException
- with_traceback
- add_note
- args
3223def observedEffects( 3224 context: RequirementContext, 3225 observed: Consequence, 3226 skipWarning=False, 3227 baseIndex: int = 0 3228) -> List[int]: 3229 """ 3230 Given a `Situation` and a `Consequence` whose challenges have 3231 outcomes assigned, returns a tuple containing a list of the 3232 depth-first-indices of each effect to apply. You can use 3233 `consequencePart` to extract the actual `Effect` values from the 3234 consequence based on their indices. 3235 3236 Only effects that actually apply are included, based on the observed 3237 outcomes as well as which `Condition`(s) are met, although charges 3238 and delays for the effects are not taken into account. 3239 3240 `baseIndex` can be set to something other than 0 to start indexing 3241 at that value. Issues an `UnassignedOutcomeWarning` if it encounters 3242 a challenge whose outcome has not been observed, unless 3243 `skipWarning` is set to `True`. In that case, no effects are listed 3244 for outcomes of that challenge. 3245 3246 For example: 3247 3248 >>> from . import core 3249 >>> warnings.filterwarnings('error') 3250 >>> e = core.emptySituation() 3251 >>> def skilled(**skills): 3252 ... 'Create a clone of our FocalContext with specific skills.' 3253 ... r = copy.deepcopy(e) 3254 ... r.state['common']['capabilities']['skills'].update(skills) 3255 ... return r 3256 >>> c = challenge( # index 1 in [c] (index 0 is the outer list) 3257 ... skills=BestSkill('charisma'), 3258 ... level=8, 3259 ... success=[ 3260 ... effect(gain='happy'), # index 3 in [c] 3261 ... challenge( 3262 ... skills=BestSkill('strength'), 3263 ... success=[effect(gain='winner')] # index 6 in [c] 3264 ... # failure is index 7 3265 ... ) # level defaults to 0 3266 ... ], 3267 ... failure=[ 3268 ... challenge( 3269 ... skills=BestSkill('strength'), 3270 ... # success is index 10 3271 ... failure=[effect(gain='loser')] # index 12 in [c] 3272 ... ), 3273 ... effect(gain='sad') # index 13 in [c] 3274 ... ] 3275 ... ) 3276 >>> import pytest 3277 >>> with pytest.warns(UnassignedOutcomeWarning): 3278 ... observedEffects(e, [c]) 3279 [] 3280 >>> with pytest.warns(UnassignedOutcomeWarning): 3281 ... observedEffects(e, [c, c]) 3282 [] 3283 >>> observedEffects(e, [c, c], skipWarning=True) 3284 [] 3285 >>> c['outcome'] = 'invalid value' # must be True, False, or None 3286 >>> observedEffects(e, [c]) 3287 Traceback (most recent call last): 3288 ... 3289 TypeError... 3290 >>> yesYes = skilled(charisma=10, strength=5) 3291 >>> yesNo = skilled(charisma=10, strength=-1) 3292 >>> noYes = skilled(charisma=4, strength=5) 3293 >>> noNo = skilled(charisma=4, strength=-1) 3294 >>> resetChallengeOutcomes([c]) 3295 >>> observedEffects( 3296 ... yesYes, 3297 ... observeChallengeOutcomes(yesYes, [c], policy='mostLikely') 3298 ... ) 3299 [3, 6] 3300 >>> resetChallengeOutcomes([c]) 3301 >>> observedEffects( 3302 ... yesNo, 3303 ... observeChallengeOutcomes(yesNo, [c], policy='mostLikely') 3304 ... ) 3305 [3] 3306 >>> resetChallengeOutcomes([c]) 3307 >>> observedEffects( 3308 ... noYes, 3309 ... observeChallengeOutcomes(noYes, [c], policy='mostLikely') 3310 ... ) 3311 [13] 3312 >>> resetChallengeOutcomes([c]) 3313 >>> observedEffects( 3314 ... noNo, 3315 ... observeChallengeOutcomes(noNo, [c], policy='mostLikely') 3316 ... ) 3317 [12, 13] 3318 >>> warnings.filterwarnings('default') 3319 >>> # known outcomes override policy & pre-specified outcomes 3320 >>> observedEffects( 3321 ... noNo, 3322 ... observeChallengeOutcomes( 3323 ... noNo, 3324 ... [c], 3325 ... policy='mostLikely', 3326 ... knownOutcomes=[True, True]) 3327 ... ) 3328 [3, 6] 3329 >>> observedEffects( 3330 ... yesYes, 3331 ... observeChallengeOutcomes( 3332 ... yesYes, 3333 ... [c], 3334 ... policy='mostLikely', 3335 ... knownOutcomes=[False, False]) 3336 ... ) 3337 [12, 13] 3338 >>> resetChallengeOutcomes([c]) 3339 >>> observedEffects( 3340 ... yesYes, 3341 ... observeChallengeOutcomes( 3342 ... yesYes, 3343 ... [c], 3344 ... policy='mostLikely', 3345 ... knownOutcomes=[False, False]) 3346 ... ) 3347 [12, 13] 3348 """ 3349 result: List[int] = [] 3350 totalCount: int = baseIndex + 1 # +1 for the outer list 3351 if not isinstance(observed, list): 3352 raise TypeError( 3353 f"Invalid consequence: must be a list." 3354 f"\nGot: {repr(observed)}" 3355 ) 3356 for item in observed: 3357 if not isinstance(item, dict): 3358 raise TypeError( 3359 f"Invalid consequence: items in the list must be" 3360 f" Effects, Challenges, or Conditions." 3361 f"\nGot item: {repr(item)}" 3362 ) 3363 3364 if 'skills' in item: # must be a Challenge 3365 item = cast(Challenge, item) 3366 succeeded = item['outcome'] 3367 useCh: Optional[Literal['success', 'failure']] 3368 if succeeded is True: 3369 useCh = 'success' 3370 elif succeeded is False: 3371 useCh = 'failure' 3372 else: 3373 useCh = None 3374 level = item["level"] 3375 if succeeded is not None: 3376 raise TypeError( 3377 f"Invalid outcome for level-{level} challenge:" 3378 f" should be True, False, or None, but got:" 3379 f" {repr(succeeded)}" 3380 ) 3381 else: 3382 if not skipWarning: 3383 warnings.warn( 3384 ( 3385 f"A level-{level} challenge in the" 3386 f" consequence being observed has no" 3387 f" observed outcome; no effects from" 3388 f" either success or failure branches" 3389 f" will be included. Use" 3390 f" observeChallengeOutcomes to fill in" 3391 f" unobserved outcomes." 3392 ), 3393 UnassignedOutcomeWarning 3394 ) 3395 3396 if useCh is not None: 3397 skipped = 0 3398 if useCh == 'failure': 3399 skipped = countParts(item['success']) 3400 subEffects = observedEffects( 3401 context, 3402 item[useCh], 3403 skipWarning=skipWarning, 3404 baseIndex=totalCount + skipped + 1 3405 ) 3406 result.extend(subEffects) 3407 3408 # TODO: Go back to returning tuples but fix counts to include 3409 # skipped stuff; this is horribly inefficient :( 3410 totalCount += countParts(item) 3411 3412 elif 'value' in item: # an effect, not a challenge 3413 item = cast(Effect, item) 3414 result.append(totalCount) 3415 totalCount += 1 3416 3417 elif 'condition' in item: # a Condition 3418 item = cast(Condition, item) 3419 useCo: Literal['consequence', 'alternative'] 3420 if item['condition'].satisfied(context): 3421 useCo = 'consequence' 3422 skipped = 0 3423 else: 3424 useCo = 'alternative' 3425 skipped = countParts(item['consequence']) 3426 subEffects = observedEffects( 3427 context, 3428 item[useCo], 3429 skipWarning=skipWarning, 3430 baseIndex=totalCount + skipped + 1 3431 ) 3432 result.extend(subEffects) 3433 totalCount += countParts(item) 3434 3435 else: # bad dict 3436 raise TypeError( 3437 f"Invalid consequence: items in the list must be" 3438 f" Effects, Challenges, or Conditions (got a dictionary" 3439 f" without 'skills', 'value', or 'condition' keys)." 3440 f"\nGot item: {repr(item)}" 3441 ) 3442 3443 return result
Given a Situation
and a Consequence
whose challenges have
outcomes assigned, returns a tuple containing a list of the
depth-first-indices of each effect to apply. You can use
consequencePart
to extract the actual Effect
values from the
consequence based on their indices.
Only effects that actually apply are included, based on the observed
outcomes as well as which Condition
(s) are met, although charges
and delays for the effects are not taken into account.
baseIndex
can be set to something other than 0 to start indexing
at that value. Issues an UnassignedOutcomeWarning
if it encounters
a challenge whose outcome has not been observed, unless
skipWarning
is set to True
. In that case, no effects are listed
for outcomes of that challenge.
For example:
>>> from . import core
>>> warnings.filterwarnings('error')
>>> e = core.emptySituation()
>>> def skilled(**skills):
... 'Create a clone of our FocalContext with specific skills.'
... r = copy.deepcopy(e)
... r.state['common']['capabilities']['skills'].update(skills)
... return r
>>> c = challenge( # index 1 in [c] (index 0 is the outer list)
... skills=BestSkill('charisma'),
... level=8,
... success=[
... effect(gain='happy'), # index 3 in [c]
... challenge(
... skills=BestSkill('strength'),
... success=[effect(gain='winner')] # index 6 in [c]
... # failure is index 7
... ) # level defaults to 0
... ],
... failure=[
... challenge(
... skills=BestSkill('strength'),
... # success is index 10
... failure=[effect(gain='loser')] # index 12 in [c]
... ),
... effect(gain='sad') # index 13 in [c]
... ]
... )
>>> import pytest
>>> with pytest.warns(UnassignedOutcomeWarning):
... observedEffects(e, [c])
[]
>>> with pytest.warns(UnassignedOutcomeWarning):
... observedEffects(e, [c, c])
[]
>>> observedEffects(e, [c, c], skipWarning=True)
[]
>>> c['outcome'] = 'invalid value' # must be True, False, or None
>>> observedEffects(e, [c])
Traceback (most recent call last):
...
TypeError...
>>> yesYes = skilled(charisma=10, strength=5)
>>> yesNo = skilled(charisma=10, strength=-1)
>>> noYes = skilled(charisma=4, strength=5)
>>> noNo = skilled(charisma=4, strength=-1)
>>> resetChallengeOutcomes([c])
>>> observedEffects(
... yesYes,
... observeChallengeOutcomes(yesYes, [c], policy='mostLikely')
... )
[3, 6]
>>> resetChallengeOutcomes([c])
>>> observedEffects(
... yesNo,
... observeChallengeOutcomes(yesNo, [c], policy='mostLikely')
... )
[3]
>>> resetChallengeOutcomes([c])
>>> observedEffects(
... noYes,
... observeChallengeOutcomes(noYes, [c], policy='mostLikely')
... )
[13]
>>> resetChallengeOutcomes([c])
>>> observedEffects(
... noNo,
... observeChallengeOutcomes(noNo, [c], policy='mostLikely')
... )
[12, 13]
>>> warnings.filterwarnings('default')
>>> # known outcomes override policy & pre-specified outcomes
>>> observedEffects(
... noNo,
... observeChallengeOutcomes(
... noNo,
... [c],
... policy='mostLikely',
... knownOutcomes=[True, True])
... )
[3, 6]
>>> observedEffects(
... yesYes,
... observeChallengeOutcomes(
... yesYes,
... [c],
... policy='mostLikely',
... knownOutcomes=[False, False])
... )
[12, 13]
>>> resetChallengeOutcomes([c])
>>> observedEffects(
... yesYes,
... observeChallengeOutcomes(
... yesYes,
... [c],
... policy='mostLikely',
... knownOutcomes=[False, False])
... )
[12, 13]
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.
3458class Requirement: 3459 """ 3460 Represents a precondition for traversing an edge or taking an action. 3461 This can be any boolean expression over `Capability`, mechanism (see 3462 `MechanismName`), and/or `Token` states that must obtain, with 3463 numerical values for the number of tokens required, and specific 3464 mechanism states or active capabilities necessary. For example, if 3465 the player needs either the wall-break capability or the wall-jump 3466 capability plus a balloon token, or for the switch mechanism to be 3467 on, you could represent that using: 3468 3469 ReqAny( 3470 ReqCapability('wall-break'), 3471 ReqAll( 3472 ReqCapability('wall-jump'), 3473 ReqTokens('balloon', 1) 3474 ), 3475 ReqMechanism('switch', 'on') 3476 ) 3477 3478 The subclasses define concrete requirements. 3479 3480 Note that mechanism names are searched for using `lookupMechanism`, 3481 starting from the `DecisionID`s of the decisions on either end of 3482 the transition where a requirement is being checked. You may need to 3483 rename mechanisms to avoid a `MechanismCollisionError`if decisions 3484 on either end of a transition use the same mechanism name. 3485 """ 3486 def satisfied( 3487 self, 3488 context: RequirementContext, 3489 dontRecurse: Optional[ 3490 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3491 ] = None 3492 ) -> bool: 3493 """ 3494 This will return `True` if the requirement is satisfied in the 3495 given `RequirementContext`, resolving mechanisms from the 3496 context's set of decisions and graph, and respecting the 3497 context's equivalences. It returns `False` otherwise. 3498 3499 The `dontRecurse` set should be unspecified to start, and will 3500 be used to avoid infinite recursion in cases of circular 3501 equivalences (requirements are not considered satisfied by 3502 equivalence loops). 3503 3504 TODO: Examples 3505 """ 3506 raise NotImplementedError( 3507 "Requirement is an abstract class and cannot be" 3508 " used directly." 3509 ) 3510 3511 def __eq__(self, other: Any) -> bool: 3512 raise NotImplementedError( 3513 "Requirement is an abstract class and cannot be compared." 3514 ) 3515 3516 def __hash__(self) -> int: 3517 raise NotImplementedError( 3518 "Requirement is an abstract class and cannot be hashed." 3519 ) 3520 3521 def walk(self) -> Generator['Requirement', None, None]: 3522 """ 3523 Yields every part of the requirement in depth-first traversal 3524 order. 3525 """ 3526 raise NotImplementedError( 3527 "Requirement is an abstract class and cannot be walked." 3528 ) 3529 3530 def asEffectList(self) -> List[Effect]: 3531 """ 3532 Transforms this `Requirement` into a list of `Effect` 3533 objects that gain the `Capability`, set the `Token` amounts, and 3534 set the `Mechanism` states mentioned by the requirement. The 3535 requirement must be either a `ReqTokens`, a `ReqCapability`, a 3536 `ReqMechanism`, or a `ReqAll` which includes nothing besides 3537 those types as sub-requirements. The token and capability 3538 requirements at the leaves of the tree will be collected into a 3539 list for the result (note that whether `ReqAny` or `ReqAll` is 3540 used is ignored, all of the tokens/capabilities/mechanisms 3541 mentioned are listed). For each `Capability` requirement a 3542 'gain' effect for that capability will be included. For each 3543 `Mechanism` or `Token` requirement, a 'set' effect for that 3544 mechanism state or token count will be included. Note that if 3545 the requirement has contradictory clauses (e.g., two different 3546 mechanism states) multiple effects which cancel each other out 3547 will be included. Also note that setting token amounts may end 3548 up decreasing them unnecessarily. 3549 3550 Raises a `TypeError` if this requirement is not suitable for 3551 transformation into an effect list. 3552 """ 3553 raise NotImplementedError("Requirement is an abstract class.") 3554 3555 def flatten(self) -> 'Requirement': 3556 """ 3557 Returns a simplified version of this requirement that merges 3558 multiple redundant layers of `ReqAny`/`ReqAll` into single 3559 `ReqAny`/`ReqAll` structures, including recursively. May return 3560 the original requirement if there's no simplification to be done. 3561 3562 Default implementation just returns `self`. 3563 """ 3564 return self 3565 3566 def unparse(self) -> str: 3567 """ 3568 Returns a string which would convert back into this `Requirement` 3569 object if you fed it to `parsing.ParseFormat.parseRequirement`. 3570 3571 TODO: Move this over into `parsing`? 3572 3573 Examples: 3574 3575 >>> r = ReqAny([ 3576 ... ReqCapability('capability'), 3577 ... ReqTokens('token', 3), 3578 ... ReqMechanism('mechanism', 'state') 3579 ... ]) 3580 >>> rep = r.unparse() 3581 >>> rep 3582 '(capability|token*3|mechanism:state)' 3583 >>> from . import parsing 3584 >>> pf = parsing.ParseFormat() 3585 >>> back = pf.parseRequirement(rep) 3586 >>> back == r 3587 True 3588 >>> ReqNot(ReqNothing()).unparse() 3589 '!(O)' 3590 >>> ReqImpossible().unparse() 3591 'X' 3592 >>> r = ReqAny([ReqCapability('A'), ReqCapability('B'), 3593 ... ReqCapability('C')]) 3594 >>> rep = r.unparse() 3595 >>> rep 3596 '(A|B|C)' 3597 >>> back = pf.parseRequirement(rep) 3598 >>> back == r 3599 True 3600 """ 3601 raise NotImplementedError("Requirement is an abstract class.")
Represents a precondition for traversing an edge or taking an action.
This can be any boolean expression over Capability
, mechanism (see
MechanismName
), and/or Token
states that must obtain, with
numerical values for the number of tokens required, and specific
mechanism states or active capabilities necessary. For example, if
the player needs either the wall-break capability or the wall-jump
capability plus a balloon token, or for the switch mechanism to be
on, you could represent that using:
ReqAny(
ReqCapability('wall-break'),
ReqAll(
ReqCapability('wall-jump'),
ReqTokens('balloon', 1)
),
ReqMechanism('switch', 'on')
)
The subclasses define concrete requirements.
Note that mechanism names are searched for using lookupMechanism
,
starting from the DecisionID
s of the decisions on either end of
the transition where a requirement is being checked. You may need to
rename mechanisms to avoid a MechanismCollisionError
if decisions
on either end of a transition use the same mechanism name.
3486 def satisfied( 3487 self, 3488 context: RequirementContext, 3489 dontRecurse: Optional[ 3490 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3491 ] = None 3492 ) -> bool: 3493 """ 3494 This will return `True` if the requirement is satisfied in the 3495 given `RequirementContext`, resolving mechanisms from the 3496 context's set of decisions and graph, and respecting the 3497 context's equivalences. It returns `False` otherwise. 3498 3499 The `dontRecurse` set should be unspecified to start, and will 3500 be used to avoid infinite recursion in cases of circular 3501 equivalences (requirements are not considered satisfied by 3502 equivalence loops). 3503 3504 TODO: Examples 3505 """ 3506 raise NotImplementedError( 3507 "Requirement is an abstract class and cannot be" 3508 " used directly." 3509 )
This will return True
if the requirement is satisfied in the
given RequirementContext
, resolving mechanisms from the
context's set of decisions and graph, and respecting the
context's equivalences. It returns False
otherwise.
The dontRecurse
set should be unspecified to start, and will
be used to avoid infinite recursion in cases of circular
equivalences (requirements are not considered satisfied by
equivalence loops).
TODO: Examples
3521 def walk(self) -> Generator['Requirement', None, None]: 3522 """ 3523 Yields every part of the requirement in depth-first traversal 3524 order. 3525 """ 3526 raise NotImplementedError( 3527 "Requirement is an abstract class and cannot be walked." 3528 )
Yields every part of the requirement in depth-first traversal order.
3530 def asEffectList(self) -> List[Effect]: 3531 """ 3532 Transforms this `Requirement` into a list of `Effect` 3533 objects that gain the `Capability`, set the `Token` amounts, and 3534 set the `Mechanism` states mentioned by the requirement. The 3535 requirement must be either a `ReqTokens`, a `ReqCapability`, a 3536 `ReqMechanism`, or a `ReqAll` which includes nothing besides 3537 those types as sub-requirements. The token and capability 3538 requirements at the leaves of the tree will be collected into a 3539 list for the result (note that whether `ReqAny` or `ReqAll` is 3540 used is ignored, all of the tokens/capabilities/mechanisms 3541 mentioned are listed). For each `Capability` requirement a 3542 'gain' effect for that capability will be included. For each 3543 `Mechanism` or `Token` requirement, a 'set' effect for that 3544 mechanism state or token count will be included. Note that if 3545 the requirement has contradictory clauses (e.g., two different 3546 mechanism states) multiple effects which cancel each other out 3547 will be included. Also note that setting token amounts may end 3548 up decreasing them unnecessarily. 3549 3550 Raises a `TypeError` if this requirement is not suitable for 3551 transformation into an effect list. 3552 """ 3553 raise NotImplementedError("Requirement is an abstract class.")
Transforms this Requirement
into a list of Effect
objects that gain the Capability
, set the Token
amounts, and
set the Mechanism
states mentioned by the requirement. The
requirement must be either a ReqTokens
, a ReqCapability
, a
ReqMechanism
, or a ReqAll
which includes nothing besides
those types as sub-requirements. The token and capability
requirements at the leaves of the tree will be collected into a
list for the result (note that whether ReqAny
or ReqAll
is
used is ignored, all of the tokens/capabilities/mechanisms
mentioned are listed). For each Capability
requirement a
'gain' effect for that capability will be included. For each
Mechanism
or Token
requirement, a 'set' effect for that
mechanism state or token count will be included. Note that if
the requirement has contradictory clauses (e.g., two different
mechanism states) multiple effects which cancel each other out
will be included. Also note that setting token amounts may end
up decreasing them unnecessarily.
Raises a TypeError
if this requirement is not suitable for
transformation into an effect list.
3555 def flatten(self) -> 'Requirement': 3556 """ 3557 Returns a simplified version of this requirement that merges 3558 multiple redundant layers of `ReqAny`/`ReqAll` into single 3559 `ReqAny`/`ReqAll` structures, including recursively. May return 3560 the original requirement if there's no simplification to be done. 3561 3562 Default implementation just returns `self`. 3563 """ 3564 return self
3566 def unparse(self) -> str: 3567 """ 3568 Returns a string which would convert back into this `Requirement` 3569 object if you fed it to `parsing.ParseFormat.parseRequirement`. 3570 3571 TODO: Move this over into `parsing`? 3572 3573 Examples: 3574 3575 >>> r = ReqAny([ 3576 ... ReqCapability('capability'), 3577 ... ReqTokens('token', 3), 3578 ... ReqMechanism('mechanism', 'state') 3579 ... ]) 3580 >>> rep = r.unparse() 3581 >>> rep 3582 '(capability|token*3|mechanism:state)' 3583 >>> from . import parsing 3584 >>> pf = parsing.ParseFormat() 3585 >>> back = pf.parseRequirement(rep) 3586 >>> back == r 3587 True 3588 >>> ReqNot(ReqNothing()).unparse() 3589 '!(O)' 3590 >>> ReqImpossible().unparse() 3591 'X' 3592 >>> r = ReqAny([ReqCapability('A'), ReqCapability('B'), 3593 ... ReqCapability('C')]) 3594 >>> rep = r.unparse() 3595 >>> rep 3596 '(A|B|C)' 3597 >>> back = pf.parseRequirement(rep) 3598 >>> back == r 3599 True 3600 """ 3601 raise NotImplementedError("Requirement is an abstract class.")
Returns a string which would convert back into this Requirement
object if you fed it to parsing.ParseFormat.parseRequirement
.
TODO: Move this over into parsing
?
Examples:
>>> r = ReqAny([
... ReqCapability('capability'),
... ReqTokens('token', 3),
... ReqMechanism('mechanism', 'state')
... ])
>>> rep = r.unparse()
>>> rep
'(capability|token*3|mechanism:state)'
>>> from . import parsing
>>> pf = parsing.ParseFormat()
>>> back = pf.parseRequirement(rep)
>>> back == r
True
>>> ReqNot(ReqNothing()).unparse()
'!(O)'
>>> ReqImpossible().unparse()
'X'
>>> r = ReqAny([ReqCapability('A'), ReqCapability('B'),
... ReqCapability('C')])
>>> rep = r.unparse()
>>> rep
'(A|B|C)'
>>> back = pf.parseRequirement(rep)
>>> back == r
True
3604class ReqAny(Requirement): 3605 """ 3606 A disjunction requirement satisfied when any one of its 3607 sub-requirements is satisfied. 3608 """ 3609 def __init__(self, subs: Iterable[Requirement]) -> None: 3610 self.subs = list(subs) 3611 3612 def __hash__(self) -> int: 3613 result = 179843 3614 for sub in self.subs: 3615 result = 31 * (result + hash(sub)) 3616 return result 3617 3618 def __eq__(self, other: Any) -> bool: 3619 return isinstance(other, ReqAny) and other.subs == self.subs 3620 3621 def __repr__(self): 3622 return "ReqAny(" + repr(self.subs) + ")" 3623 3624 def satisfied( 3625 self, 3626 context: RequirementContext, 3627 dontRecurse: Optional[ 3628 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3629 ] = None 3630 ) -> bool: 3631 """ 3632 True as long as any one of the sub-requirements is satisfied. 3633 """ 3634 return any( 3635 sub.satisfied(context, dontRecurse) 3636 for sub in self.subs 3637 ) 3638 3639 def walk(self) -> Generator[Requirement, None, None]: 3640 yield self 3641 for sub in self.subs: 3642 yield from sub.walk() 3643 3644 def asEffectList(self) -> List[Effect]: 3645 """ 3646 Raises a `TypeError` since disjunctions don't have a translation 3647 into a simple list of effects to satisfy them. 3648 """ 3649 raise TypeError( 3650 "Cannot convert ReqAny into an effect list:" 3651 " contradictory token or mechanism requirements on" 3652 " different branches are not easy to synthesize." 3653 ) 3654 3655 def flatten(self) -> Requirement: 3656 """ 3657 Flattens this requirement by merging any sub-requirements which 3658 are also `ReqAny` instances into this one. 3659 """ 3660 merged = [] 3661 for sub in self.subs: 3662 flat = sub.flatten() 3663 if isinstance(flat, ReqAny): 3664 merged.extend(flat.subs) 3665 else: 3666 merged.append(flat) 3667 3668 return ReqAny(merged) 3669 3670 def unparse(self) -> str: 3671 return '(' + '|'.join(sub.unparse() for sub in self.subs) + ')'
A disjunction requirement satisfied when any one of its sub-requirements is satisfied.
3624 def satisfied( 3625 self, 3626 context: RequirementContext, 3627 dontRecurse: Optional[ 3628 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3629 ] = None 3630 ) -> bool: 3631 """ 3632 True as long as any one of the sub-requirements is satisfied. 3633 """ 3634 return any( 3635 sub.satisfied(context, dontRecurse) 3636 for sub in self.subs 3637 )
True as long as any one of the sub-requirements is satisfied.
3639 def walk(self) -> Generator[Requirement, None, None]: 3640 yield self 3641 for sub in self.subs: 3642 yield from sub.walk()
Yields every part of the requirement in depth-first traversal order.
3644 def asEffectList(self) -> List[Effect]: 3645 """ 3646 Raises a `TypeError` since disjunctions don't have a translation 3647 into a simple list of effects to satisfy them. 3648 """ 3649 raise TypeError( 3650 "Cannot convert ReqAny into an effect list:" 3651 " contradictory token or mechanism requirements on" 3652 " different branches are not easy to synthesize." 3653 )
Raises a TypeError
since disjunctions don't have a translation
into a simple list of effects to satisfy them.
3655 def flatten(self) -> Requirement: 3656 """ 3657 Flattens this requirement by merging any sub-requirements which 3658 are also `ReqAny` instances into this one. 3659 """ 3660 merged = [] 3661 for sub in self.subs: 3662 flat = sub.flatten() 3663 if isinstance(flat, ReqAny): 3664 merged.extend(flat.subs) 3665 else: 3666 merged.append(flat) 3667 3668 return ReqAny(merged)
Flattens this requirement by merging any sub-requirements which
are also ReqAny
instances into this one.
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
3674class ReqAll(Requirement): 3675 """ 3676 A conjunction requirement satisfied when all of its sub-requirements 3677 are satisfied. 3678 """ 3679 def __init__(self, subs: Iterable[Requirement]) -> None: 3680 self.subs = list(subs) 3681 3682 def __hash__(self) -> int: 3683 result = 182971 3684 for sub in self.subs: 3685 result = 17 * (result + hash(sub)) 3686 return result 3687 3688 def __eq__(self, other: Any) -> bool: 3689 return isinstance(other, ReqAll) and other.subs == self.subs 3690 3691 def __repr__(self): 3692 return "ReqAll(" + repr(self.subs) + ")" 3693 3694 def satisfied( 3695 self, 3696 context: RequirementContext, 3697 dontRecurse: Optional[ 3698 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3699 ] = None 3700 ) -> bool: 3701 """ 3702 True as long as all of the sub-requirements are satisfied. 3703 """ 3704 return all( 3705 sub.satisfied(context, dontRecurse) 3706 for sub in self.subs 3707 ) 3708 3709 def walk(self) -> Generator[Requirement, None, None]: 3710 yield self 3711 for sub in self.subs: 3712 yield from sub.walk() 3713 3714 def asEffectList(self) -> List[Effect]: 3715 """ 3716 Returns a gain list composed by adding together the gain lists 3717 for each sub-requirement. Note that some types of requirement 3718 will raise a `TypeError` during this process if they appear as a 3719 sub-requirement. 3720 """ 3721 result = [] 3722 for sub in self.subs: 3723 result += sub.asEffectList() 3724 3725 return result 3726 3727 def flatten(self) -> Requirement: 3728 """ 3729 Flattens this requirement by merging any sub-requirements which 3730 are also `ReqAll` instances into this one. 3731 """ 3732 merged = [] 3733 for sub in self.subs: 3734 flat = sub.flatten() 3735 if isinstance(flat, ReqAll): 3736 merged.extend(flat.subs) 3737 else: 3738 merged.append(flat) 3739 3740 return ReqAll(merged) 3741 3742 def unparse(self) -> str: 3743 return '(' + '&'.join(sub.unparse() for sub in self.subs) + ')'
A conjunction requirement satisfied when all of its sub-requirements are satisfied.
3694 def satisfied( 3695 self, 3696 context: RequirementContext, 3697 dontRecurse: Optional[ 3698 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3699 ] = None 3700 ) -> bool: 3701 """ 3702 True as long as all of the sub-requirements are satisfied. 3703 """ 3704 return all( 3705 sub.satisfied(context, dontRecurse) 3706 for sub in self.subs 3707 )
True as long as all of the sub-requirements are satisfied.
3709 def walk(self) -> Generator[Requirement, None, None]: 3710 yield self 3711 for sub in self.subs: 3712 yield from sub.walk()
Yields every part of the requirement in depth-first traversal order.
3714 def asEffectList(self) -> List[Effect]: 3715 """ 3716 Returns a gain list composed by adding together the gain lists 3717 for each sub-requirement. Note that some types of requirement 3718 will raise a `TypeError` during this process if they appear as a 3719 sub-requirement. 3720 """ 3721 result = [] 3722 for sub in self.subs: 3723 result += sub.asEffectList() 3724 3725 return result
Returns a gain list composed by adding together the gain lists
for each sub-requirement. Note that some types of requirement
will raise a TypeError
during this process if they appear as a
sub-requirement.
3727 def flatten(self) -> Requirement: 3728 """ 3729 Flattens this requirement by merging any sub-requirements which 3730 are also `ReqAll` instances into this one. 3731 """ 3732 merged = [] 3733 for sub in self.subs: 3734 flat = sub.flatten() 3735 if isinstance(flat, ReqAll): 3736 merged.extend(flat.subs) 3737 else: 3738 merged.append(flat) 3739 3740 return ReqAll(merged)
Flattens this requirement by merging any sub-requirements which
are also ReqAll
instances into this one.
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
3746class ReqNot(Requirement): 3747 """ 3748 A negation requirement satisfied when its sub-requirement is NOT 3749 satisfied. 3750 """ 3751 def __init__(self, sub: Requirement) -> None: 3752 self.sub = sub 3753 3754 def __hash__(self) -> int: 3755 return 17293 + hash(self.sub) 3756 3757 def __eq__(self, other: Any) -> bool: 3758 return isinstance(other, ReqNot) and other.sub == self.sub 3759 3760 def __repr__(self): 3761 return "ReqNot(" + repr(self.sub) + ")" 3762 3763 def satisfied( 3764 self, 3765 context: RequirementContext, 3766 dontRecurse: Optional[ 3767 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3768 ] = None 3769 ) -> bool: 3770 """ 3771 True as long as the sub-requirement is not satisfied. 3772 """ 3773 return not self.sub.satisfied(context, dontRecurse) 3774 3775 def walk(self) -> Generator[Requirement, None, None]: 3776 yield self 3777 yield self.sub 3778 3779 def asEffectList(self) -> List[Effect]: 3780 """ 3781 Raises a `TypeError` since understanding a `ReqNot` in terms of 3782 capabilities/tokens to be gained is not straightforward, and would 3783 need to be done relative to a game state in any case. 3784 """ 3785 raise TypeError( 3786 "Cannot convert ReqNot into an effect list:" 3787 " capabilities or tokens would have to be lost, not gained to" 3788 " satisfy this requirement." 3789 ) 3790 3791 def flatten(self) -> Requirement: 3792 return ReqNot(self.sub.flatten()) 3793 3794 def unparse(self) -> str: 3795 return '!(' + self.sub.unparse() + ')'
A negation requirement satisfied when its sub-requirement is NOT satisfied.
3763 def satisfied( 3764 self, 3765 context: RequirementContext, 3766 dontRecurse: Optional[ 3767 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3768 ] = None 3769 ) -> bool: 3770 """ 3771 True as long as the sub-requirement is not satisfied. 3772 """ 3773 return not self.sub.satisfied(context, dontRecurse)
True as long as the sub-requirement is not satisfied.
3779 def asEffectList(self) -> List[Effect]: 3780 """ 3781 Raises a `TypeError` since understanding a `ReqNot` in terms of 3782 capabilities/tokens to be gained is not straightforward, and would 3783 need to be done relative to a game state in any case. 3784 """ 3785 raise TypeError( 3786 "Cannot convert ReqNot into an effect list:" 3787 " capabilities or tokens would have to be lost, not gained to" 3788 " satisfy this requirement." 3789 )
Raises a TypeError
since understanding a ReqNot
in terms of
capabilities/tokens to be gained is not straightforward, and would
need to be done relative to a game state in any case.
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
3798class ReqCapability(Requirement): 3799 """ 3800 A capability requirement is satisfied if the specified capability is 3801 possessed by the player according to the given state. 3802 """ 3803 def __init__(self, capability: Capability) -> None: 3804 self.capability = capability 3805 3806 def __hash__(self) -> int: 3807 return 47923 + hash(self.capability) 3808 3809 def __eq__(self, other: Any) -> bool: 3810 return ( 3811 isinstance(other, ReqCapability) 3812 and other.capability == self.capability 3813 ) 3814 3815 def __repr__(self): 3816 return "ReqCapability(" + repr(self.capability) + ")" 3817 3818 def satisfied( 3819 self, 3820 context: RequirementContext, 3821 dontRecurse: Optional[ 3822 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3823 ] = None 3824 ) -> bool: 3825 return hasCapabilityOrEquivalent( 3826 self.capability, 3827 context, 3828 dontRecurse 3829 ) 3830 3831 def walk(self) -> Generator[Requirement, None, None]: 3832 yield self 3833 3834 def asEffectList(self) -> List[Effect]: 3835 """ 3836 Returns a list containing a single 'gain' effect which grants 3837 the required capability. 3838 """ 3839 return [effect(gain=self.capability)] 3840 3841 def unparse(self) -> str: 3842 return self.capability
A capability requirement is satisfied if the specified capability is possessed by the player according to the given state.
3818 def satisfied( 3819 self, 3820 context: RequirementContext, 3821 dontRecurse: Optional[ 3822 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3823 ] = None 3824 ) -> bool: 3825 return hasCapabilityOrEquivalent( 3826 self.capability, 3827 context, 3828 dontRecurse 3829 )
This will return True
if the requirement is satisfied in the
given RequirementContext
, resolving mechanisms from the
context's set of decisions and graph, and respecting the
context's equivalences. It returns False
otherwise.
The dontRecurse
set should be unspecified to start, and will
be used to avoid infinite recursion in cases of circular
equivalences (requirements are not considered satisfied by
equivalence loops).
TODO: Examples
3834 def asEffectList(self) -> List[Effect]: 3835 """ 3836 Returns a list containing a single 'gain' effect which grants 3837 the required capability. 3838 """ 3839 return [effect(gain=self.capability)]
Returns a list containing a single 'gain' effect which grants the required capability.
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
3845class ReqTokens(Requirement): 3846 """ 3847 A token requirement satisfied if the player possesses at least a 3848 certain number of a given type of token. 3849 3850 Note that checking the satisfaction of individual doors in a specific 3851 state is not enough to guarantee they're jointly traversable, since 3852 if a series of doors requires the same kind of token and they use up 3853 those tokens, further logic is needed to understand that as the 3854 tokens get used up, their requirements may no longer be satisfied. 3855 3856 Also note that a requirement for tokens does NOT mean that tokens 3857 will be subtracted when traversing the door (you can have re-usable 3858 tokens after all). To implement a token cost, use both a requirement 3859 and a 'lose' effect. 3860 """ 3861 def __init__(self, tokenType: Token, cost: TokenCount) -> None: 3862 self.tokenType = tokenType 3863 self.cost = cost 3864 3865 def __hash__(self) -> int: 3866 return (17 * hash(self.tokenType)) + (11 * self.cost) 3867 3868 def __eq__(self, other: Any) -> bool: 3869 return ( 3870 isinstance(other, ReqTokens) 3871 and other.tokenType == self.tokenType 3872 and other.cost == self.cost 3873 ) 3874 3875 def __repr__(self): 3876 return f"ReqTokens({repr(self.tokenType)}, {repr(self.cost)})" 3877 3878 def satisfied( 3879 self, 3880 context: RequirementContext, 3881 dontRecurse: Optional[ 3882 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3883 ] = None 3884 ) -> bool: 3885 return combinedTokenCount(context.state, self.tokenType) >= self.cost 3886 3887 def walk(self) -> Generator[Requirement, None, None]: 3888 yield self 3889 3890 def asEffectList(self) -> List[Effect]: 3891 """ 3892 Returns a list containing a single 'set' effect which sets the 3893 required tokens (note that this may unnecessarily subtract 3894 tokens if the state had more than enough tokens beforehand). 3895 """ 3896 return [effect(set=(self.tokenType, self.cost))] 3897 3898 def unparse(self) -> str: 3899 return f'{self.tokenType}*{self.cost}'
A token requirement satisfied if the player possesses at least a certain number of a given type of token.
Note that checking the satisfaction of individual doors in a specific state is not enough to guarantee they're jointly traversable, since if a series of doors requires the same kind of token and they use up those tokens, further logic is needed to understand that as the tokens get used up, their requirements may no longer be satisfied.
Also note that a requirement for tokens does NOT mean that tokens will be subtracted when traversing the door (you can have re-usable tokens after all). To implement a token cost, use both a requirement and a 'lose' effect.
3878 def satisfied( 3879 self, 3880 context: RequirementContext, 3881 dontRecurse: Optional[ 3882 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3883 ] = None 3884 ) -> bool: 3885 return combinedTokenCount(context.state, self.tokenType) >= self.cost
This will return True
if the requirement is satisfied in the
given RequirementContext
, resolving mechanisms from the
context's set of decisions and graph, and respecting the
context's equivalences. It returns False
otherwise.
The dontRecurse
set should be unspecified to start, and will
be used to avoid infinite recursion in cases of circular
equivalences (requirements are not considered satisfied by
equivalence loops).
TODO: Examples
3890 def asEffectList(self) -> List[Effect]: 3891 """ 3892 Returns a list containing a single 'set' effect which sets the 3893 required tokens (note that this may unnecessarily subtract 3894 tokens if the state had more than enough tokens beforehand). 3895 """ 3896 return [effect(set=(self.tokenType, self.cost))]
Returns a list containing a single 'set' effect which sets the required tokens (note that this may unnecessarily subtract tokens if the state had more than enough tokens beforehand).
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
3902class ReqMechanism(Requirement): 3903 """ 3904 A mechanism requirement satisfied if the specified mechanism is in 3905 the specified state. The mechanism is specified by name and a lookup 3906 on that name will be performed when assessing the requirement, based 3907 on the specific position at which the requirement applies. However, 3908 if a `where` value is supplied, the lookup on the mechanism name will 3909 always start from that decision, regardless of where the requirement 3910 is being evaluated. 3911 """ 3912 def __init__( 3913 self, 3914 mechanism: AnyMechanismSpecifier, 3915 state: MechanismState, 3916 ) -> None: 3917 self.mechanism = mechanism 3918 self.reqState = state 3919 3920 # Normalize mechanism specifiers without any position information 3921 if isinstance(mechanism, tuple): 3922 if len(mechanism) != 4: 3923 raise ValueError( 3924 f"Mechanism specifier must have 4 parts if it's a" 3925 f" tuple. (Got: {mechanism})." 3926 ) 3927 elif all(x is None for x in mechanism[:3]): 3928 self.mechanism = mechanism[3] 3929 3930 def __hash__(self) -> int: 3931 return ( 3932 (11 * hash(self.mechanism)) 3933 + (31 * hash(self.reqState)) 3934 ) 3935 3936 def __eq__(self, other: Any) -> bool: 3937 return ( 3938 isinstance(other, ReqMechanism) 3939 and other.mechanism == self.mechanism 3940 and other.reqState == self.reqState 3941 ) 3942 3943 def __repr__(self): 3944 mRep = repr(self.mechanism) 3945 sRep = repr(self.reqState) 3946 return f"ReqMechanism({mRep}, {sRep})" 3947 3948 def satisfied( 3949 self, 3950 context: RequirementContext, 3951 dontRecurse: Optional[ 3952 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3953 ] = None 3954 ) -> bool: 3955 return mechanismInStateOrEquivalent( 3956 self.mechanism, 3957 self.reqState, 3958 context, 3959 dontRecurse 3960 ) 3961 3962 def walk(self) -> Generator[Requirement, None, None]: 3963 yield self 3964 3965 def asEffectList(self) -> List[Effect]: 3966 """ 3967 Returns a list containing a single 'set' effect which sets the 3968 required mechanism to the required state. 3969 """ 3970 return [effect(set=(self.mechanism, self.reqState))] 3971 3972 def unparse(self) -> str: 3973 if isinstance(self.mechanism, (MechanismID, MechanismName)): 3974 return f'{self.mechanism}:{self.reqState}' 3975 else: # Must be a MechanismSpecifier 3976 # TODO: This elsewhere! 3977 domain, zone, decision, mechanism = self.mechanism 3978 mspec = '' 3979 if domain is not None: 3980 mspec += domain + '//' 3981 if zone is not None: 3982 mspec += zone + '::' 3983 if decision is not None: 3984 mspec += decision + '::' 3985 mspec += mechanism 3986 return f'{mspec}:{self.reqState}'
A mechanism requirement satisfied if the specified mechanism is in
the specified state. The mechanism is specified by name and a lookup
on that name will be performed when assessing the requirement, based
on the specific position at which the requirement applies. However,
if a where
value is supplied, the lookup on the mechanism name will
always start from that decision, regardless of where the requirement
is being evaluated.
3912 def __init__( 3913 self, 3914 mechanism: AnyMechanismSpecifier, 3915 state: MechanismState, 3916 ) -> None: 3917 self.mechanism = mechanism 3918 self.reqState = state 3919 3920 # Normalize mechanism specifiers without any position information 3921 if isinstance(mechanism, tuple): 3922 if len(mechanism) != 4: 3923 raise ValueError( 3924 f"Mechanism specifier must have 4 parts if it's a" 3925 f" tuple. (Got: {mechanism})." 3926 ) 3927 elif all(x is None for x in mechanism[:3]): 3928 self.mechanism = mechanism[3]
3948 def satisfied( 3949 self, 3950 context: RequirementContext, 3951 dontRecurse: Optional[ 3952 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3953 ] = None 3954 ) -> bool: 3955 return mechanismInStateOrEquivalent( 3956 self.mechanism, 3957 self.reqState, 3958 context, 3959 dontRecurse 3960 )
This will return True
if the requirement is satisfied in the
given RequirementContext
, resolving mechanisms from the
context's set of decisions and graph, and respecting the
context's equivalences. It returns False
otherwise.
The dontRecurse
set should be unspecified to start, and will
be used to avoid infinite recursion in cases of circular
equivalences (requirements are not considered satisfied by
equivalence loops).
TODO: Examples
3965 def asEffectList(self) -> List[Effect]: 3966 """ 3967 Returns a list containing a single 'set' effect which sets the 3968 required mechanism to the required state. 3969 """ 3970 return [effect(set=(self.mechanism, self.reqState))]
Returns a list containing a single 'set' effect which sets the required mechanism to the required state.
3972 def unparse(self) -> str: 3973 if isinstance(self.mechanism, (MechanismID, MechanismName)): 3974 return f'{self.mechanism}:{self.reqState}' 3975 else: # Must be a MechanismSpecifier 3976 # TODO: This elsewhere! 3977 domain, zone, decision, mechanism = self.mechanism 3978 mspec = '' 3979 if domain is not None: 3980 mspec += domain + '//' 3981 if zone is not None: 3982 mspec += zone + '::' 3983 if decision is not None: 3984 mspec += decision + '::' 3985 mspec += mechanism 3986 return f'{mspec}:{self.reqState}'
Returns a string which would convert back into this Requirement
object if you fed it to parsing.ParseFormat.parseRequirement
.
TODO: Move this over into parsing
?
Examples:
>>> r = ReqAny([
... ReqCapability('capability'),
... ReqTokens('token', 3),
... ReqMechanism('mechanism', 'state')
... ])
>>> rep = r.unparse()
>>> rep
'(capability|token*3|mechanism:state)'
>>> from . import parsing
>>> pf = parsing.ParseFormat()
>>> back = pf.parseRequirement(rep)
>>> back == r
True
>>> ReqNot(ReqNothing()).unparse()
'!(O)'
>>> ReqImpossible().unparse()
'X'
>>> r = ReqAny([ReqCapability('A'), ReqCapability('B'),
... ReqCapability('C')])
>>> rep = r.unparse()
>>> rep
'(A|B|C)'
>>> back = pf.parseRequirement(rep)
>>> back == r
True
Inherited Members
3989class ReqLevel(Requirement): 3990 """ 3991 A tag requirement satisfied if a specific skill is at or above the 3992 specified level. 3993 """ 3994 def __init__( 3995 self, 3996 skill: Skill, 3997 minLevel: Level, 3998 ) -> None: 3999 self.skill = skill 4000 self.minLevel = minLevel 4001 4002 def __hash__(self) -> int: 4003 return ( 4004 (79 * hash(self.skill)) 4005 + (55 * hash(self.minLevel)) 4006 ) 4007 4008 def __eq__(self, other: Any) -> bool: 4009 return ( 4010 isinstance(other, ReqLevel) 4011 and other.skill == self.skill 4012 and other.minLevel == self.minLevel 4013 ) 4014 4015 def __repr__(self): 4016 sRep = repr(self.skill) 4017 lRep = repr(self.minLevel) 4018 return f"ReqLevel({sRep}, {lRep})" 4019 4020 def satisfied( 4021 self, 4022 context: RequirementContext, 4023 dontRecurse: Optional[ 4024 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 4025 ] = None 4026 ) -> bool: 4027 return getSkillLevel(context.state, self.skill) >= self.minLevel 4028 4029 def walk(self) -> Generator[Requirement, None, None]: 4030 yield self 4031 4032 def asEffectList(self) -> List[Effect]: 4033 """ 4034 Returns a list containing a single 'set' effect which sets the 4035 required skill to the minimum required level. Note that this may 4036 reduce a skill level that was more than sufficient to meet the 4037 requirement. 4038 """ 4039 return [effect(set=("skill", self.skill, self.minLevel))] 4040 4041 def unparse(self) -> str: 4042 return f'{self.skill}^{self.minLevel}'
A tag requirement satisfied if a specific skill is at or above the specified level.
4020 def satisfied( 4021 self, 4022 context: RequirementContext, 4023 dontRecurse: Optional[ 4024 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 4025 ] = None 4026 ) -> bool: 4027 return getSkillLevel(context.state, self.skill) >= self.minLevel
This will return True
if the requirement is satisfied in the
given RequirementContext
, resolving mechanisms from the
context's set of decisions and graph, and respecting the
context's equivalences. It returns False
otherwise.
The dontRecurse
set should be unspecified to start, and will
be used to avoid infinite recursion in cases of circular
equivalences (requirements are not considered satisfied by
equivalence loops).
TODO: Examples
4032 def asEffectList(self) -> List[Effect]: 4033 """ 4034 Returns a list containing a single 'set' effect which sets the 4035 required skill to the minimum required level. Note that this may 4036 reduce a skill level that was more than sufficient to meet the 4037 requirement. 4038 """ 4039 return [effect(set=("skill", self.skill, self.minLevel))]
Returns a list containing a single 'set' effect which sets the required skill to the minimum required level. Note that this may reduce a skill level that was more than sufficient to meet the requirement.
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
4045class ReqTag(Requirement): 4046 """ 4047 A tag requirement satisfied if there is any active decision that has 4048 the specified value for the given tag (default value is 1 for tags 4049 where a value wasn't specified). Zone tags also satisfy the 4050 requirement if they're applied to zones that include active 4051 decisions. 4052 """ 4053 def __init__( 4054 self, 4055 tag: "Tag", 4056 value: "TagValue", 4057 ) -> None: 4058 self.tag = tag 4059 self.value = value 4060 4061 def __hash__(self) -> int: 4062 return ( 4063 (71 * hash(self.tag)) 4064 + (43 * hash(self.value)) 4065 ) 4066 4067 def __eq__(self, other: Any) -> bool: 4068 return ( 4069 isinstance(other, ReqTag) 4070 and other.tag == self.tag 4071 and other.value == self.value 4072 ) 4073 4074 def __repr__(self): 4075 tRep = repr(self.tag) 4076 vRep = repr(self.value) 4077 return f"ReqTag({tRep}, {vRep})" 4078 4079 def satisfied( 4080 self, 4081 context: RequirementContext, 4082 dontRecurse: Optional[ 4083 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 4084 ] = None 4085 ) -> bool: 4086 active = combinedDecisionSet(context.state) 4087 graph = context.graph 4088 zones = set() 4089 for decision in active: 4090 tags = graph.decisionTags(decision) 4091 if self.tag in tags and tags[self.tag] == self.value: 4092 return True 4093 zones |= graph.zoneAncestors(decision) 4094 for zone in zones: 4095 zTags = graph.zoneTags(zone) 4096 if self.tag in zTags and zTags[self.tag] == self.value: 4097 return True 4098 4099 return False 4100 4101 def walk(self) -> Generator[Requirement, None, None]: 4102 yield self 4103 4104 def asEffectList(self) -> List[Effect]: 4105 """ 4106 Returns a list containing a single 'set' effect which sets the 4107 required mechanism to the required state. 4108 """ 4109 raise TypeError( 4110 "Cannot convert ReqTag into an effect list:" 4111 " effects cannot apply/remove/change tags" 4112 ) 4113 4114 def unparse(self) -> str: 4115 return f'{self.tag}~{self.value!r}'
A tag requirement satisfied if there is any active decision that has the specified value for the given tag (default value is 1 for tags where a value wasn't specified). Zone tags also satisfy the requirement if they're applied to zones that include active decisions.
4079 def satisfied( 4080 self, 4081 context: RequirementContext, 4082 dontRecurse: Optional[ 4083 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 4084 ] = None 4085 ) -> bool: 4086 active = combinedDecisionSet(context.state) 4087 graph = context.graph 4088 zones = set() 4089 for decision in active: 4090 tags = graph.decisionTags(decision) 4091 if self.tag in tags and tags[self.tag] == self.value: 4092 return True 4093 zones |= graph.zoneAncestors(decision) 4094 for zone in zones: 4095 zTags = graph.zoneTags(zone) 4096 if self.tag in zTags and zTags[self.tag] == self.value: 4097 return True 4098 4099 return False
This will return True
if the requirement is satisfied in the
given RequirementContext
, resolving mechanisms from the
context's set of decisions and graph, and respecting the
context's equivalences. It returns False
otherwise.
The dontRecurse
set should be unspecified to start, and will
be used to avoid infinite recursion in cases of circular
equivalences (requirements are not considered satisfied by
equivalence loops).
TODO: Examples
4104 def asEffectList(self) -> List[Effect]: 4105 """ 4106 Returns a list containing a single 'set' effect which sets the 4107 required mechanism to the required state. 4108 """ 4109 raise TypeError( 4110 "Cannot convert ReqTag into an effect list:" 4111 " effects cannot apply/remove/change tags" 4112 )
Returns a list containing a single 'set' effect which sets the required mechanism to the required state.
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
4118class ReqNothing(Requirement): 4119 """ 4120 A requirement representing that something doesn't actually have a 4121 requirement. This requirement is always satisfied. 4122 """ 4123 def __hash__(self) -> int: 4124 return 127942 4125 4126 def __eq__(self, other: Any) -> bool: 4127 return isinstance(other, ReqNothing) 4128 4129 def __repr__(self): 4130 return "ReqNothing()" 4131 4132 def satisfied( 4133 self, 4134 context: RequirementContext, 4135 dontRecurse: Optional[ 4136 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 4137 ] = None 4138 ) -> bool: 4139 return True 4140 4141 def walk(self) -> Generator[Requirement, None, None]: 4142 yield self 4143 4144 def asEffectList(self) -> List[Effect]: 4145 """ 4146 Returns an empty list, since nothing is required. 4147 """ 4148 return [] 4149 4150 def unparse(self) -> str: 4151 return 'O'
A requirement representing that something doesn't actually have a requirement. This requirement is always satisfied.
4132 def satisfied( 4133 self, 4134 context: RequirementContext, 4135 dontRecurse: Optional[ 4136 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 4137 ] = None 4138 ) -> bool: 4139 return True
This will return True
if the requirement is satisfied in the
given RequirementContext
, resolving mechanisms from the
context's set of decisions and graph, and respecting the
context's equivalences. It returns False
otherwise.
The dontRecurse
set should be unspecified to start, and will
be used to avoid infinite recursion in cases of circular
equivalences (requirements are not considered satisfied by
equivalence loops).
TODO: Examples
4144 def asEffectList(self) -> List[Effect]: 4145 """ 4146 Returns an empty list, since nothing is required. 4147 """ 4148 return []
Returns an empty list, since nothing is required.
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
4154class ReqImpossible(Requirement): 4155 """ 4156 A requirement representing that something is impossible. This 4157 requirement is never satisfied. 4158 """ 4159 def __hash__(self) -> int: 4160 return 478743 4161 4162 def __eq__(self, other: Any) -> bool: 4163 return isinstance(other, ReqImpossible) 4164 4165 def __repr__(self): 4166 return "ReqImpossible()" 4167 4168 def satisfied( 4169 self, 4170 context: RequirementContext, 4171 dontRecurse: Optional[ 4172 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 4173 ] = None 4174 ) -> bool: 4175 return False 4176 4177 def walk(self) -> Generator[Requirement, None, None]: 4178 yield self 4179 4180 def asEffectList(self) -> List[Effect]: 4181 """ 4182 Raises a `TypeError` since a `ReqImpossible` cannot be converted 4183 into an effect which would allow the transition to be taken. 4184 """ 4185 raise TypeError( 4186 "Cannot convert ReqImpossible into an effect list:" 4187 " there are no powers or tokens which could be gained to" 4188 " satisfy this requirement." 4189 ) 4190 4191 def unparse(self) -> str: 4192 return 'X'
A requirement representing that something is impossible. This requirement is never satisfied.
4168 def satisfied( 4169 self, 4170 context: RequirementContext, 4171 dontRecurse: Optional[ 4172 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 4173 ] = None 4174 ) -> bool: 4175 return False
This will return True
if the requirement is satisfied in the
given RequirementContext
, resolving mechanisms from the
context's set of decisions and graph, and respecting the
context's equivalences. It returns False
otherwise.
The dontRecurse
set should be unspecified to start, and will
be used to avoid infinite recursion in cases of circular
equivalences (requirements are not considered satisfied by
equivalence loops).
TODO: Examples
4180 def asEffectList(self) -> List[Effect]: 4181 """ 4182 Raises a `TypeError` since a `ReqImpossible` cannot be converted 4183 into an effect which would allow the transition to be taken. 4184 """ 4185 raise TypeError( 4186 "Cannot convert ReqImpossible into an effect list:" 4187 " there are no powers or tokens which could be gained to" 4188 " satisfy this requirement." 4189 )
Raises a TypeError
since a ReqImpossible
cannot be converted
into an effect which would allow the transition to be taken.
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
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).
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 toReqImpossible
) 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.
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...
4304class NoTagValue: 4305 """ 4306 Class used to indicate no tag value for things that return tag values 4307 since `None` is a valid tag value. 4308 """ 4309 pass
Class used to indicate no tag value for things that return tag values
since None
is a valid tag value.
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.
A type alias: annotations are strings.
4331class ZoneInfo(NamedTuple): 4332 """ 4333 Zone info holds a level integer (starting from 0 as the level directly 4334 above decisions), a set of parent zones, a set of child decisions 4335 and/or zones, and zone tags and annotations. Zones at a particular 4336 level may only contain zones in lower levels, although zones at any 4337 level may also contain decisions directly. The norm is for zones at 4338 level 0 to contain decisions, while zones at higher levels contain 4339 zones from the level directly below them. 4340 4341 Note that zones may have multiple parents, because one sub-zone may be 4342 contained within multiple super-zones. 4343 """ 4344 level: int 4345 parents: Set[Zone] 4346 contents: Set[Union[DecisionID, Zone]] 4347 tags: Dict[Tag, TagValue] 4348 annotations: List[Annotation]
Zone info holds a level integer (starting from 0 as the level directly above decisions), a set of parent zones, a set of child decisions and/or zones, and zone tags and annotations. Zones at a particular level may only contain zones in lower levels, although zones at any level may also contain decisions directly. The norm is for zones at level 0 to contain decisions, while zones at higher levels contain zones from the level directly below them.
Note that zones may have multiple parents, because one sub-zone may be contained within multiple super-zones.
Create new instance of ZoneInfo(level, parents, contents, tags, annotations)
Inherited Members
- builtins.tuple
- index
- count
An alias for the empty string to indicate a default zone.
The valid action types for exploration actions (see
ExplorationAction
).
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 validExplorationAction
should be used instead. The string 'start' followed by a
DecisionID
/ (FocalPointName
-to-DecisionID
dictionary) / set-of-DecisionID
s position(s) specifier, anotherDecisionID
(orNone
), aDomain
, and then optionalCapabilitySet
, mechanism state dictionary, and custom state dictionary objects (each of which could instead beNone
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 aBadStart
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; useDiscreteExploration.createDomain
to create a domain first if necessary. Likewise, any specified decisions to activate must already exist, useDecisionGraph.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.
- A
The string 'take' followed by a
ContextSpecifier
,DecisionID
, andTransitionWithOutcomes
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). NormalDomainFocalization
-based rules for updating active decisions determine what happens besides transition consequences, but for a 'singular'-focalized domain (as determined by the activeFocalContext
in theDiscreteExploration
's currentState
), 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 theDecisionID
used is an inactive decision.- For 'plural'-focalized domains, a
FocalPointSpecifier
is needed to know which of the plural focal points to move, this takes the place of the sourceContextSpecifier
andDecisionID
since it provides that information. In this case the third item is still aTransition
.
- For 'plural'-focalized domains, a
The string 'warp' followed by either a
DecisionID
, or aFocalPointSpecifier
tuple followed by aDecisionID
. 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 ofDomain
s. 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 completeFocalContext
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 (seerevertedState
). 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 theDecisionGraph
are preserved.
4524def describeExplorationAction( 4525 situation: 'Situation', 4526 action: Optional[ExplorationAction] 4527) -> str: 4528 """ 4529 Returns a string description of the action represented by an 4530 `ExplorationAction` object (or the string '(no action)' for the value 4531 `None`). Uses the provided situation to look up things like decision 4532 names, focal point positions, and destinations where relevant. Does 4533 not know details of which graph it is applied to or the outcomes of 4534 the action, so just describes what is being attempted. 4535 """ 4536 if action is None: 4537 return '(no action)' 4538 4539 if ( 4540 not isinstance(action, tuple) 4541 or len(action) == 0 4542 ): 4543 raise TypeError(f"Not an exploration action: {action!r}") 4544 4545 graph = situation.graph 4546 4547 if action[0] not in get_args(ExplorationActionType): 4548 raise ValueError(f"Invalid exploration action type: {action[0]!r}") 4549 4550 aType = action[0] 4551 if aType == 'noAction': 4552 return "wait" 4553 4554 elif aType == 'start': 4555 if len(action) != 7: 4556 raise ValueError( 4557 f"Wrong number of parts for 'start' action: {action!r}" 4558 ) 4559 ( 4560 _, 4561 startActive, 4562 primary, 4563 domain, 4564 capabilities, 4565 mechanisms, 4566 custom 4567 ) = action 4568 Union[DecisionID, Dict[FocalPointName, DecisionID], Set[DecisionID]] 4569 at: str 4570 if primary is None: 4571 if isinstance(startActive, DecisionID): 4572 at = f" at {graph.identityOf(startActive)}" 4573 elif isinstance(startActive, dict): 4574 at = f" with {len(startActive)} focal point(s)" 4575 elif isinstance(startActive, set): 4576 at = f" from {len(startActive)} decisions" 4577 else: 4578 raise TypeError( 4579 f"Invalid type for starting location:" 4580 f" {type(startActive)}" 4581 ) 4582 else: 4583 at = f" at {graph.identityOf(primary)}" 4584 if isinstance(startActive, dict): 4585 at += f" (among {len(startActive)} focal point(s))" 4586 elif isinstance(startActive, set): 4587 at += f" (among {len(startActive)} decisions)" 4588 4589 return ( 4590 f"start exploring domain {domain}{at}" 4591 ) 4592 4593 elif aType == 'explore': 4594 if len(action) == 7: 4595 assert isinstance(action[2], DecisionID) 4596 fromID = action[2] 4597 assert isinstance(action[3], tuple) 4598 transitionName, specified = action[3] 4599 assert isinstance(action[3][0], Transition) 4600 assert isinstance(action[3][1], list) 4601 assert all(isinstance(x, bool) for x in action[3][1]) 4602 elif len(action) == 6: 4603 assert isinstance(action[1], tuple) 4604 assert len(action[1]) == 3 4605 fpPos = resolvePosition(situation, action[1]) 4606 if fpPos is None: 4607 raise ValueError( 4608 f"Invalid focal point specifier: no position found" 4609 f" for:\n{action[1]}" 4610 ) 4611 else: 4612 fromID = fpPos 4613 transitionName, specified = action[2] 4614 else: 4615 raise ValueError( 4616 f"Wrong number of parts for 'explore' action: {action!r}" 4617 ) 4618 4619 destID = graph.getDestination(fromID, transitionName) 4620 4621 frDesc = graph.identityOf(fromID) 4622 deDesc = graph.identityOf(destID) 4623 4624 newNameOrDest: Union[DecisionName, DecisionID, None] = action[-3] 4625 nowWord = "now " 4626 if newNameOrDest is None: 4627 if destID is None: 4628 nowWord = "" 4629 newName = "INVALID: an unspecified + unnamed decision" 4630 else: 4631 nowWord = "" 4632 newName = graph.nameFor(destID) 4633 elif isinstance(newNameOrDest, DecisionName): 4634 newName = newNameOrDest 4635 else: 4636 assert isinstance(newNameOrDest, DecisionID) 4637 destID = newNameOrDest 4638 nowWord = "now reaches " 4639 newName = graph.identityOf(destID) 4640 4641 newZone: Union[Zone, None] = action[-1] 4642 if newZone in (None, ""): 4643 deDesc = f"{destID} ({nowWord}{newName})" 4644 else: 4645 deDesc = f"{destID} ({nowWord}{newZone}::{newName})" 4646 # TODO: Don't hardcode '::' here? 4647 4648 oDesc = "" 4649 if len(specified) > 0: 4650 oDesc = " with outcomes: " 4651 first = True 4652 for o in specified: 4653 if first: 4654 first = False 4655 else: 4656 oDesc += ", " 4657 if o: 4658 oDesc += "success" 4659 else: 4660 oDesc += "failure" 4661 4662 return ( 4663 f"explore {transitionName} from decision {frDesc} to" 4664 f" {deDesc}{oDesc}" 4665 ) 4666 4667 elif aType == 'take': 4668 if len(action) == 4: 4669 assert action[1] in get_args(ContextSpecifier) 4670 assert isinstance(action[2], DecisionID) 4671 assert isinstance(action[3], tuple) 4672 assert len(action[3]) == 2 4673 assert isinstance(action[3][0], Transition) 4674 assert isinstance(action[3][1], list) 4675 context = action[1] 4676 fromID = action[2] 4677 transitionName, specified = action[3] 4678 destID = graph.getDestination(fromID, transitionName) 4679 oDesc = "" 4680 if len(specified) > 0: 4681 oDesc = " with outcomes: " 4682 first = True 4683 for o in specified: 4684 if first: 4685 first = False 4686 else: 4687 oDesc += ", " 4688 if o: 4689 oDesc += "success" 4690 else: 4691 oDesc += "failure" 4692 if fromID == destID: # an action 4693 return f"do action {transitionName}" 4694 else: # normal transition 4695 frDesc = graph.identityOf(fromID) 4696 deDesc = graph.identityOf(destID) 4697 4698 return ( 4699 f"take {transitionName} from decision {frDesc} to" 4700 f" {deDesc}{oDesc}" 4701 ) 4702 elif len(action) == 3: 4703 assert isinstance(action[1], tuple) 4704 assert len(action[1]) == 3 4705 assert isinstance(action[2], tuple) 4706 assert len(action[2]) == 2 4707 assert isinstance(action[2][0], Transition) 4708 assert isinstance(action[2][1], list) 4709 _, focalPoint, transition = action 4710 context, domain, name = focalPoint 4711 frID = resolvePosition(situation, focalPoint) 4712 4713 transitionName, specified = action[2] 4714 oDesc = "" 4715 if len(specified) > 0: 4716 oDesc = " with outcomes: " 4717 first = True 4718 for o in specified: 4719 if first: 4720 first = False 4721 else: 4722 oDesc += ", " 4723 if o: 4724 oDesc += "success" 4725 else: 4726 oDesc += "failure" 4727 4728 if frID is None: 4729 return ( 4730 f"invalid action (moves {focalPoint} which doesn't" 4731 f" exist)" 4732 ) 4733 else: 4734 destID = graph.getDestination(frID, transitionName) 4735 4736 if frID == destID: 4737 return "do action {transition}{oDesc}" 4738 else: 4739 frDesc = graph.identityOf(frID) 4740 deDesc = graph.identityOf(destID) 4741 return ( 4742 f"{name} takes {transition} from {frDesc} to" 4743 f" {deDesc}{oDesc}" 4744 ) 4745 else: 4746 raise ValueError( 4747 f"Wrong number of parts for 'take' action: {action!r}" 4748 ) 4749 4750 elif aType == 'warp': 4751 if len(action) != 3: 4752 raise ValueError( 4753 f"Wrong number of parts for 'warp' action: {action!r}" 4754 ) 4755 if action[1] in get_args(ContextSpecifier): 4756 assert isinstance(action[1], str) 4757 assert isinstance(action[2], DecisionID) 4758 _, context, destination = action 4759 deDesc = graph.identityOf(destination) 4760 return f"warp to {deDesc!r}" 4761 elif isinstance(action[1], tuple) and len(action[1]) == 3: 4762 assert isinstance(action[2], DecisionID) 4763 _, focalPoint, destination = action 4764 context, domain, name = focalPoint 4765 deDesc = graph.identityOf(destination) 4766 frID = resolvePosition(situation, focalPoint) 4767 frDesc = graph.identityOf(frID) 4768 return f"{name} warps to {deDesc!r}" 4769 else: 4770 raise TypeError( 4771 f"Invalid second part for 'warp' action: {action!r}" 4772 ) 4773 4774 elif aType == 'focus': 4775 if len(action) != 4: 4776 raise ValueError( 4777 "Wrong number of parts for 'focus' action: {action!r}" 4778 ) 4779 _, context, deactivate, activate = action 4780 assert isinstance(deactivate, set) 4781 assert isinstance(activate, set) 4782 result = "change in active domains: " 4783 clauses = [] 4784 if len(deactivate) > 0: 4785 clauses.append("deactivate domain(s) {', '.join(deactivate)}") 4786 if len(activate) > 0: 4787 clauses.append("activate domain(s) {', '.join(activate)}") 4788 result += '; '.join(clauses) 4789 return result 4790 4791 elif aType == 'swap': 4792 if len(action) != 2: 4793 raise ValueError( 4794 "Wrong number of parts for 'swap' action: {action!r}" 4795 ) 4796 _, fcName = action 4797 return f"swap to focal context {fcName!r}" 4798 4799 elif aType == 'focalize': 4800 if len(action) != 2: 4801 raise ValueError( 4802 "Wrong number of parts for 'focalize' action: {action!r}" 4803 ) 4804 _, fcName = action 4805 return f"create new focal context {fcName!r}" 4806 4807 else: 4808 raise RuntimeError( 4809 "Missing case for exploration action type: {action[0]!r}" 4810 )
Returns a string description of the action represented by an
ExplorationAction
object (or the string '(no action)' for the value
None
). Uses the provided situation to look up things like decision
names, focal point positions, and destinations where relevant. Does
not know details of which graph it is applied to or the outcomes of
the action, so just describes what is being attempted.
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.
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 sameDecisionID
for unchanged nodes. - 'state': The game
State
for that step, including common and activeFocalContext
s 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, orNone
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 followingSituation
in theDiscreteExploration
. 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 useNone
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.
Create new instance of Situation(graph, state, type, action, saves, tags, annotations)
Alias for field number 3
Inherited Members
- builtins.tuple
- index
- count
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.
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.
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.
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.
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).
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 FocalContext
s.
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.
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
).
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.
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.
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).
Inherited Members
- builtins.BaseException
- with_traceback
- add_note
- args
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-Consequence
s 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
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
Consequence
s 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
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
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.
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.
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.
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.
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.
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
An (x, y) pair in unspecified coordinates.
Maps one or more decision IDs to LayoutPosition
s for those decisions.
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.
Features in a feature graph have unique integer identifiers that are assigned automatically in order of creation.
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.
5780class FeatureSpecifier(NamedTuple): 5781 """ 5782 There are several ways to specify a feature within a `FeatureGraph`: 5783 Simplest is to just include the `FeatureID` directly (in that case 5784 the domain must be `None` and the 'within' sequence must be empty). 5785 A specific domain and/or a sequence of containing features (starting 5786 from most-external to most-internal) may also be specified when a 5787 string is used as the feature itself, to help disambiguate (when an 5788 ambiguous `FeatureSpecifier` is used, 5789 `AmbiguousFeatureSpecifierError` may arise in some cases). For any 5790 feature, a part may also be specified indicating which part of the 5791 feature is being referred to; this can be `None` when not referring 5792 to any specific sub-part. 5793 """ 5794 domain: Optional[Domain] 5795 within: Sequence[Feature] 5796 feature: Union[Feature, FeatureID] 5797 part: Optional[Part]
There are several ways to specify a feature within a FeatureGraph
:
Simplest is to just include the FeatureID
directly (in that case
the domain must be None
and the 'within' sequence must be empty).
A specific domain and/or a sequence of containing features (starting
from most-external to most-internal) may also be specified when a
string is used as the feature itself, to help disambiguate (when an
ambiguous FeatureSpecifier
is used,
AmbiguousFeatureSpecifierError
may arise in some cases). For any
feature, a part may also be specified indicating which part of the
feature is being referred to; this can be None
when not referring
to any specific sub-part.
Create new instance of FeatureSpecifier(domain, within, feature, part)
Inherited Members
- builtins.tuple
- index
- count
5800def feature( 5801 name: Feature, 5802 part: Optional[Part] = None, 5803 domain: Optional[Domain] = None, 5804 within: Optional[Sequence[Feature]] = None 5805) -> FeatureSpecifier: 5806 """ 5807 Builds a `FeatureSpecifier` with some defaults. The default domain 5808 is `None`, and by default the feature has an empty 'within' field and 5809 its part field is `None`. 5810 """ 5811 if within is None: 5812 within = [] 5813 return FeatureSpecifier( 5814 domain=domain, 5815 within=within, 5816 feature=name, 5817 part=part 5818 )
Builds a FeatureSpecifier
with some defaults. The default domain
is None
, and by default the feature has an empty 'within' field and
its part field is None
.
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
.
5834def normalizeFeatureSpecifier(spec: AnyFeatureSpecifier) -> FeatureSpecifier: 5835 """ 5836 Turns an `AnyFeatureSpecifier` into a `FeatureSpecifier`. Note that 5837 it does not do parsing from a complex string. Use 5838 `parsing.ParseFormat.parseFeatureSpecifier` for that. 5839 5840 It will turn a feature specifier with an int-convertible feature name 5841 into a feature-ID-based specifier, discarding any domain and/or zone 5842 parts. 5843 5844 TODO: Issue a warning if parts are discarded? 5845 """ 5846 if isinstance(spec, (FeatureID, Feature)): 5847 return FeatureSpecifier( 5848 domain=None, 5849 within=[], 5850 feature=spec, 5851 part=None 5852 ) 5853 elif isinstance(spec, FeatureSpecifier): 5854 try: 5855 fID = int(spec.feature) 5856 return FeatureSpecifier(None, [], fID, spec.part) 5857 except ValueError: 5858 return spec 5859 else: 5860 raise TypeError( 5861 f"Invalid feature specifier type: {type(spec)}" 5862 )
Turns an AnyFeatureSpecifier
into a FeatureSpecifier
. Note that
it does not do parsing from a complex string. Use
parsing.ParseFormat.parseFeatureSpecifier
for that.
It will turn a feature specifier with an int-convertible feature name into a feature-ID-based specifier, discarding any domain and/or zone parts.
TODO: Issue a warning if parts are discarded?
5865class MetricSpace: 5866 """ 5867 TODO 5868 Represents a variable-dimensional coordinate system within which 5869 locations can be identified by coordinates. May (or may not) include 5870 a reference to one or more images which are visual representation(s) 5871 of the space. 5872 """ 5873 def __init__(self, name: str): 5874 self.name = name 5875 5876 self.points: Dict[PointID, Coords] = {} 5877 # Holds all IDs and their corresponding coordinates as key/value 5878 # pairs 5879 5880 self.nextID: PointID = 0 5881 # ID numbers should not be repeated or reused 5882 5883 def addPoint(self, coords: Coords) -> PointID: 5884 """ 5885 Given a sequence (list/array/etc) of int coordinates, creates a 5886 point and adds it to the metric space object 5887 5888 >>> ms = MetricSpace("test") 5889 >>> ms.addPoint([2, 3]) 5890 0 5891 >>> #expected result 5892 >>> ms.addPoint([2, 7, 0]) 5893 1 5894 """ 5895 thisID = self.nextID 5896 5897 self.nextID += 1 5898 5899 self.points[thisID] = coords # creates key value pair 5900 5901 return thisID 5902 5903 # How do we "add" things to the metric space? What data structure 5904 # is it? dictionary 5905 5906 def removePoint(self, thisID: PointID) -> None: 5907 """ 5908 Given the ID of a point/coord, checks the dictionary 5909 (points) for that key and removes the key/value pair from 5910 it. 5911 5912 >>> ms = MetricSpace("test") 5913 >>> ms.addPoint([2, 3]) 5914 0 5915 >>> ms.removePoint(0) 5916 >>> ms.removePoint(0) 5917 Traceback (most recent call last): 5918 ... 5919 KeyError... 5920 >>> #expected result should be a caught KeyNotFound exception 5921 """ 5922 self.points.pop(thisID) 5923 5924 def distance(self, origin: AnyPoint, dest: AnyPoint) -> float: 5925 """ 5926 Given an orgin point and destination point, returns the 5927 distance between the two points as a float. 5928 5929 >>> ms = MetricSpace("test") 5930 >>> ms.addPoint([4, 0]) 5931 0 5932 >>> ms.addPoint([1, 0]) 5933 1 5934 >>> ms.distance(0, 1) 5935 3.0 5936 >>> p1 = ms.addPoint([4, 3]) 5937 >>> p2 = ms.addPoint([4, 9]) 5938 >>> ms.distance(p1, p2) 5939 6.0 5940 >>> ms.distance([8, 6], [4, 6]) 5941 4.0 5942 >>> ms.distance([1, 1], [1, 1]) 5943 0.0 5944 >>> ms.distance([-2, -3], [-5, -7]) 5945 5.0 5946 >>> ms.distance([2.5, 3.7], [4.9, 6.1]) 5947 3.394112549695428 5948 """ 5949 if isinstance(origin, PointID): 5950 coord1 = self.points[origin] 5951 else: 5952 coord1 = origin 5953 5954 if isinstance(dest, PointID): 5955 coord2 = self.points[dest] 5956 else: 5957 coord2 = dest 5958 5959 inside = 0.0 5960 5961 for dim in range(max(len(coord1), len(coord2))): 5962 if dim < len(coord1): 5963 val1 = coord1[dim] 5964 else: 5965 val1 = 0 5966 if dim < len(coord2): 5967 val2 = coord2[dim] 5968 else: 5969 val2 = 0 5970 5971 inside += (val2 - val1)**2 5972 5973 result = math.sqrt(inside) 5974 return result 5975 5976 def NDCoords( 5977 self, 5978 point: AnyPoint, 5979 numDimension: int 5980 ) -> Coords: 5981 """ 5982 Given a 2D set of coordinates (x, y), converts them to the desired 5983 dimension 5984 5985 >>> ms = MetricSpace("test") 5986 >>> ms.NDCoords([5, 9], 3) 5987 [5, 9, 0] 5988 >>> ms.NDCoords([3, 1], 1) 5989 [3] 5990 """ 5991 if isinstance(point, PointID): 5992 coords = self.points[point] 5993 else: 5994 coords = point 5995 5996 seqLength = len(coords) 5997 5998 if seqLength != numDimension: 5999 6000 newCoords: Coords 6001 6002 if seqLength < numDimension: 6003 6004 newCoords = [item for item in coords] 6005 6006 for i in range(numDimension - seqLength): 6007 newCoords.append(0) 6008 6009 else: 6010 newCoords = coords[:numDimension] 6011 6012 return newCoords 6013 6014 def lastID(self) -> PointID: 6015 """ 6016 Returns the most updated ID of the metricSpace instance. The nextID 6017 field is always 1 more than the last assigned ID. Assumes that there 6018 has at least been one ID assigned to a point as a key value pair 6019 in the dictionary. Returns 0 if that is not the case. Does not 6020 consider possible counting errors if a point has been removed from 6021 the dictionary. The last ID does not neccessarily equal the number 6022 of points in the metricSpace (or in the dictionary). 6023 6024 >>> ms = MetricSpace("test") 6025 >>> ms.lastID() 6026 0 6027 >>> ms.addPoint([2, 3]) 6028 0 6029 >>> ms.addPoint([2, 7, 0]) 6030 1 6031 >>> ms.addPoint([2, 7]) 6032 2 6033 >>> ms.lastID() 6034 2 6035 >>> ms.removePoint(2) 6036 >>> ms.lastID() 6037 2 6038 """ 6039 if self.nextID < 1: 6040 return self.nextID 6041 return self.nextID - 1
TODO Represents a variable-dimensional coordinate system within which locations can be identified by coordinates. May (or may not) include a reference to one or more images which are visual representation(s) of the space.
5883 def addPoint(self, coords: Coords) -> PointID: 5884 """ 5885 Given a sequence (list/array/etc) of int coordinates, creates a 5886 point and adds it to the metric space object 5887 5888 >>> ms = MetricSpace("test") 5889 >>> ms.addPoint([2, 3]) 5890 0 5891 >>> #expected result 5892 >>> ms.addPoint([2, 7, 0]) 5893 1 5894 """ 5895 thisID = self.nextID 5896 5897 self.nextID += 1 5898 5899 self.points[thisID] = coords # creates key value pair 5900 5901 return thisID 5902 5903 # How do we "add" things to the metric space? What data structure 5904 # is it? dictionary
Given a sequence (list/array/etc) of int coordinates, creates a point and adds it to the metric space object
>>> ms = MetricSpace("test")
>>> ms.addPoint([2, 3])
0
>>> #expected result
>>> ms.addPoint([2, 7, 0])
1
5906 def removePoint(self, thisID: PointID) -> None: 5907 """ 5908 Given the ID of a point/coord, checks the dictionary 5909 (points) for that key and removes the key/value pair from 5910 it. 5911 5912 >>> ms = MetricSpace("test") 5913 >>> ms.addPoint([2, 3]) 5914 0 5915 >>> ms.removePoint(0) 5916 >>> ms.removePoint(0) 5917 Traceback (most recent call last): 5918 ... 5919 KeyError... 5920 >>> #expected result should be a caught KeyNotFound exception 5921 """ 5922 self.points.pop(thisID)
Given the ID of a point/coord, checks the dictionary (points) for that key and removes the key/value pair from it.
>>> ms = MetricSpace("test")
>>> ms.addPoint([2, 3])
0
>>> ms.removePoint(0)
>>> ms.removePoint(0)
Traceback (most recent call last):
...
KeyError...
>>> #expected result should be a caught KeyNotFound exception
5924 def distance(self, origin: AnyPoint, dest: AnyPoint) -> float: 5925 """ 5926 Given an orgin point and destination point, returns the 5927 distance between the two points as a float. 5928 5929 >>> ms = MetricSpace("test") 5930 >>> ms.addPoint([4, 0]) 5931 0 5932 >>> ms.addPoint([1, 0]) 5933 1 5934 >>> ms.distance(0, 1) 5935 3.0 5936 >>> p1 = ms.addPoint([4, 3]) 5937 >>> p2 = ms.addPoint([4, 9]) 5938 >>> ms.distance(p1, p2) 5939 6.0 5940 >>> ms.distance([8, 6], [4, 6]) 5941 4.0 5942 >>> ms.distance([1, 1], [1, 1]) 5943 0.0 5944 >>> ms.distance([-2, -3], [-5, -7]) 5945 5.0 5946 >>> ms.distance([2.5, 3.7], [4.9, 6.1]) 5947 3.394112549695428 5948 """ 5949 if isinstance(origin, PointID): 5950 coord1 = self.points[origin] 5951 else: 5952 coord1 = origin 5953 5954 if isinstance(dest, PointID): 5955 coord2 = self.points[dest] 5956 else: 5957 coord2 = dest 5958 5959 inside = 0.0 5960 5961 for dim in range(max(len(coord1), len(coord2))): 5962 if dim < len(coord1): 5963 val1 = coord1[dim] 5964 else: 5965 val1 = 0 5966 if dim < len(coord2): 5967 val2 = coord2[dim] 5968 else: 5969 val2 = 0 5970 5971 inside += (val2 - val1)**2 5972 5973 result = math.sqrt(inside) 5974 return result
Given an orgin point and destination point, returns the distance between the two points as a float.
>>> ms = MetricSpace("test")
>>> ms.addPoint([4, 0])
0
>>> ms.addPoint([1, 0])
1
>>> ms.distance(0, 1)
3.0
>>> p1 = ms.addPoint([4, 3])
>>> p2 = ms.addPoint([4, 9])
>>> ms.distance(p1, p2)
6.0
>>> ms.distance([8, 6], [4, 6])
4.0
>>> ms.distance([1, 1], [1, 1])
0.0
>>> ms.distance([-2, -3], [-5, -7])
5.0
>>> ms.distance([2.5, 3.7], [4.9, 6.1])
3.394112549695428
5976 def NDCoords( 5977 self, 5978 point: AnyPoint, 5979 numDimension: int 5980 ) -> Coords: 5981 """ 5982 Given a 2D set of coordinates (x, y), converts them to the desired 5983 dimension 5984 5985 >>> ms = MetricSpace("test") 5986 >>> ms.NDCoords([5, 9], 3) 5987 [5, 9, 0] 5988 >>> ms.NDCoords([3, 1], 1) 5989 [3] 5990 """ 5991 if isinstance(point, PointID): 5992 coords = self.points[point] 5993 else: 5994 coords = point 5995 5996 seqLength = len(coords) 5997 5998 if seqLength != numDimension: 5999 6000 newCoords: Coords 6001 6002 if seqLength < numDimension: 6003 6004 newCoords = [item for item in coords] 6005 6006 for i in range(numDimension - seqLength): 6007 newCoords.append(0) 6008 6009 else: 6010 newCoords = coords[:numDimension] 6011 6012 return newCoords
Given a 2D set of coordinates (x, y), converts them to the desired dimension
>>> ms = MetricSpace("test")
>>> ms.NDCoords([5, 9], 3)
[5, 9, 0]
>>> ms.NDCoords([3, 1], 1)
[3]
6014 def lastID(self) -> PointID: 6015 """ 6016 Returns the most updated ID of the metricSpace instance. The nextID 6017 field is always 1 more than the last assigned ID. Assumes that there 6018 has at least been one ID assigned to a point as a key value pair 6019 in the dictionary. Returns 0 if that is not the case. Does not 6020 consider possible counting errors if a point has been removed from 6021 the dictionary. The last ID does not neccessarily equal the number 6022 of points in the metricSpace (or in the dictionary). 6023 6024 >>> ms = MetricSpace("test") 6025 >>> ms.lastID() 6026 0 6027 >>> ms.addPoint([2, 3]) 6028 0 6029 >>> ms.addPoint([2, 7, 0]) 6030 1 6031 >>> ms.addPoint([2, 7]) 6032 2 6033 >>> ms.lastID() 6034 2 6035 >>> ms.removePoint(2) 6036 >>> ms.lastID() 6037 2 6038 """ 6039 if self.nextID < 1: 6040 return self.nextID 6041 return self.nextID - 1
Returns the most updated ID of the metricSpace instance. The nextID field is always 1 more than the last assigned ID. Assumes that there has at least been one ID assigned to a point as a key value pair in the dictionary. Returns 0 if that is not the case. Does not consider possible counting errors if a point has been removed from the dictionary. The last ID does not neccessarily equal the number of points in the metricSpace (or in the dictionary).
>>> ms = MetricSpace("test")
>>> ms.lastID()
0
>>> ms.addPoint([2, 3])
0
>>> ms.addPoint([2, 7, 0])
1
>>> ms.addPoint([2, 7])
2
>>> ms.lastID()
2
>>> ms.removePoint(2)
>>> ms.lastID()
2
6044def featurePart(spec: AnyFeatureSpecifier, part: Part) -> FeatureSpecifier: 6045 """ 6046 Returns a new feature specifier (and/or normalizes to one) that 6047 contains the specified part in the 'part' slot. If the provided 6048 feature specifier already contains a 'part', that will be replaced. 6049 6050 For example: 6051 6052 >>> featurePart('town', 'north') 6053 FeatureSpecifier(domain=None, within=[], feature='town', part='north') 6054 >>> featurePart(5, 'top') 6055 FeatureSpecifier(domain=None, within=[], feature=5, part='top') 6056 >>> featurePart( 6057 ... FeatureSpecifier('dom', ['one', 'two'], 'three', 'middle'), 6058 ... 'top' 6059 ... ) 6060 FeatureSpecifier(domain='dom', within=['one', 'two'], feature='three',\ 6061 part='top') 6062 >>> featurePart(FeatureSpecifier(None, ['region'], 'place', None), 'top') 6063 FeatureSpecifier(domain=None, within=['region'], feature='place',\ 6064 part='top') 6065 """ 6066 spec = normalizeFeatureSpecifier(spec) 6067 return FeatureSpecifier(spec.domain, spec.within, spec.feature, part)
Returns a new feature specifier (and/or normalizes to one) that contains the specified part in the 'part' slot. If the provided feature specifier already contains a 'part', that will be replaced.
For example:
>>> featurePart('town', 'north')
FeatureSpecifier(domain=None, within=[], feature='town', part='north')
>>> featurePart(5, 'top')
FeatureSpecifier(domain=None, within=[], feature=5, part='top')
>>> featurePart(
... FeatureSpecifier('dom', ['one', 'two'], 'three', 'middle'),
... 'top'
... )
FeatureSpecifier(domain='dom', within=['one', 'two'], feature='three', part='top')
>>> featurePart(FeatureSpecifier(None, ['region'], 'place', None), 'top')
FeatureSpecifier(domain=None, within=['region'], feature='place', part='top')
The different types of features that a FeatureGraph
can have:
- Nodes, representing destinations, and/or intersections. A node is something that one can be "at" and possibly "in."
- Paths, connecting nodes and/or other elements. Also used to represent access points (like doorways between regions) even when they don't have length.
- Edges, separating regions and/or impeding movement (but a door is also a kind of edge).
- Regions, enclosing other elements and/or regions. Besides via containment, region-region connections are mediated by nodes, paths, and/or edges.
- Landmarks, which are recognizable and possibly visible from afar.
- 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
Consequence
s to indicate what happens when it is activated. - 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.
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
FeatureAffordance
s) - '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.
- 'action': the action whose use trips the trigger (one of the
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.
The reciprocal feature relation types for each FeatureRelationshipType
which has a required reciprocal.
6228class FeatureDecision(TypedDict): 6229 """ 6230 Represents a decision made during exploration, including the 6231 position(s) at which the explorer made the decision, which 6232 feature(s) were most relevant to the decision and what course of 6233 action was decided upon (see `FeatureAction`). Has the following 6234 slots: 6235 6236 - 'type': The type of decision (see `exploration.core.DecisionType`). 6237 - 'domains': A set of domains which are active during the decision, 6238 as opposed to domains which may be unfocused or otherwise 6239 inactive. 6240 - 'focus': An optional single `FeatureSpecifier` which represents the 6241 focal character or object for a decision. May be `None` e.g. in 6242 cases where a menu is in focus. Note that the 'positions' slot 6243 determines which positions are relevant to the decision, 6244 potentially separately from the focus but usually overlapping it. 6245 - 'positions': A dictionary mapping `core.Domain`s to sets of 6246 `FeatureSpecifier`s representing the player's position(s) in 6247 each domain. Some domains may function like tech trees, where 6248 the set of positions only expands over time. Others may function 6249 like a single avatar in a virtual world, where there is only one 6250 position. Still others might function like a group of virtual 6251 avatars, with multiple positions that can be updated 6252 independently. 6253 - 'intention': A `FeatureAction` indicating the action taken or 6254 attempted next as a result of the decision. 6255 """ 6256 # TODO: HERE 6257 pass
Represents a decision made during exploration, including the
position(s) at which the explorer made the decision, which
feature(s) were most relevant to the decision and what course of
action was decided upon (see FeatureAction
). Has the following
slots:
- 'type': The type of decision (see
DecisionType
). - 'domains': A set of domains which are active during the decision, as opposed to domains which may be unfocused or otherwise inactive.
- 'focus': An optional single
FeatureSpecifier
which represents the focal character or object for a decision. May beNone
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.Domain
s to sets ofFeatureSpecifier
s 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.
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:
- Place affordances touching or within the entity.
- 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.
The mapping from affordances to the sets of feature types those affordances apply to.
6337class FeatureEffect(TypedDict): 6338 """ 6339 Similar to `Effect` but with more options for how to manipulate the 6340 game state. This represents a single concrete change to either 6341 internal game state, or to the feature graph. Multiple changes 6342 (possibly with random factors involved) can be represented by a 6343 `Consequence`; a `FeatureEffect` is used as a leaf in a `Consequence` 6344 tree. 6345 """ 6346 type: Literal[ 6347 'gain', 6348 'lose', 6349 'toggle', 6350 'deactivate', 6351 'move', 6352 'focus', 6353 'initiate' 6354 'foreground', 6355 'background', 6356 ] 6357 value: Union[ 6358 Capability, 6359 Tuple[Token, int], 6360 List[Capability], 6361 None 6362 ] 6363 charges: Optional[int] 6364 delay: Optional[int]
Similar to Effect
but with more options for how to manipulate the
game state. This represents a single concrete change to either
internal game state, or to the feature graph. Multiple changes
(possibly with random factors involved) can be represented by a
Consequence
; a FeatureEffect
is used as a leaf in a Consequence
tree.
6367def featureEffect( 6368 #applyTo: ContextSpecifier = 'active', 6369 #gain: Optional[Union[ 6370 # Capability, 6371 # Tuple[Token, TokenCount], 6372 # Tuple[Literal['skill'], Skill, Level] 6373 #]] = None, 6374 #lose: Optional[Union[ 6375 # Capability, 6376 # Tuple[Token, TokenCount], 6377 # Tuple[Literal['skill'], Skill, Level] 6378 #]] = None, 6379 #set: Optional[Union[ 6380 # Tuple[Token, TokenCount], 6381 # Tuple[AnyMechanismSpecifier, MechanismState], 6382 # Tuple[Literal['skill'], Skill, Level] 6383 #]] = None, 6384 #toggle: Optional[Union[ 6385 # Tuple[AnyMechanismSpecifier, List[MechanismState]], 6386 # List[Capability] 6387 #]] = None, 6388 #deactivate: Optional[bool] = None, 6389 #edit: Optional[List[List[commands.Command]]] = None, 6390 #goto: Optional[Union[ 6391 # AnyDecisionSpecifier, 6392 # Tuple[AnyDecisionSpecifier, FocalPointName] 6393 #]] = None, 6394 #bounce: Optional[bool] = None, 6395 #delay: Optional[int] = None, 6396 #charges: Optional[int] = None, 6397 **kwargs 6398): 6399 # TODO: HERE 6400 return effect(**kwargs)
6405class FeatureAction(TypedDict): 6406 """ 6407 Indicates an action decided on by a `FeatureDecision`. Has the 6408 following slots: 6409 6410 - 'subject': the main feature (an `AnyFeatureSpecifier`) that 6411 performs the action (usually an 'entity'). 6412 - 'object': the main feature (an `AnyFeatureSpecifier`) with which 6413 the affordance is performed. 6414 - 'affordance': the specific `FeatureAffordance` indicating the type 6415 of action. 6416 - 'direction': The general direction of movement (especially when 6417 the affordance is `follow`). This can be either a direction in 6418 an associated `MetricSpace`, or it can be defined towards or 6419 away from the destination specified. If a destination but no 6420 direction is provided, the direction is assumed to be towards 6421 that destination. 6422 - 'part': The part within/along a feature for movement (e.g., which 6423 side of an edge are you on, or which part of a region are you 6424 traveling through). 6425 - 'destination': The destination of the action (when known ahead of 6426 time). For example, moving along a path towards a particular 6427 feature touching that path, or entering a node into a particular 6428 feature within that node. Note that entering of regions can be 6429 left implicit: if you enter a region to get to a landmark within 6430 it, noting that as approaching the landmark is more appropriate 6431 than noting that as entering the region with the landmark as the 6432 destination. The system can infer what regions you're in by 6433 which feature you're at. 6434 - 'outcome': A `Consequence` list/tree indicating one or more 6435 outcomes, possibly involving challenges. Note that the actual 6436 outcomes of an action may be altered by triggers; the outcomes 6437 listed here are the default outcomes if no triggers are tripped. 6438 6439 The 'direction', 'part', and/or 'destination' may each be None, 6440 depending on the type of affordance and/or amount of detail desired. 6441 """ 6442 subject: AnyFeatureSpecifier 6443 object: AnyFeatureSpecifier 6444 affordance: FeatureAffordance 6445 direction: Optional[Part] 6446 part: Optional[Part] 6447 destination: Optional[AnyFeatureSpecifier] 6448 outcome: Consequence
Indicates an action decided on by a FeatureDecision
. Has the
following slots:
- 'subject': the main feature (an
AnyFeatureSpecifier
) that performs the action (usually an 'entity'). - 'object': the main feature (an
AnyFeatureSpecifier
) with which the affordance is performed. - 'affordance': the specific
FeatureAffordance
indicating the type of action. - 'direction': The general direction of movement (especially when
the affordance is
follow
). This can be either a direction in an associatedMetricSpace
, 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.