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
DefaultZone
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- `DefaultZone` 66- `ExplorationActionType` 67- `ExplorationAction` 68- `DecisionType` 69- `Situation` 70- `PointID` 71- `Coords` 72- `AnyPoint` 73- `Feature` 74- `FeatureID` 75- `Part` 76- `FeatureSpecifier` 77- `AnyFeatureSpecifier` 78- `MetricSpace` 79- `FeatureType` 80- `FeatureRelationshipType` 81- `FeatureDecision` 82- `FeatureAffordance` 83- `FeatureEffect` 84- `FeatureAction` 85""" 86 87from typing import ( 88 Any, Optional, List, Set, Union, Iterable, Tuple, Dict, TypedDict, 89 Literal, TypeAlias, TYPE_CHECKING, cast, Callable, get_args, 90 Sequence, NamedTuple, Generator 91) 92 93import copy 94import random 95import re 96import warnings 97import math 98 99from . import commands 100 101# `DecisionGraph` is defined in core.py, but `RequirementContext` needs to 102# be defined here for a couple of reasons. Thankfully, the details of 103# `DecisionGraph` don't matter here for type-checking purposes, so we 104# can provide this fake definition for the type checker: 105if TYPE_CHECKING: 106 from .core import DecisionGraph 107 108 109#---------# 110# Globals # 111#---------# 112 113DEFAULT_DOMAIN: 'Domain' = 'main' 114""" 115Default domain value for use when a domain is needed but not specified. 116""" 117 118DEFAULT_FOCAL_CONTEXT_NAME: 'FocalContextName' = 'main' 119""" 120Default focal context name for use when a focal context name is needed 121but not specified. 122""" 123 124DEFAULT_MECHANISM_STATE: 'MechanismState' = 'off' 125""" 126Default state we assume in situations where a mechanism hasn't been 127assigned a state. 128""" 129 130DEFAULT_EXPLORATION_STATUS: 'ExplorationStatus' = 'noticed' 131""" 132Default exploration status we assume when no exploration status has been 133set. 134""" 135 136DEFAULT_SAVE_SLOT: 'SaveSlot' = "slot0" 137""" 138Default save slot to use when saving or reverting and the slot isn't 139specified. 140""" 141 142 143#------------# 144# Base Types # 145#------------# 146 147Domain: TypeAlias = str 148""" 149A type alias: Domains are identified by their names. 150 151A domain represents a separable sphere of action in a game, such as 152movement in the in-game virtual space vs. movement in a menu (which is 153really just another kind of virtual space). Progress along a quest or tech 154tree can also be modeled as a separate domain. 155 156Conceptually, domains may be either currently-active or 157currently-inactive (e.g., when a menu is open vs. closed, or when 158real-world movement is paused (or not) during menuing; see `State`). Also, 159the game state stores a set of 'active' decision points for each domain. 160At each particular game step, the set of options available to the player 161is the union of all outgoing transitions from active nodes in each active 162domain. 163 164Each decision belongs to a single domain. 165""" 166 167Zone: 'TypeAlias' = str 168""" 169A type alias: A zone as part of a `DecisionGraph` is identified using 170its name. 171 172Zones contain decisions and/or other zones; one zone may be contained by 173multiple other zones, but a zone may not contain itself or otherwise 174form a containment loop. 175 176Note that zone names must be globally unique within a `DecisionGraph`, 177and by extension, two zones with the same name at different steps of a 178`DiscreteExploration` are assumed to represent the same space. 179""" 180 181DecisionID: 'TypeAlias' = int 182""" 183A type alias: decision points are defined by arbitrarily assigned 184unique-per-`Exploration` ID numbers. 185 186A decision represents a location within a decision graph where a decision 187can be made about where to go, or a dead-end reached by a previous 188decision. Typically, one room can have multiple decision points in it, 189even though many rooms have only one. Concepts like 'room' and 'area' 190that group multiple decisions together (at various scales) are handled 191by the idea of a `Zone`. 192""" 193 194DecisionName: 'TypeAlias' = str 195""" 196A type alias: decisions have names which are strings. 197 198Two decisions might share the same name, but they can be disambiguated 199because they may be in different `Zone`s, and ultimately, they will have 200different `DecisionID`s. 201""" 202 203 204class DecisionSpecifier(NamedTuple): 205 """ 206 A decision specifier attempts to uniquely identify a decision by 207 name, rather than by ID. See `AnyDecisionSpecifier` for a type which 208 can also be an ID. 209 210 Ambiguity is possible if two decisions share the same name; the 211 decision specifier provides two means of disambiguation: a domain 212 may be specified, and a zone may be specified; if either is 213 specified only decisions within that domain and/or zone will match, 214 but of course there could still be multiple decisions that match 215 those criteria that still share names, in which case many operations 216 will end up raising an `AmbiguousDecisionSpecifierError`. 217 """ 218 domain: Optional[Domain] 219 zone: Optional[Zone] 220 name: DecisionName 221 222 223AnyDecisionSpecifier: 'TypeAlias' = Union[DecisionID, DecisionSpecifier, str] 224""" 225A type alias: Collects three different ways of specifying a decision: by 226ID, by `DecisionSpecifier`, or by a string which will be treated as 227either a `DecisionName`, or as a `DecisionID` if it can be converted to 228an integer. 229""" 230 231 232class InvalidDecisionSpecifierError(ValueError): 233 """ 234 An error used when a decision specifier is in the wrong format. 235 """ 236 237 238class InvalidMechanismSpecifierError(ValueError): 239 """ 240 An error used when a mechanism specifier is invalid. 241 """ 242 243 244Transition: 'TypeAlias' = str 245""" 246A type alias: transitions are defined by their names. 247 248A transition represents a means of travel from one decision to another. 249Outgoing transition names have to be unique at each decision, but not 250globally. 251""" 252 253 254TransitionWithOutcomes: 'TypeAlias' = Tuple[Transition, List[bool]] 255""" 256A type alias: a transition with an outcome attached is a tuple that has 257a `Transition` and then a sequence of booleans indicating 258success/failure of successive challenges attached to that transition. 259Challenges encountered during application of transition effects will each 260have their outcomes dictated by successive booleans in the sequence. If 261the sequence is shorter than the number of challenges encountered, 262additional challenges are resolved according to a `ChallengePolicy` 263specified when applying effects. 264TODO: Implement this, including parsing. 265""" 266 267 268AnyTransition: 'TypeAlias' = Union[Transition, TransitionWithOutcomes] 269""" 270Either a `Transition` or a `TransitionWithOutcomes`. 271""" 272 273 274def nameAndOutcomes(transition: AnyTransition) -> TransitionWithOutcomes: 275 """ 276 Returns a `TransitionWithOutcomes` when given either one of those 277 already or a base `Transition`. Outcomes will be an empty list when 278 given a transition alone. Checks that the type actually matches. 279 """ 280 if isinstance(transition, Transition): 281 return (transition, []) 282 else: 283 if not isinstance(transition, tuple) or len(transition) != 2: 284 raise TypeError( 285 f"Transition with outcomes must be a length-2 tuple." 286 f" Got: {transition!r}" 287 ) 288 name, outcomes = transition 289 if not isinstance(name, Transition): 290 raise TypeError( 291 f"Transition name must be a string." 292 f" Got: {name!r}" 293 ) 294 if ( 295 not isinstance(outcomes, list) 296 or not all(isinstance(x, bool) for x in outcomes) 297 ): 298 raise TypeError( 299 f"Transition outcomes must be a list of booleans." 300 f" Got: {outcomes!r}" 301 ) 302 return transition 303 304 305Capability: 'TypeAlias' = str 306""" 307A type alias: capabilities are defined by their names. 308 309A capability represents the power to traverse certain transitions. These 310transitions should have a `Requirement` specified to indicate which 311capability/ies and/or token(s) can be used to traverse them. Capabilities 312are usually permanent, but may in some cases be temporary or be 313temporarily disabled. Capabilities might also combine (e.g., speed booster 314can't be used underwater until gravity suit is acquired but this is 315modeled through either `Requirement` expressions or equivalences (see 316`DecisionGraph.addEquivalence`). 317 318By convention, a capability whose name starts with '?' indicates a 319capability that the player considers unknown, to be filled in later via 320equivalence. Similarly, by convention capabilities local to a particular 321zone and/or decision will be prefixed with the name of that zone/decision 322and '::' (subject to the restriction that capability names may NOT contain 323the symbols '&', '|', '!', '*', '(', and ')'). Note that in most cases 324zone-local capabilities can instead be `Mechanism`s, which are zone-local 325by default. 326""" 327 328Token: 'TypeAlias' = str 329""" 330A type alias: tokens are defined by their type names. 331 332A token represents an expendable item that can be used to traverse certain 333transitions a limited number of times (normally once after which the 334token is used up), or to permanently open certain transitions (perhaps 335when a certain amount have been acquired). 336 337When a key permanently opens only one specific door, or is re-usable to 338open many doors, that should be represented as a `Capability`, not a 339token. Only when there is a choice of which door to unlock (and the key is 340then used up) should keys be represented as tokens. 341 342Like capabilities, tokens can be unknown (names starting with '?') or may 343be zone- or decision-specific (prefixed with a zone/decision name and 344'::'). Also like capabilities, token names may not contain any of the 345symbols '&', '|', '!', '*', '(', or ')'. 346""" 347 348TokenCount: 'TypeAlias' = int 349""" 350A token count is just an integer. 351""" 352 353Skill: 'TypeAlias' = str 354""" 355Names a skill to be used for a challenge. The agent's skill level along 356with the challenge level determines the probability of success (see 357`Challenge`). When an agent doesn't list a skill at all, the level is 358assumed to be 0. 359""" 360 361 362Level: 'TypeAlias' = int 363""" 364A challenge or skill level is just an integer. 365""" 366 367MechanismID: 'TypeAlias' = int 368""" 369A type alias: mechanism IDs are integers. See `MechanismName` and 370`MechanismState`. 371""" 372 373MechanismName: 'TypeAlias' = str 374""" 375A type alias: mechanism names are strings. See also `MechanismState`. 376 377A mechanism represents something in the world that can be toggled or can 378otherwise change state, and which may alter the requirements for 379transitions and/or actions. For example, a switch that opens and closes 380one or more gates. Mechanisms can be used in `Requirement`s by writing 381"mechanism:state", for example, "switch:on". Each mechanism can only be 382in one of its possible states at a time, so an effect that puts a 383mechanism in one state removes it from all other states. Mechanism states 384can be the subject of equivalences (see `DecisionGraph.addEquivalence`). 385 386Mechanisms have `MechanismID`s and are each associated with a specific 387decision (unless they are global), and when a mechanism name is 388mentioned, we look for the first mechanism with that name at the current 389decision, then in the lowest zone(s) containing that decision, etc. It's 390an error if we find two mechanisms with the same name at the same level 391of search. `DecisionGraph.addMechanism` will create a named mechanism 392and assign it an ID. 393 394By convention, a capability whose name starts with '?' indicates a 395mechanism that the player considers unknown, to be filled in later via 396equivalence. Mechanism names are resolved by searching incrementally 397through higher and higher-level zones, then a global mechanism set and 398finally in all decisions. This means that the same mechanism name can 399potentially be re-used in different zones, especially when all 400transitions which depend on that mechanism's state are within the same 401zone. 402TODO: G: for global scope? 403 404Mechanism states are not tracked as part of `FocalContext`s but are 405instead tracked as part of the `DecisionGraph` itself. If there are 406mechanism-like things which operate on a per-character basis or otherwise 407need to be tracked as part of focal contexts, use decision-local 408`Capability` names to track them instead. 409""" 410 411 412class MechanismSpecifier(NamedTuple): 413 """ 414 Specifies a mechanism either just by name, or with domain and/or 415 zone and/or decision name hints. 416 """ 417 domain: Optional[Domain] 418 zone: Optional[Zone] 419 decision: Optional[DecisionName] 420 name: MechanismName 421 422 423def mechanismAt( 424 name: MechanismName, 425 domain: Optional[Domain] = None, 426 zone: Optional[Zone] = None, 427 decision: Optional[DecisionName] = None 428) -> MechanismSpecifier: 429 """ 430 Builds a `MechanismSpecifier` using `None` default hints but 431 accepting `domain`, `zone`, and/or `decision` hints. 432 """ 433 return MechanismSpecifier(domain, zone, decision, name) 434 435 436AnyMechanismSpecifier: 'TypeAlias' = Union[ 437 MechanismID, 438 MechanismName, 439 MechanismSpecifier 440] 441""" 442Can be a mechanism ID, mechanism name, or a mechanism specifier. 443""" 444 445MechanismState: 'TypeAlias' = str 446""" 447A type alias: the state of a mechanism is a string. See `Mechanism`. 448 449Each mechanism may have any number of states, but may only be in one of 450them at once. Mechanism states may NOT be strings which can be 451converted to integers using `int` because otherwise the 'set' effect 452would have trouble figuring out whether a mechanism or item count was 453being set. 454""" 455 456EffectSpecifier: 'TypeAlias' = Tuple[DecisionID, Transition, int] 457""" 458Identifies a particular effect that's part of a consequence attached to 459a certain transition in a `DecisionGraph`. Identifies the effect based 460on the transition's source `DecisionID` and `Transition` name, plus an 461integer. The integer specifies the index of the effect in depth-first 462traversal order of the consequence of the specified transition. 463 464TODO: Ensure these are updated when merging/deleting/renaming stuff. 465""" 466 467 468class CapabilitySet(TypedDict): 469 """ 470 Represents a set of capabilities, including boolean on/off 471 `Capability` names, countable `Token`s accumulated, and 472 integer-leveled skills. It has three slots: 473 474 - 'capabilities': A set representing which `Capability`s this 475 `CapabilitySet` includes. 476 - 'tokens': A dictionary mapping `Token` types to integers 477 representing how many of that token type this `CapabilitySet` has 478 accumulated. 479 - 'skills': A dictionary mapping `Skill` types to `Level` integers, 480 representing what skill levels this `CapabilitySet` has. 481 """ 482 capabilities: Set[Capability] 483 tokens: Dict[Token, TokenCount] 484 skills: Dict[Skill, Level] 485 486 487DomainFocalization: 'TypeAlias' = Literal[ 488 'singular', 489 'plural', 490 'spreading' 491] 492""" 493How the player experiences decisions in a domain is controlled by 494focalization, which is specific to a `FocalContext` and a `Domain`: 495 496- Typically, focalization is 'singular' and there's a particular avatar 497 (or co-located group of avatars) that the player follows around, at 498 each point making a decision based on the position of that avatar 499 (that avatar is effectively "at" one decision in the graph). Position 500 in a singular domain is represented as a single `DecisionID`. When the 501 player picks a transition, this decision ID is updated to the decision 502 on the other side of that transition. 503- Less commonly, there can be multiple points of focalization which the 504 player can freely switch between, meaning the player can at any given 505 moment decide both which focal point to actually attend to, and what 506 transition to take at that decision. This is called 'plural' 507 focalization, and is common in tactics or strategy games where the 508 player commands multiple units, although those games are often a poor 509 match for decision mapping approaches. Position in a plural domain is 510 represented by a dictionary mapping one or more focal-point name 511 strings to single `DecisionID`s. When the player makes a decision, 512 they need to specify the name of the focal point for which the 513 decision is made along with the transition name at that focal point, 514 and that focal point is updated to the decision on the other side of 515 the chosen transition. 516- Focalization can also be 'spreading' meaning that not only can the 517 player pick options from one of multiple decisions, they also 518 effectively expand the set of available decisions without having to 519 give up access to old ones. This happens for example in a tech tree, 520 where the player can invest some resource to unlock new nodes. 521 Position in a spreading domain is represented by a set of 522 `DecisionID`s, and when a transition is chosen, the decision on the 523 other side is added to the set if it wasn't already present. 524""" 525 526 527FocalPointName: 'TypeAlias' = str 528""" 529The name of a particular focal point in 'plural' `DomainFocalization`. 530""" 531 532 533ContextSpecifier: 'TypeAlias' = Literal["common", "active"] 534""" 535Used when it's necessary to specify whether the common or the active 536`FocalContext` is being referenced and/or updated. 537""" 538 539 540FocalPointSpecifier: 'TypeAlias' = Tuple[ 541 ContextSpecifier, 542 Domain, 543 FocalPointName 544] 545""" 546Specifies a particular focal point by picking out whether it's in the 547common or active context, which domain it's in, and the focal point name 548within that domain. Only needed for domains with 'plural' focalization 549(see `DomainFocalization`). 550""" 551 552 553class FocalContext(TypedDict): 554 """ 555 Focal contexts identify an avatar or similar situation where the player 556 has certain capabilities available (a `CapabilitySet`) and may also have 557 position information in one or more `Domain`s (see `State` and 558 `DomainFocalization`). Normally, only a single `FocalContext` is needed, 559 but situations where the player swaps between capability sets and/or 560 positions sometimes call for more. 561 562 At each decision step, only a single `FocalContext` is active, and the 563 capabilities of that context (plus capabilities of the 'common' 564 context) determine what transitions are traversable. At the same time, 565 the set of reachable transitions is determined by the focal context's 566 per-domain position information, including its per-domain 567 `DomainFocalization` type. 568 569 The slots are: 570 571 - 'capabilities': A `CapabilitySet` representing what capabilities, 572 tokens, and skills this context has. Note that capabilities from 573 the common `FocalContext` are added to these to determine what 574 transition requirements are met in a given step. 575 - 'focalization': A mapping from `Domain`s to `DomainFocalization` 576 specifying how this context is focalized in each domain. 577 - 'activeDomains': A set of `Domain`s indicating which `Domain`(s) are 578 active for this focal context right now. 579 - 'activeDecisions': A mapping from `Domain`s to either single 580 `DecisionID`s, dictionaries mapping `FocalPointName`s to 581 optional `DecisionID`s, or sets of `DecisionID`s. Which one is 582 used depends on the `DomainFocalization` of this context for 583 that domain. May also be `None` for domains in which no 584 decisions are active (and in 'plural'-focalization lists, 585 individual entries may be `None`). Active decisions from the 586 common `FocalContext` are also considered active at each step. 587 """ 588 capabilities: CapabilitySet 589 focalization: Dict[Domain, DomainFocalization] 590 activeDomains: Set[Domain] 591 activeDecisions: Dict[ 592 Domain, 593 Union[ 594 None, 595 DecisionID, 596 Dict[FocalPointName, Optional[DecisionID]], 597 Set[DecisionID] 598 ] 599 ] 600 601 602FocalContextName: 'TypeAlias' = str 603""" 604`FocalContext`s are assigned names are are indexed under those names 605within `State` objects (they don't contain their own name). Note that 606the 'common' focal context does not have a name. 607""" 608 609 610def getDomainFocalization( 611 context: FocalContext, 612 domain: Domain, 613 defaultFocalization: DomainFocalization = 'singular' 614) -> DomainFocalization: 615 """ 616 Fetches the focalization value for the given domain in the given 617 focal context, setting it to the provided default first if that 618 focal context didn't have an entry for that domain yet. 619 """ 620 return context['focalization'].setdefault(domain, defaultFocalization) 621 622 623class State(TypedDict): 624 """ 625 Represents a game state, including certain exploration-relevant 626 information, plus possibly extra custom information. Has the 627 following slots: 628 629 - 'common': A single `FocalContext` containing capability and position 630 information which is always active in addition to the current 631 `FocalContext`'s information. 632 - 'contexts': A dictionary mapping strings to `FocalContext`s, which 633 store capability and position information. 634 - 'activeContext': A string naming the currently-active 635 `FocalContext` (a key of the 'contexts' slot). 636 - 'primaryDecision': A `DecisionID` (or `None`) indicating the 637 primary decision that is being considered in this state. Whereas 638 the focalization structures can and often will indicate multiple 639 active decisions, whichever decision the player just arrived at 640 via the transition selected in a previous state will be the most 641 relevant, and we track that here. Of course, for some states 642 (like a pre-starting initial state) there is no primary 643 decision. 644 - 'mechanisms': A dictionary mapping `Mechanism` IDs to 645 `MechanismState` strings. 646 - 'exploration': A dictionary mapping decision IDs to exploration 647 statuses, which tracks how much knowledge the player has of 648 different decisions. 649 - 'effectCounts': A dictionary mapping `EffectSpecifier`s to 650 integers specifying how many times that effect has been 651 triggered since the beginning of the exploration (including 652 times that the actual effect was not applied due to delays 653 and/or charges. This is used to figure out when effects with 654 charges and/or delays should be applied. 655 - 'deactivated': A set of (`DecisionID`, `Transition`) tuples 656 specifying which transitions have been deactivated. This is used 657 in addition to transition requirements to figure out which 658 transitions are traversable. 659 - 'custom': An arbitrary sub-dictionary representing any kind of 660 custom game state. In most cases, things can be reasonably 661 approximated via capabilities and tokens and custom game state is 662 not needed. 663 """ 664 common: FocalContext 665 contexts: Dict[FocalContextName, FocalContext] 666 activeContext: FocalContextName 667 primaryDecision: Optional[DecisionID] 668 mechanisms: Dict[MechanismID, MechanismState] 669 exploration: Dict[DecisionID, 'ExplorationStatus'] 670 effectCounts: Dict[EffectSpecifier, int] 671 deactivated: Set[Tuple[DecisionID, Transition]] 672 custom: dict 673 674 675#-------------------# 676# Utility Functions # 677#-------------------# 678 679def idOrDecisionSpecifier( 680 ds: DecisionSpecifier 681) -> Union[DecisionSpecifier, int]: 682 """ 683 Given a decision specifier which might use a name that's convertible 684 to an integer ID, returns the appropriate ID if so, and the original 685 decision specifier if not, raising an 686 `InvalidDecisionSpecifierError` if given a specifier with a 687 convertible name that also has other parts. 688 """ 689 try: 690 dID = int(ds.name) 691 except ValueError: 692 return ds 693 694 if ds.domain is None and ds.zone is None: 695 return dID 696 else: 697 raise InvalidDecisionSpecifierError( 698 f"Specifier {ds} has an ID name but also includes" 699 f" domain and/or zone information." 700 ) 701 702 703def spliceDecisionSpecifiers( 704 base: DecisionSpecifier, 705 default: DecisionSpecifier 706) -> DecisionSpecifier: 707 """ 708 Copies domain and/or zone info from the `default` specifier into the 709 `base` specifier, returning a new `DecisionSpecifier` without 710 modifying either argument. Info is only copied where the `base` 711 specifier has a missing value, although if the base specifier has a 712 domain but no zone and the domain is different from that of the 713 default specifier, no zone info is copied. 714 715 For example: 716 717 >>> d1 = DecisionSpecifier('main', 'zone', 'name') 718 >>> d2 = DecisionSpecifier('niam', 'enoz', 'eman') 719 >>> spliceDecisionSpecifiers(d1, d2) 720 DecisionSpecifier(domain='main', zone='zone', name='name') 721 >>> spliceDecisionSpecifiers(d2, d1) 722 DecisionSpecifier(domain='niam', zone='enoz', name='eman') 723 >>> d3 = DecisionSpecifier(None, None, 'three') 724 >>> spliceDecisionSpecifiers(d3, d1) 725 DecisionSpecifier(domain='main', zone='zone', name='three') 726 >>> spliceDecisionSpecifiers(d3, d2) 727 DecisionSpecifier(domain='niam', zone='enoz', name='three') 728 >>> d4 = DecisionSpecifier('niam', None, 'four') 729 >>> spliceDecisionSpecifiers(d4, d1) # diff domain -> no zone 730 DecisionSpecifier(domain='niam', zone=None, name='four') 731 >>> spliceDecisionSpecifiers(d4, d2) # same domian -> copy zone 732 DecisionSpecifier(domain='niam', zone='enoz', name='four') 733 >>> d5 = DecisionSpecifier(None, 'cone', 'five') 734 >>> spliceDecisionSpecifiers(d4, d5) # None domain -> copy zone 735 DecisionSpecifier(domain='niam', zone='cone', name='four') 736 """ 737 newDomain = base.domain 738 if newDomain is None: 739 newDomain = default.domain 740 newZone = base.zone 741 if ( 742 newZone is None 743 and (newDomain == default.domain or default.domain is None) 744 ): 745 newZone = default.zone 746 747 return DecisionSpecifier(domain=newDomain, zone=newZone, name=base.name) 748 749 750def mergeCapabilitySets(A: CapabilitySet, B: CapabilitySet) -> CapabilitySet: 751 """ 752 Merges two capability sets into a new one, where all capabilities in 753 either original set are active, and token counts and skill levels are 754 summed. 755 756 Example: 757 758 >>> cs1 = { 759 ... 'capabilities': {'fly', 'whistle'}, 760 ... 'tokens': {'subway': 3}, 761 ... 'skills': {'agility': 1, 'puzzling': 3}, 762 ... } 763 >>> cs2 = { 764 ... 'capabilities': {'dig', 'fly'}, 765 ... 'tokens': {'subway': 1, 'goat': 2}, 766 ... 'skills': {'agility': -1}, 767 ... } 768 >>> ms = mergeCapabilitySets(cs1, cs2) 769 >>> ms['capabilities'] == {'fly', 'whistle', 'dig'} 770 True 771 >>> ms['tokens'] == {'subway': 4, 'goat': 2} 772 True 773 >>> ms['skills'] == {'agility': 0, 'puzzling': 3} 774 True 775 """ 776 # Set up our result 777 result: CapabilitySet = { 778 'capabilities': set(), 779 'tokens': {}, 780 'skills': {} 781 } 782 783 # Merge capabilities 784 result['capabilities'].update(A['capabilities']) 785 result['capabilities'].update(B['capabilities']) 786 787 # Merge tokens 788 tokensA = A['tokens'] 789 tokensB = B['tokens'] 790 resultTokens = result['tokens'] 791 for tType, val in tokensA.items(): 792 if tType not in resultTokens: 793 resultTokens[tType] = val 794 else: 795 resultTokens[tType] += val 796 for tType, val in tokensB.items(): 797 if tType not in resultTokens: 798 resultTokens[tType] = val 799 else: 800 resultTokens[tType] += val 801 802 # Merge skills 803 skillsA = A['skills'] 804 skillsB = B['skills'] 805 resultSkills = result['skills'] 806 for skill, level in skillsA.items(): 807 if skill not in resultSkills: 808 resultSkills[skill] = level 809 else: 810 resultSkills[skill] += level 811 for skill, level in skillsB.items(): 812 if skill not in resultSkills: 813 resultSkills[skill] = level 814 else: 815 resultSkills[skill] += level 816 817 return result 818 819 820def emptyFocalContext() -> FocalContext: 821 """ 822 Returns a completely empty focal context, which has no capabilities 823 and which has no associated domains. 824 """ 825 return { 826 'capabilities': { 827 'capabilities': set(), 828 'tokens': {}, 829 'skills': {} 830 }, 831 'focalization': {}, 832 'activeDomains': set(), 833 'activeDecisions': {} 834 } 835 836 837def basicFocalContext( 838 domain: Optional[Domain] = None, 839 focalization: DomainFocalization = 'singular' 840): 841 """ 842 Returns a basic focal context, which has no capabilities and which 843 uses the given focalization (default 'singular') for a single 844 domain with the given name (default `DEFAULT_DOMAIN`) which is 845 active but which has no position specified. 846 """ 847 if domain is None: 848 domain = DEFAULT_DOMAIN 849 return { 850 'capabilities': { 851 'capabilities': set(), 852 'tokens': {}, 853 'skills': {} 854 }, 855 'focalization': {domain: focalization}, 856 'activeDomains': {domain}, 857 'activeDecisions': {domain: None} 858 } 859 860 861def emptyState() -> State: 862 """ 863 Returns an empty `State` dictionary. The empty dictionary uses 864 `DEFAULT_FOCAL_CONTEXT_NAME` as the name of the active 865 `FocalContext`. 866 """ 867 return { 868 'common': emptyFocalContext(), 869 'contexts': {DEFAULT_FOCAL_CONTEXT_NAME: basicFocalContext()}, 870 'activeContext': DEFAULT_FOCAL_CONTEXT_NAME, 871 'primaryDecision': None, 872 'mechanisms': {}, 873 'exploration': {}, 874 'effectCounts': {}, 875 'deactivated': set(), 876 'custom': {} 877 } 878 879 880def basicState( 881 context: Optional[FocalContextName] = None, 882 domain: Optional[Domain] = None, 883 focalization: DomainFocalization = 'singular' 884) -> State: 885 """ 886 Returns a `State` dictionary with a newly created single active 887 focal context that uses the given name (default 888 `DEFAULT_FOCAL_CONTEXT_NAME`). This context is created using 889 `basicFocalContext` with the given domain and focalization as 890 arguments (defaults `DEFAULT_DOMAIN` and 'singular'). 891 """ 892 if context is None: 893 context = DEFAULT_FOCAL_CONTEXT_NAME 894 return { 895 'common': emptyFocalContext(), 896 'contexts': {context: basicFocalContext(domain, focalization)}, 897 'activeContext': context, 898 'primaryDecision': None, 899 'mechanisms': {}, 900 'exploration': {}, 901 'effectCounts': {}, 902 'deactivated': set(), 903 'custom': {} 904 } 905 906 907def effectiveCapabilitySet(state: State) -> CapabilitySet: 908 """ 909 Given a `baseTypes.State` object, computes the effective capability 910 set for that state, which merges capabilities and tokens from the 911 common `baseTypes.FocalContext` with those of the active one. 912 913 Returns a `CapabilitySet`. 914 """ 915 # Grab relevant contexts 916 commonContext = state['common'] 917 activeContext = state['contexts'][state['activeContext']] 918 919 # Extract capability dictionaries 920 commonCapabilities = commonContext['capabilities'] 921 activeCapabilities = activeContext['capabilities'] 922 923 return mergeCapabilitySets( 924 commonCapabilities, 925 activeCapabilities 926 ) 927 928 929def combinedDecisionSet(state: State) -> Set[DecisionID]: 930 """ 931 Given a `State` object, computes the active decision set for that 932 state, which is the set of decisions at which the player can make an 933 immediate decision. This depends on the 'common' `FocalContext` as 934 well as the active focal context, and of course each `FocalContext` 935 may specify separate active decisions for different domains, separate 936 sets of active domains, etc. See `FocalContext` and 937 `DomainFocalization` for more details, as well as `activeDecisionSet`. 938 939 Returns a set of `DecisionID`s. 940 """ 941 commonContext = state['common'] 942 activeContext = state['contexts'][state['activeContext']] 943 result = set() 944 for ctx in (commonContext, activeContext): 945 result |= activeDecisionSet(ctx) 946 947 return result 948 949 950def activeDecisionSet(context: FocalContext) -> Set[DecisionID]: 951 """ 952 Given a `FocalContext`, returns the set of all `DecisionID`s which 953 are active in that focal context. This includes only decisions which 954 are in active domains. 955 956 For example: 957 958 >>> fc = emptyFocalContext() 959 >>> activeDecisionSet(fc) 960 set() 961 >>> fc['focalization'] = { 962 ... 'Si': 'singular', 963 ... 'Pl': 'plural', 964 ... 'Sp': 'spreading' 965 ... } 966 >>> fc['activeDomains'] = {'Si'} 967 >>> fc['activeDecisions'] = { 968 ... 'Si': 0, 969 ... 'Pl': {'one': 1, 'two': 2}, 970 ... 'Sp': {3, 4} 971 ... } 972 >>> activeDecisionSet(fc) 973 {0} 974 >>> fc['activeDomains'] = {'Si', 'Pl'} 975 >>> sorted(activeDecisionSet(fc)) 976 [0, 1, 2] 977 >>> fc['activeDomains'] = {'Pl'} 978 >>> sorted(activeDecisionSet(fc)) 979 [1, 2] 980 >>> fc['activeDomains'] = {'Sp'} 981 >>> sorted(activeDecisionSet(fc)) 982 [3, 4] 983 >>> fc['activeDomains'] = {'Si', 'Pl', 'Sp'} 984 >>> sorted(activeDecisionSet(fc)) 985 [0, 1, 2, 3, 4] 986 """ 987 result = set() 988 decisionsMap = context['activeDecisions'] 989 for domain in context['activeDomains']: 990 activeGroup = decisionsMap[domain] 991 if activeGroup is None: 992 pass 993 elif isinstance(activeGroup, DecisionID): 994 result.add(activeGroup) 995 elif isinstance(activeGroup, dict): 996 for x in activeGroup.values(): 997 if x is not None: 998 result.add(x) 999 elif isinstance(activeGroup, set): 1000 result.update(activeGroup) 1001 else: 1002 raise TypeError( 1003 f"The FocalContext {repr(context)} has an invalid" 1004 f" active group for domain {repr(domain)}." 1005 f"\nGroup is: {repr(activeGroup)}" 1006 ) 1007 1008 return result 1009 1010 1011ExplorationStatus: 'TypeAlias' = Literal[ 1012 'unknown', 1013 'hypothesized', 1014 'noticed', 1015 'exploring', 1016 'explored', 1017] 1018""" 1019Exploration statuses track what kind of knowledge the player has about a 1020decision. Note that this is independent of whether or not they've 1021visited it. They are one of the following strings: 1022 1023 - 'unknown': Indicates a decision that the player has absolutely no 1024 knowledge of, not even by implication. Normally such decisions 1025 are not part of a decision map, since the player can only write 1026 down what they've at least seen implied. But in cases where you 1027 want to track exploration of a pre-specified decision map, 1028 decisions that are pre-specified but which the player hasn't had 1029 any hint of yet would have this status. 1030 - 'hypothesized': Indicates a decision that the player can 1031 reasonably expect might be there, but which they haven't yet 1032 confirmed. This comes up when, for example, there's a flashback 1033 during which the player explores an older version of an area, 1034 which they then return to in the "present day." In this case, 1035 the player can hypothesize that the area layout will be the 1036 same, although in the end, it could in fact be different. The 1037 entire flashback area can be cloned and the cloned decisions 1038 marked as hypothesized to represent this. Note that this does 1039 NOT apply to decisions which are definitely implied, such as the 1040 decision on the other side of something the player recognizes as 1041 a door. Those kind of decisions should be marked as 'noticed'. 1042 - 'noticed': Indicates a decision that the player assumes will 1043 exist, and/or which the player has been able to observe some 1044 aspects of indirectly, such as in a cutscene. A decision on the 1045 other side of a closed door is in this category, since even 1046 though the player hasn't seen anything about it, they can pretty 1047 reliably assume there will be some decision there. 1048 - 'exploring': Indicates that a player has started to gain some 1049 knowledge of the transitions available at a decision (beyond the 1050 obvious reciprocals for connections to a 'noticed' decision, 1051 usually but not always by having now visited that decision. Even 1052 the most cursory visit should elevate a decision's exploration 1053 level to 'exploring', except perhaps if the visit is in a 1054 cutscene (although that can also count in some cases). 1055 - 'explored': Indicates that the player believes they have 1056 discovered all of the relevant transitions at this decision, and 1057 there is no need for them to explore it further. This notation 1058 should be based on the player's immediate belief, so even if 1059 it's known that the player will later discover another hidden 1060 option at this transition (or even if the options will later 1061 change), unless the player is cognizant of that, it should be 1062 marked as 'explored' as soon as the player believes they've 1063 exhausted observation of transitions. The player does not have 1064 to have explored all of those transitions yet, including 1065 actions, as long as they're satisfied for now that they've found 1066 all of the options available. 1067""" 1068 1069 1070def moreExplored( 1071 a: ExplorationStatus, 1072 b: ExplorationStatus 1073) -> ExplorationStatus: 1074 """ 1075 Returns whichever of the two exploration statuses counts as 'more 1076 explored'. 1077 """ 1078 eArgs = get_args(ExplorationStatus) 1079 try: 1080 aIndex = eArgs.index(a) 1081 except ValueError: 1082 raise ValueError( 1083 f"Status {a!r} is not a valid exploration status. Must be" 1084 f" one of: {eArgs!r}" 1085 ) 1086 try: 1087 bIndex = eArgs.index(b) 1088 except ValueError: 1089 raise ValueError( 1090 f"Status {b!r} is not a valid exploration status. Must be" 1091 f" one of: {eArgs!r}" 1092 ) 1093 if aIndex > bIndex: 1094 return a 1095 else: 1096 return b 1097 1098 1099def statusVisited(status: ExplorationStatus) -> bool: 1100 """ 1101 Returns true or false depending on whether the provided status 1102 indicates a decision has been visited or not. The 'exploring' and 1103 'explored' statuses imply a decision has been visisted, but other 1104 statuses do not. 1105 """ 1106 return status in ('exploring', 'explored') 1107 1108 1109RestoreFCPart: 'TypeAlias' = Literal[ 1110 "capabilities", 1111 "tokens", 1112 "skills", 1113 "positions" 1114] 1115""" 1116Parts of a `FocalContext` that can be restored. Used in `revertedState`. 1117""" 1118 1119RestoreCapabilityPart = Literal["capabilities", "tokens", "skills"] 1120""" 1121Parts of a focal context `CapabilitySet` that can be restored. Used in 1122`revertedState`. 1123""" 1124 1125RestoreFCKey = Literal["focalization", "activeDomains", "activeDecisions"] 1126""" 1127Parts of a `FocalContext` besides the capability set that we can restore. 1128""" 1129 1130RestoreStatePart = Literal["mechanisms", "exploration", "custom"] 1131""" 1132Parts of a State that we can restore besides the `FocalContext` stuff. 1133Doesn't include the stuff covered by the 'effects' restore aspect. See 1134`revertedState` for more. 1135""" 1136 1137 1138def revertedState( 1139 currentStuff: Tuple['DecisionGraph', State], 1140 savedStuff: Tuple['DecisionGraph', State], 1141 revisionAspects: Set[str] 1142) -> Tuple['DecisionGraph', State]: 1143 """ 1144 Given two (graph, state) pairs, as well as a set of reversion aspect 1145 strings, returns a (graph, state) pair representing the reverted 1146 graph and state. The provided graphs and states will not be 1147 modified, and the return value will not include references to them, 1148 so modifying the returned state will not modify the original or 1149 saved states or graphs. 1150 1151 If the `revisionAspects` set is empty, then all aspects except 1152 skills, exploration statuses, and the graph will be reverted. 1153 1154 Note that the reversion process can lead to impossible states if the 1155 wrong combination of reversion aspects is used (e.g., reverting the 1156 graph but not focal context position information might lead to 1157 positions that refer to decisions which do not exist). 1158 1159 Valid reversion aspect strings are: 1160 - "common-capabilities", "common-tokens", "common-skills," 1161 "common-positions" or just "common" for all four. These 1162 control the parts of the common context's `CapabilitySet` 1163 that get reverted, as well as whether the focalization, 1164 active domains, and active decisions get reverted (those 1165 three as "positions"). 1166 - "c-*NAME*-capabilities" as well as -tokens, -skills, 1167 -positions, and without a suffix, where *NAME* is the name of 1168 a specific focal context. 1169 - "all-capabilities" as well as -tokens, -skills, -positions, 1170 and -contexts, reverting the relevant part of all focal 1171 contexts except the common one, with "all-contexts" reverting 1172 every part of all non-common focal contexts. 1173 - "current-capabilities" as well as -tokens, -skills, -positions, 1174 and without a suffix, for the currently-active focal context. 1175 - "primary" which reverts the primary decision (some positions should 1176 also be reverted in this case). 1177 - "mechanisms" which reverts mechanism states. 1178 - "exploration" which reverts the exploration state of decisions 1179 (note that the `DecisionGraph` also stores "unconfirmed" tags 1180 which are NOT affected by a revert unless "graph" is specified). 1181 - "effects" which reverts the record of how many times transition 1182 effects have been triggered, plus whether transitions have 1183 been disabled or not. 1184 - "custom" which reverts custom state. 1185 - "graph" reverts the graph itself (but this is usually not 1186 desired). This will still preserve the next-ID value for 1187 assigning new nodes, so that nodes created in a reverted graph 1188 will not re-use IDs from nodes created before the reversion. 1189 - "-*NAME*" where *NAME* is a custom reversion specification 1190 defined using `core.DecisionGraph.reversionType` and available 1191 in the "current" decision graph (note the colon is required 1192 before the custom name). This allows complex reversion systems 1193 to be set up once and referenced repeatedly. Any strings 1194 specified along with a custom reversion type will revert the 1195 specified state in addition to what the custom reversion type 1196 specifies. 1197 1198 For example: 1199 1200 >>> from . import core 1201 >>> g = core.DecisionGraph.example("simple") # A - B - C triangle 1202 >>> g.setTransitionRequirement('B', 'next', ReqCapability('helmet')) 1203 >>> g.addAction( 1204 ... 'A', 1205 ... 'getHelmet', 1206 ... consequence=[effect(gain='helmet'), effect(deactivate=True)] 1207 ... ) 1208 >>> s0 = basicState() 1209 >>> fc0 = s0['contexts']['main'] 1210 >>> fc0['activeDecisions']['main'] = 0 # A 1211 >>> s1 = basicState() 1212 >>> fc1 = s1['contexts']['main'] 1213 >>> fc1['capabilities']['capabilities'].add('helmet') 1214 >>> fc1['activeDecisions']['main'] = 1 # B 1215 >>> s1['exploration'] = {0: 'explored', 1: 'exploring', 2: 'unknown'} 1216 >>> s1['effectCounts'] = {(0, 'getHelmet', 1): 1} 1217 >>> s1['deactivated'] = {(0, "getHelmet")} 1218 >>> # Basic reversion of everything except graph & exploration 1219 >>> rg, rs = revertedState((g, s1), (g, s0), set()) 1220 >>> rg == g 1221 True 1222 >>> rg is g 1223 False 1224 >>> rs == s0 1225 False 1226 >>> rs is s0 1227 False 1228 >>> rs['contexts'] == s0['contexts'] 1229 True 1230 >>> rs['exploration'] == s1['exploration'] 1231 True 1232 >>> rs['effectCounts'] = s0['effectCounts'] 1233 >>> rs['deactivated'] = s0['deactivated'] 1234 >>> # Reverting capabilities but not position, exploration, or effects 1235 >>> rg, rs = revertedState((g, s1), (g, s0), {"current-capabilities"}) 1236 >>> rg == g 1237 True 1238 >>> rs == s0 or rs == s1 1239 False 1240 >>> s1['contexts']['main']['capabilities']['capabilities'] 1241 {'helmet'} 1242 >>> s0['contexts']['main']['capabilities']['capabilities'] 1243 set() 1244 >>> rs['contexts']['main']['capabilities']['capabilities'] 1245 set() 1246 >>> s1['contexts']['main']['activeDecisions']['main'] 1247 1 1248 >>> s0['contexts']['main']['activeDecisions']['main'] 1249 0 1250 >>> rs['contexts']['main']['activeDecisions']['main'] 1251 1 1252 >>> # Restore position and effects; that's all that wasn't reverted 1253 >>> rs['contexts']['main']['activeDecisions']['main'] = 0 1254 >>> rs['exploration'] = {} 1255 >>> rs['effectCounts'] = {} 1256 >>> rs['deactivated'] = set() 1257 >>> rs == s0 1258 True 1259 >>> # Reverting position but not state 1260 >>> rg, rs = revertedState((g, s1), (g, s0), {"current-positions"}) 1261 >>> rg == g 1262 True 1263 >>> s1['contexts']['main']['capabilities']['capabilities'] 1264 {'helmet'} 1265 >>> s0['contexts']['main']['capabilities']['capabilities'] 1266 set() 1267 >>> rs['contexts']['main']['capabilities']['capabilities'] 1268 {'helmet'} 1269 >>> s1['contexts']['main']['activeDecisions']['main'] 1270 1 1271 >>> s0['contexts']['main']['activeDecisions']['main'] 1272 0 1273 >>> rs['contexts']['main']['activeDecisions']['main'] 1274 0 1275 >>> # Reverting based on specific focal context name 1276 >>> rg2, rs2 = revertedState((g, s1), (g, s0), {"c-main-positions"}) 1277 >>> rg2 == rg 1278 True 1279 >>> rs2 == rs 1280 True 1281 >>> # Test of graph reversion 1282 >>> import copy 1283 >>> g2 = copy.deepcopy(g) 1284 >>> g2.addDecision('D') 1285 3 1286 >>> g2.addTransition(2, 'alt', 'D', 'return') 1287 >>> rg, rs = revertedState((g2, s1), (g, s0), {'graph'}) 1288 >>> rg == g 1289 True 1290 >>> rg is g 1291 False 1292 1293 TODO: More tests for various other reversion aspects 1294 TODO: Implement per-token-type / per-capability / per-mechanism / 1295 per-skill reversion. 1296 """ 1297 # Expand custom references 1298 expandedAspects = set() 1299 queue = list(revisionAspects) 1300 if len(queue) == 0: 1301 queue = [ # don't revert skills, exploration, and graph 1302 "common-capabilities", 1303 "common-tokens", 1304 "common-positions", 1305 "all-capabilities", 1306 "all-tokens", 1307 "all-positions", 1308 "mechanisms", 1309 "primary", 1310 "effects", 1311 "custom" 1312 ] # we do not include "graph" or "exploration" here... 1313 customLookup = currentStuff[0].reversionTypes 1314 while len(queue) > 0: 1315 aspect = queue.pop(0) 1316 if aspect.startswith('-'): 1317 customName = aspect[1:] 1318 if customName not in customLookup: 1319 raise ValueError( 1320 f"Custom reversion type {aspect[1:]!r} is invalid" 1321 f" because that reversion type has not been" 1322 f" defined. Defined types are:" 1323 f"\n{list(customLookup.keys())}" 1324 ) 1325 queue.extend(customLookup[customName]) 1326 else: 1327 expandedAspects.add(aspect) 1328 1329 # Further expand focal-context-part collectives 1330 if "common" in expandedAspects: 1331 expandedAspects.add("common-capabilities") 1332 expandedAspects.add("common-tokens") 1333 expandedAspects.add("common-skills") 1334 expandedAspects.add("common-positions") 1335 1336 if "all-contexts" in expandedAspects: 1337 expandedAspects.add("all-capabilities") 1338 expandedAspects.add("all-tokens") 1339 expandedAspects.add("all-skills") 1340 expandedAspects.add("all-positions") 1341 1342 if "current" in expandedAspects: 1343 expandedAspects.add("current-capabilities") 1344 expandedAspects.add("current-tokens") 1345 expandedAspects.add("current-skills") 1346 expandedAspects.add("current-positions") 1347 1348 # Figure out things to revert that are specific to named focal 1349 # contexts 1350 perFC: Dict[FocalContextName, Set[RestoreFCPart]] = {} 1351 currentFCName = currentStuff[1]['activeContext'] 1352 for aspect in expandedAspects: 1353 # For current- stuff, look up current context name 1354 if aspect.startswith("current"): 1355 found = False 1356 part: RestoreFCPart 1357 for part in get_args(RestoreFCPart): 1358 if aspect == f"current-{part}": 1359 perFC.setdefault(currentFCName, set()).add(part) 1360 found = True 1361 if not found and aspect != "current": 1362 raise RuntimeError(f"Invalid reversion aspect: {aspect!r}") 1363 elif aspect.startswith("c-"): 1364 if aspect.endswith("-capabilities"): 1365 fcName = aspect[2:-13] 1366 perFC.setdefault(fcName, set()).add("capabilities") 1367 elif aspect.endswith("-tokens"): 1368 fcName = aspect[2:-7] 1369 perFC.setdefault(fcName, set()).add("tokens") 1370 elif aspect.endswith("-skills"): 1371 fcName = aspect[2:-7] 1372 perFC.setdefault(fcName, set()).add("skills") 1373 elif aspect.endswith("-positions"): 1374 fcName = aspect[2:-10] 1375 perFC.setdefault(fcName, set()).add("positions") 1376 else: 1377 fcName = aspect[2:] 1378 forThis = perFC.setdefault(fcName, set()) 1379 forThis.add("capabilities") 1380 forThis.add("tokens") 1381 forThis.add("skills") 1382 forThis.add("positions") 1383 1384 currentState = currentStuff[1] 1385 savedState = savedStuff[1] 1386 1387 # Expand all-FC reversions to per-FC entries for each FC in both 1388 # current and prior states 1389 allFCs = set(currentState['contexts']) | set(savedState['contexts']) 1390 for part in get_args(RestoreFCPart): 1391 if f"all-{part}" in expandedAspects: 1392 for fcName in allFCs: 1393 perFC.setdefault(fcName, set()).add(part) 1394 1395 # Revert graph or not 1396 if "graph" in expandedAspects: 1397 resultGraph = copy.deepcopy(savedStuff[0]) 1398 # Patch nextID to avoid spurious ID matches 1399 resultGraph.nextID = currentStuff[0].nextID 1400 else: 1401 resultGraph = copy.deepcopy(currentStuff[0]) 1402 1403 # Start from non-reverted state copy 1404 resultState = copy.deepcopy(currentState) 1405 1406 # Revert primary decision or not 1407 if "primary" in expandedAspects: 1408 resultState['primaryDecision'] = savedState['primaryDecision'] 1409 1410 # Revert specified aspects of the common focal context 1411 savedCommon = savedState['common'] 1412 capKey: RestoreCapabilityPart 1413 for capKey in get_args(RestoreCapabilityPart): 1414 if f"common-{part}" in expandedAspects: 1415 resultState['common']['capabilities'][capKey] = copy.deepcopy( 1416 savedCommon['capabilities'][capKey] 1417 ) 1418 if "common-positions" in expandedAspects: 1419 fcKey: RestoreFCKey 1420 for fcKey in get_args(RestoreFCKey): 1421 resultState['common'][fcKey] = copy.deepcopy(savedCommon[fcKey]) 1422 1423 # Update focal context parts for named focal contexts: 1424 savedContextMap = savedState['contexts'] 1425 for fcName, restore in perFC.items(): 1426 thisFC = resultState['contexts'].setdefault( 1427 fcName, 1428 emptyFocalContext() 1429 ) # Create FC by name if it didn't exist already 1430 thatFC = savedContextMap.get(fcName) 1431 if thatFC is None: # what if it's a new one? 1432 if restore == set(get_args(RestoreFCPart)): 1433 # If we're restoring everything and the context didn't 1434 # exist in the prior state, delete it in the restored 1435 # state 1436 del resultState['contexts'][fcName] 1437 else: 1438 # Otherwise update parts of it to be blank since prior 1439 # state didn't have any info 1440 for part in restore: 1441 if part == "positions": 1442 thisFC['focalization'] = {} 1443 thisFC['activeDomains'] = set() 1444 thisFC['activeDecisions'] = {} 1445 elif part == "capabilities": 1446 thisFC['capabilities'][part] = set() 1447 else: 1448 thisFC['capabilities'][part] = {} 1449 else: # same context existed in saved data; update parts 1450 for part in restore: 1451 if part == "positions": 1452 for fcKey in get_args(RestoreFCKey): # typed above 1453 thisFC[fcKey] = copy.deepcopy(thatFC[fcKey]) 1454 else: 1455 thisFC['capabilities'][part] = copy.deepcopy( 1456 thatFC['capabilities'][part] 1457 ) 1458 1459 # Revert mechanisms, exploration, and/or custom state if specified 1460 statePart: RestoreStatePart 1461 for statePart in get_args(RestoreStatePart): 1462 if statePart in expandedAspects: 1463 resultState[statePart] = copy.deepcopy(savedState[statePart]) 1464 1465 # Revert effect tracking if specified 1466 if "effects" in expandedAspects: 1467 resultState['effectCounts'] = copy.deepcopy( 1468 savedState['effectCounts'] 1469 ) 1470 resultState['deactivated'] = copy.deepcopy(savedState['deactivated']) 1471 1472 return (resultGraph, resultState) 1473 1474 1475#--------------# 1476# Consequences # 1477#--------------# 1478 1479class RequirementContext(NamedTuple): 1480 """ 1481 The context necessary to check whether a requirement is satisfied or 1482 not. Also used for computing effective skill levels for 1483 `SkillCombination`s. Includes a `State` that specifies `Capability` 1484 and `Token` states, a `DecisionGraph` (which includes equivalences), 1485 and a set of `DecisionID`s to use as the starting place for finding 1486 mechanisms by name. 1487 """ 1488 state: State 1489 graph: 'DecisionGraph' 1490 searchFrom: Set[DecisionID] 1491 1492 1493def getSkillLevel(state: State, skill: Skill) -> Level: 1494 """ 1495 Given a `State` and a `Skill`, looks up that skill in both the 1496 common and active `FocalContext`s of the state, and adds those 1497 numbers together to get an effective skill level for that skill. 1498 Note that `SkillCombination`s can be used to set up more complex 1499 logic for skill combinations across different skills; if adding 1500 levels isn't desired between `FocalContext`s, use different skill 1501 names. 1502 1503 If the skill isn't mentioned, the level will count as 0. 1504 """ 1505 commonContext = state['common'] 1506 activeContext = state['contexts'][state['activeContext']] 1507 return ( 1508 commonContext['capabilities']['skills'].get(skill, 0) 1509 + activeContext['capabilities']['skills'].get(skill, 0) 1510 ) 1511 1512 1513SaveSlot: TypeAlias = str 1514 1515 1516EffectType = Literal[ 1517 'gain', 1518 'lose', 1519 'set', 1520 'toggle', 1521 'deactivate', 1522 'edit', 1523 'goto', 1524 'bounce', 1525 'follow', 1526 'save' 1527] 1528""" 1529The types that effects can use. See `Effect` for details. 1530""" 1531 1532AnyEffectValue: TypeAlias = Union[ 1533 Capability, 1534 Tuple[Token, TokenCount], 1535 Tuple[AnyMechanismSpecifier, MechanismState], 1536 Tuple[Literal['skill'], Skill, Level], 1537 Tuple[AnyMechanismSpecifier, List[MechanismState]], 1538 List[Capability], 1539 None, 1540 List[List[commands.Command]], 1541 AnyDecisionSpecifier, 1542 Tuple[AnyDecisionSpecifier, FocalPointName], 1543 Transition, 1544 SaveSlot 1545] 1546""" 1547A union of all possible effect types. 1548""" 1549 1550 1551class Effect(TypedDict): 1552 """ 1553 Represents one effect of a transition on the decision graph and/or 1554 game state. The `type` slot is an `EffectType` that indicates what 1555 type of effect it is, and determines what the `value` slot will hold. 1556 The `charges` slot is normally `None`, but when set to an integer, 1557 the effect will only trigger that many times, subtracting one charge 1558 each time until it reaches 0, after which the effect will remain but 1559 be ignored. The `delay` slot is also normally `None`, but when set to 1560 an integer, the effect won't trigger but will instead subtract one 1561 from the delay until it reaches zero, at which point it will start to 1562 trigger (and use up charges if there are any). The 'applyTo' slot 1563 should be either 'common' or 'active' (a `ContextSpecifier`) and 1564 determines which focal context the effect applies to. 1565 1566 The `value` values for each `type` are: 1567 1568 - `'gain'`: A `Capability`, (`Token`, `TokenCount`) pair, or 1569 ('skill', `Skill`, `Level`) triple indicating a capability 1570 gained, some tokens acquired, or skill levels gained. 1571 - `'lose'`: A `Capability`, (`Token`, `TokenCount`) pair, or 1572 ('skill', `Skill`, `Level`) triple indicating a capability lost, 1573 some tokens spent, or skill levels lost. Note that the literal 1574 string 'skill' is added to disambiguate skills from tokens. 1575 - `'set'`: A (`Token`, `TokenCount`) pair, a (`MechanismSpecifier`, 1576 `MechanismState`) pair, or a ('skill', `Skill`, `Level`) triple 1577 indicating the new number of tokens, new mechanism state, or new 1578 skill level to establish. Ignores the old count/level, unlike 1579 'gain' and 'lose.' 1580 - `'toggle'`: A list of capabilities which will be toggled on one 1581 after the other, toggling the rest off, OR, a tuple containing a 1582 mechanism name followed by a list of states to be set one after 1583 the other. Does not work for tokens or skills. If a `Capability` 1584 list only has one item, it will be toggled on or off depending 1585 on whether the player currently has that capability or not, 1586 otherwise, whichever capability in the toggle list is currently 1587 active will determine which one gets activated next (the 1588 subsequent one in the list, wrapping from the end to the start). 1589 Note that equivalences are NOT considered when determining which 1590 capability to turn on, and ALL capabilities in the toggle list 1591 except the next one to turn on are turned off. Also, if none of 1592 the capabilities in the list is currently activated, the first 1593 one will be. For mechanisms, `DEFAULT_MECHANISM_STATE` will be 1594 used as the default state if only one state is provided, since 1595 mechanisms can't be "not in a state." `Mechanism` toggles 1596 function based on the current mechanism state; if it's not in 1597 the list they set the first given state. 1598 - `'deactivate'`: `None`. When the effect is activated, the 1599 transition it applies on will be added to the deactivated set in 1600 the current state. This effect type ignores the 'applyTo' value 1601 since it does not make changes to a `FocalContext`. 1602 - `'edit'`: A list of lists of `Command`s, with each list to be 1603 applied in succession on every subsequent activation of the 1604 transition (like toggle). These can use extra variables '$@' to 1605 refer to the source decision of the transition the edit effect is 1606 attached to, '$@d' to refer to the destination decision, '$@t' to 1607 refer to the transition, and '$@r' to refer to its reciprocal. 1608 Commands are powerful and might edit more than just the 1609 specified focal context. 1610 TODO: How to handle list-of-lists format? 1611 - `'goto'`: Either an `AnyDecisionSpecifier` specifying where the 1612 player should end up, or an (`AnyDecisionSpecifier`, 1613 `FocalPointName`) specifying both where they should end up and 1614 which focal point in the relevant domain should be moved. If 1615 multiple 'goto' values are present on different effects of a 1616 transition, they each trigger in turn (and e.g., might activate 1617 multiple decision points in a spreading-focalized domain). Every 1618 transition has a destination, so 'goto' is not necessary: use it 1619 only when an attempt to take a transition is diverted (and 1620 normally, in conjunction with 'charges', 'delay', and/or as an 1621 effect that's behind a `Challenge` or `Conditional`). If a goto 1622 specifies a destination in a plural-focalized domain, but does 1623 not include a focal point name, then the focal point which was 1624 taking the transition will be the one to move. If that 1625 information is not available, the first focal point created in 1626 that domain will be moved by default. Note that when using 1627 something other than a destination ID as the 1628 `AnyDecisionSpecifier`, it's up to you to ensure that the 1629 specifier is not ambiguous, otherwise taking the transition will 1630 crash the program. 1631 - `'bounce'`: Value will be `None`. Prevents the normal position 1632 update associated with a transition that this effect applies to. 1633 Normally, a transition should be marked with an appropriate 1634 requirement to prevent access, even in cases where access seems 1635 possible until tested (just add the requirement on a step after 1636 the transition is observed where relevant). However, 'bounce' can 1637 be used in cases where there's a challenge to fail, for example. 1638 `bounce` is redundant with `goto`: if a `goto` effect applies on 1639 a certain transition, the presence or absence of `bounce` on the 1640 same transition is ignored, since the new position will be 1641 specified by the `goto` value anyways. 1642 - `'follow'`: Value will be a `Transition` name. A transition with 1643 that name must exist at the destination of the action, and when 1644 the follow effect triggers, the player will immediately take 1645 that transition (triggering any consequences it has) after 1646 arriving at their normal destination (so the exploration status 1647 of the normal destination will also be updated). This can result 1648 in an infinite loop if two 'follow' effects imply transitions 1649 which trigger each other, so don't do that. 1650 - `'save'`: Value will be a string indicating a save-slot name. 1651 Indicates a save point, which can be returned to using a 1652 'revertTo' `ExplorationAction`. The entire game state and current 1653 graph is recorded, including effects of the current consequence 1654 before, but not after, the 'save' effect. However, the graph 1655 configuration is not restored by default (see 'revert'). A revert 1656 effect may specify only parts of the state to revert. 1657 1658 TODO: 1659 'focus', 1660 'foreground', 1661 'background', 1662 """ 1663 type: EffectType 1664 applyTo: ContextSpecifier 1665 value: AnyEffectValue 1666 charges: Optional[int] 1667 delay: Optional[int] 1668 hidden: bool 1669 1670 1671def effect( 1672 *, 1673 applyTo: ContextSpecifier = 'active', 1674 gain: Optional[Union[ 1675 Capability, 1676 Tuple[Token, TokenCount], 1677 Tuple[Literal['skill'], Skill, Level] 1678 ]] = None, 1679 lose: Optional[Union[ 1680 Capability, 1681 Tuple[Token, TokenCount], 1682 Tuple[Literal['skill'], Skill, Level] 1683 ]] = None, 1684 set: Optional[Union[ 1685 Tuple[Token, TokenCount], 1686 Tuple[AnyMechanismSpecifier, MechanismState], 1687 Tuple[Literal['skill'], Skill, Level] 1688 ]] = None, 1689 toggle: Optional[Union[ 1690 Tuple[AnyMechanismSpecifier, List[MechanismState]], 1691 List[Capability] 1692 ]] = None, 1693 deactivate: Optional[bool] = None, 1694 edit: Optional[List[List[commands.Command]]] = None, 1695 goto: Optional[Union[ 1696 AnyDecisionSpecifier, 1697 Tuple[AnyDecisionSpecifier, FocalPointName] 1698 ]] = None, 1699 bounce: Optional[bool] = None, 1700 follow: Optional[Transition] = None, 1701 save: Optional[SaveSlot] = None, 1702 delay: Optional[int] = None, 1703 charges: Optional[int] = None, 1704 hidden: bool = False 1705) -> Effect: 1706 """ 1707 Factory for a transition effect which includes default values so you 1708 can just specify effect types that are relevant to a particular 1709 situation. You may not supply values for more than one of 1710 gain/lose/set/toggle/deactivate/edit/goto/bounce, since which one 1711 you use determines the effect type. 1712 """ 1713 tCount = len([ 1714 x 1715 for x in ( 1716 gain, 1717 lose, 1718 set, 1719 toggle, 1720 deactivate, 1721 edit, 1722 goto, 1723 bounce, 1724 follow, 1725 save 1726 ) 1727 if x is not None 1728 ]) 1729 if tCount == 0: 1730 raise ValueError( 1731 "You must specify one of gain, lose, set, toggle, deactivate," 1732 " edit, goto, bounce, follow, or save." 1733 ) 1734 elif tCount > 1: 1735 raise ValueError( 1736 f"You may only specify one of gain, lose, set, toggle," 1737 f" deactivate, edit, goto, bounce, follow, or save" 1738 f" (you provided values for {tCount} of those)." 1739 ) 1740 1741 result: Effect = { 1742 'type': 'edit', 1743 'applyTo': applyTo, 1744 'value': [], 1745 'delay': delay, 1746 'charges': charges, 1747 'hidden': hidden 1748 } 1749 1750 if gain is not None: 1751 result['type'] = 'gain' 1752 result['value'] = gain 1753 elif lose is not None: 1754 result['type'] = 'lose' 1755 result['value'] = lose 1756 elif set is not None: 1757 result['type'] = 'set' 1758 if ( 1759 len(set) == 2 1760 and isinstance(set[0], MechanismName) 1761 and isinstance(set[1], MechanismState) 1762 ): 1763 result['value'] = ( 1764 MechanismSpecifier(None, None, None, set[0]), 1765 set[1] 1766 ) 1767 else: 1768 result['value'] = set 1769 elif toggle is not None: 1770 result['type'] = 'toggle' 1771 result['value'] = toggle 1772 elif deactivate is not None: 1773 result['type'] = 'deactivate' 1774 result['value'] = None 1775 elif edit is not None: 1776 result['type'] = 'edit' 1777 result['value'] = edit 1778 elif goto is not None: 1779 result['type'] = 'goto' 1780 result['value'] = goto 1781 elif bounce is not None: 1782 result['type'] = 'bounce' 1783 result['value'] = None 1784 elif follow is not None: 1785 result['type'] = 'follow' 1786 result['value'] = follow 1787 elif save is not None: 1788 result['type'] = 'save' 1789 result['value'] = save 1790 else: 1791 raise RuntimeError( 1792 "No effect specified in effect function & check failed." 1793 ) 1794 1795 return result 1796 1797 1798class SkillCombination: 1799 """ 1800 Represents which skill(s) are used for a `Challenge`, including under 1801 what circumstances different skills might apply using 1802 `Requirement`s. This is an abstract class, use the subclasses 1803 `BestSkill`, `WorstSkill`, `CombinedSkill`, `InverseSkill`, and/or 1804 `ConditionalSkill` to represent a specific situation. To represent a 1805 single required skill, use a `BestSkill` or `CombinedSkill` with 1806 that skill as the only skill. 1807 1808 Use `SkillCombination.effectiveLevel` to figure out the effective 1809 level of the entire requirement in a given situation. Note that 1810 levels from the common and active `FocalContext`s are added together 1811 whenever a specific skill level is referenced. 1812 1813 Some examples: 1814 1815 >>> from . import core 1816 >>> ctx = RequirementContext(emptyState(), core.DecisionGraph(), set()) 1817 >>> ctx.state['common']['capabilities']['skills']['brawn'] = 1 1818 >>> ctx.state['common']['capabilities']['skills']['brains'] = 3 1819 >>> ctx.state['common']['capabilities']['skills']['luck'] = -1 1820 1821 1. To represent using just the 'brains' skill, you would use: 1822 1823 `BestSkill('brains')` 1824 1825 >>> sr = BestSkill('brains') 1826 >>> sr.effectiveLevel(ctx) 1827 3 1828 1829 If a skill isn't listed, its level counts as 0: 1830 1831 >>> sr = BestSkill('agility') 1832 >>> sr.effectiveLevel(ctx) 1833 0 1834 1835 To represent using the higher of 'brains' or 'brawn' you'd use: 1836 1837 `BestSkill('brains', 'brawn')` 1838 1839 >>> sr = BestSkill('brains', 'brawn') 1840 >>> sr.effectiveLevel(ctx) 1841 3 1842 1843 The zero default only applies if an unknown skill is in the mix: 1844 1845 >>> sr = BestSkill('luck') 1846 >>> sr.effectiveLevel(ctx) 1847 -1 1848 >>> sr = BestSkill('luck', 'agility') 1849 >>> sr.effectiveLevel(ctx) 1850 0 1851 1852 2. To represent using the lower of 'brains' or 'brawn' you'd use: 1853 1854 `WorstSkill('brains', 'brawn')` 1855 1856 >>> sr = WorstSkill('brains', 'brawn') 1857 >>> sr.effectiveLevel(ctx) 1858 1 1859 1860 3. To represent using 'brawn' if the focal context has the 'brawny' 1861 capability, but brains if not, use: 1862 1863 ``` 1864 ConditionalSkill( 1865 ReqCapability('brawny'), 1866 'brawn', 1867 'brains' 1868 ) 1869 ``` 1870 1871 >>> sr = ConditionalSkill( 1872 ... ReqCapability('brawny'), 1873 ... 'brawn', 1874 ... 'brains' 1875 ... ) 1876 >>> sr.effectiveLevel(ctx) 1877 3 1878 >>> brawny = copy.deepcopy(ctx) 1879 >>> brawny.state['common']['capabilities']['capabilities'].add( 1880 ... 'brawny' 1881 ... ) 1882 >>> sr.effectiveLevel(brawny) 1883 1 1884 1885 If the player can still choose to use 'brains' even when they 1886 have the 'brawny' capability, you would do: 1887 1888 >>> sr = ConditionalSkill( 1889 ... ReqCapability('brawny'), 1890 ... BestSkill('brawn', 'brains'), 1891 ... 'brains' 1892 ... ) 1893 >>> sr.effectiveLevel(ctx) 1894 3 1895 >>> sr.effectiveLevel(brawny) # can still use brains if better 1896 3 1897 1898 4. To represent using the combined level of the 'brains' and 1899 'brawn' skills, you would use: 1900 1901 `CombinedSkill('brains', 'brawn')` 1902 1903 >>> sr = CombinedSkill('brains', 'brawn') 1904 >>> sr.effectiveLevel(ctx) 1905 4 1906 1907 5. Skill names can be replaced by entire sub-`SkillCombination`s in 1908 any position, so more complex forms are possible: 1909 1910 >>> sr = BestSkill(CombinedSkill('brains', 'luck'), 'brawn') 1911 >>> sr.effectiveLevel(ctx) 1912 2 1913 >>> sr = BestSkill( 1914 ... ConditionalSkill( 1915 ... ReqCapability('brawny'), 1916 ... 'brawn', 1917 ... 'brains', 1918 ... ), 1919 ... CombinedSkill('brains', 'luck') 1920 ... ) 1921 >>> sr.effectiveLevel(ctx) 1922 3 1923 >>> sr.effectiveLevel(brawny) 1924 2 1925 """ 1926 def effectiveLevel(self, context: 'RequirementContext') -> Level: 1927 """ 1928 Returns the effective `Level` of the skill combination, given 1929 the situation specified by the provided `RequirementContext`. 1930 """ 1931 raise NotImplementedError( 1932 "SkillCombination is an abstract class. Use one of its" 1933 " subclsases instead." 1934 ) 1935 1936 def __eq__(self, other: Any) -> bool: 1937 raise NotImplementedError( 1938 "SkillCombination is an abstract class and cannot be compared." 1939 ) 1940 1941 def __hash__(self) -> int: 1942 raise NotImplementedError( 1943 "SkillCombination is an abstract class and cannot be hashed." 1944 ) 1945 1946 def walk(self) -> Generator[ 1947 Union['SkillCombination', Skill, Level], 1948 None, 1949 None 1950 ]: 1951 """ 1952 Yields this combination and each sub-part in depth-first 1953 traversal order. 1954 """ 1955 raise NotImplementedError( 1956 "SkillCombination is an abstract class and cannot be walked." 1957 ) 1958 1959 def unparse(self) -> str: 1960 """ 1961 Returns a string that `SkillCombination.parse` would turn back 1962 into a `SkillCombination` equivalent to this one. For example: 1963 1964 >>> BestSkill('brains').unparse() 1965 'best(brains)' 1966 >>> WorstSkill('brains', 'brawn').unparse() 1967 'worst(brains, brawn)' 1968 >>> CombinedSkill( 1969 ... ConditionalSkill(ReqTokens('orb', 3), 'brains', 0), 1970 ... InverseSkill('luck') 1971 ... ).unparse() 1972 'sum(if(orb*3, brains, 0), ~luck)' 1973 """ 1974 raise NotImplementedError( 1975 "SkillCombination is an abstract class and cannot be" 1976 " unparsed." 1977 ) 1978 1979 1980class BestSkill(SkillCombination): 1981 def __init__( 1982 self, 1983 *skills: Union[SkillCombination, Skill, Level] 1984 ): 1985 """ 1986 Given one or more `SkillCombination` sub-items and/or skill 1987 names or levels, represents a situation where the highest 1988 effective level among the sub-items is used. Skill names 1989 translate to the player's level for that skill (with 0 as a 1990 default) while level numbers translate to that number. 1991 """ 1992 if len(skills) == 0: 1993 raise ValueError( 1994 "Cannot create a `BestSkill` with 0 sub-skills." 1995 ) 1996 self.skills = skills 1997 1998 def __eq__(self, other: Any) -> bool: 1999 return isinstance(other, BestSkill) and other.skills == self.skills 2000 2001 def __hash__(self) -> int: 2002 result = 1829 2003 for sk in self.skills: 2004 result += hash(sk) 2005 return result 2006 2007 def __repr__(self) -> str: 2008 subs = ', '.join(repr(sk) for sk in self.skills) 2009 return "BestSkill(" + subs + ")" 2010 2011 def walk(self) -> Generator[ 2012 Union[SkillCombination, Skill, Level], 2013 None, 2014 None 2015 ]: 2016 yield self 2017 for sub in self.skills: 2018 if isinstance(sub, (Skill, Level)): 2019 yield sub 2020 else: 2021 yield from sub.walk() 2022 2023 def effectiveLevel(self, ctx: 'RequirementContext') -> Level: 2024 """ 2025 Determines the effective level of each sub-skill-combo and 2026 returns the highest of those. 2027 """ 2028 result = None 2029 level: Level 2030 if len(self.skills) == 0: 2031 raise RuntimeError("Invalid BestSkill: has zero sub-skills.") 2032 for sk in self.skills: 2033 if isinstance(sk, Level): 2034 level = sk 2035 elif isinstance(sk, Skill): 2036 level = getSkillLevel(ctx.state, sk) 2037 elif isinstance(sk, SkillCombination): 2038 level = sk.effectiveLevel(ctx) 2039 else: 2040 raise RuntimeError( 2041 f"Invalid BestSkill: found sub-skill '{repr(sk)}'" 2042 f" which is not a skill name string, level integer," 2043 f" or SkillCombination." 2044 ) 2045 if result is None or result < level: 2046 result = level 2047 2048 assert result is not None 2049 return result 2050 2051 def unparse(self): 2052 result = "best(" 2053 for sk in self.skills: 2054 if isinstance(sk, SkillCombination): 2055 result += sk.unparse() 2056 else: 2057 result += str(sk) 2058 result += ', ' 2059 return result[:-2] + ')' 2060 2061 2062class WorstSkill(SkillCombination): 2063 def __init__( 2064 self, 2065 *skills: Union[SkillCombination, Skill, Level] 2066 ): 2067 """ 2068 Given one or more `SkillCombination` sub-items and/or skill 2069 names or levels, represents a situation where the lowest 2070 effective level among the sub-items is used. Skill names 2071 translate to the player's level for that skill (with 0 as a 2072 default) while level numbers translate to that number. 2073 """ 2074 if len(skills) == 0: 2075 raise ValueError( 2076 "Cannot create a `WorstSkill` with 0 sub-skills." 2077 ) 2078 self.skills = skills 2079 2080 def __eq__(self, other: Any) -> bool: 2081 return isinstance(other, WorstSkill) and other.skills == self.skills 2082 2083 def __hash__(self) -> int: 2084 result = 7182 2085 for sk in self.skills: 2086 result += hash(sk) 2087 return result 2088 2089 def __repr__(self) -> str: 2090 subs = ', '.join(repr(sk) for sk in self.skills) 2091 return "WorstSkill(" + subs + ")" 2092 2093 def walk(self) -> Generator[ 2094 Union[SkillCombination, Skill, Level], 2095 None, 2096 None 2097 ]: 2098 yield self 2099 for sub in self.skills: 2100 if isinstance(sub, (Skill, Level)): 2101 yield sub 2102 else: 2103 yield from sub.walk() 2104 2105 def effectiveLevel(self, ctx: 'RequirementContext') -> Level: 2106 """ 2107 Determines the effective level of each sub-skill-combo and 2108 returns the lowest of those. 2109 """ 2110 result = None 2111 level: Level 2112 if len(self.skills) == 0: 2113 raise RuntimeError("Invalid WorstSkill: has zero sub-skills.") 2114 for sk in self.skills: 2115 if isinstance(sk, Level): 2116 level = sk 2117 elif isinstance(sk, Skill): 2118 level = getSkillLevel(ctx.state, sk) 2119 elif isinstance(sk, SkillCombination): 2120 level = sk.effectiveLevel(ctx) 2121 else: 2122 raise RuntimeError( 2123 f"Invalid WorstSkill: found sub-skill '{repr(sk)}'" 2124 f" which is not a skill name string, level integer," 2125 f" or SkillCombination." 2126 ) 2127 if result is None or result > level: 2128 result = level 2129 2130 assert result is not None 2131 return result 2132 2133 def unparse(self): 2134 result = "worst(" 2135 for sk in self.skills: 2136 if isinstance(sk, SkillCombination): 2137 result += sk.unparse() 2138 else: 2139 result += str(sk) 2140 result += ', ' 2141 return result[:-2] + ')' 2142 2143 2144class CombinedSkill(SkillCombination): 2145 def __init__( 2146 self, 2147 *skills: Union[SkillCombination, Skill, Level] 2148 ): 2149 """ 2150 Given one or more `SkillCombination` sub-items and/or skill 2151 names or levels, represents a situation where the sum of the 2152 effective levels of each sub-item is used. Skill names 2153 translate to the player's level for that skill (with 0 as a 2154 default) while level numbers translate to that number. 2155 """ 2156 if len(skills) == 0: 2157 raise ValueError( 2158 "Cannot create a `CombinedSkill` with 0 sub-skills." 2159 ) 2160 self.skills = skills 2161 2162 def __eq__(self, other: Any) -> bool: 2163 return ( 2164 isinstance(other, CombinedSkill) 2165 and other.skills == self.skills 2166 ) 2167 2168 def __hash__(self) -> int: 2169 result = 2871 2170 for sk in self.skills: 2171 result += hash(sk) 2172 return result 2173 2174 def __repr__(self) -> str: 2175 subs = ', '.join(repr(sk) for sk in self.skills) 2176 return "CombinedSkill(" + subs + ")" 2177 2178 def walk(self) -> Generator[ 2179 Union[SkillCombination, Skill, Level], 2180 None, 2181 None 2182 ]: 2183 yield self 2184 for sub in self.skills: 2185 if isinstance(sub, (Skill, Level)): 2186 yield sub 2187 else: 2188 yield from sub.walk() 2189 2190 def effectiveLevel(self, ctx: 'RequirementContext') -> Level: 2191 """ 2192 Determines the effective level of each sub-skill-combo and 2193 returns the sum of those, with 0 as a default. 2194 """ 2195 result = 0 2196 level: Level 2197 if len(self.skills) == 0: 2198 raise RuntimeError( 2199 "Invalid CombinedSkill: has zero sub-skills." 2200 ) 2201 for sk in self.skills: 2202 if isinstance(sk, Level): 2203 level = sk 2204 elif isinstance(sk, Skill): 2205 level = getSkillLevel(ctx.state, sk) 2206 elif isinstance(sk, SkillCombination): 2207 level = sk.effectiveLevel(ctx) 2208 else: 2209 raise RuntimeError( 2210 f"Invalid CombinedSkill: found sub-skill '{repr(sk)}'" 2211 f" which is not a skill name string, level integer," 2212 f" or SkillCombination." 2213 ) 2214 result += level 2215 2216 assert result is not None 2217 return result 2218 2219 def unparse(self): 2220 result = "sum(" 2221 for sk in self.skills: 2222 if isinstance(sk, SkillCombination): 2223 result += sk.unparse() 2224 else: 2225 result += str(sk) 2226 result += ', ' 2227 return result[:-2] + ')' 2228 2229 2230class InverseSkill(SkillCombination): 2231 def __init__( 2232 self, 2233 invert: Union[SkillCombination, Skill, Level] 2234 ): 2235 """ 2236 Represents the effective level of the given `SkillCombination`, 2237 the level of the given `Skill`, or just the provided specific 2238 `Level`, except inverted (multiplied by -1). 2239 """ 2240 self.invert = invert 2241 2242 def __eq__(self, other: Any) -> bool: 2243 return ( 2244 isinstance(other, InverseSkill) 2245 and other.invert == self.invert 2246 ) 2247 2248 def __hash__(self) -> int: 2249 return 3193 + hash(self.invert) 2250 2251 def __repr__(self) -> str: 2252 return "InverseSkill(" + repr(self.invert) + ")" 2253 2254 def walk(self) -> Generator[ 2255 Union[SkillCombination, Skill, Level], 2256 None, 2257 None 2258 ]: 2259 yield self 2260 if isinstance(self.invert, SkillCombination): 2261 yield from self.invert.walk() 2262 else: 2263 yield self.invert 2264 2265 def effectiveLevel(self, ctx: 'RequirementContext') -> Level: 2266 """ 2267 Determines whether the requirement is satisfied or not and then 2268 returns the effective level of either the `ifSatisfied` or 2269 `ifNot` skill combination, as appropriate. 2270 """ 2271 if isinstance(self.invert, Level): 2272 return -self.invert 2273 elif isinstance(self.invert, Skill): 2274 return -getSkillLevel(ctx.state, self.invert) 2275 elif isinstance(self.invert, SkillCombination): 2276 return -self.invert.effectiveLevel(ctx) 2277 else: 2278 raise RuntimeError( 2279 f"Invalid InverseSkill: invert value {repr(self.invert)}" 2280 f" The invert value must be a Level (int), a Skill" 2281 f" (str), or a SkillCombination." 2282 ) 2283 2284 def unparse(self): 2285 # TODO: Move these to `parsing` to avoid hard-coded tokens here? 2286 if isinstance(self.invert, SkillCombination): 2287 return '~' + self.invert.unparse() 2288 else: 2289 return '~' + str(self.invert) 2290 2291 2292class ConditionalSkill(SkillCombination): 2293 def __init__( 2294 self, 2295 requirement: 'Requirement', 2296 ifSatisfied: Union[SkillCombination, Skill, Level], 2297 ifNot: Union[SkillCombination, Skill, Level] = 0 2298 ): 2299 """ 2300 Given a `Requirement` and two different sub-`SkillCombination`s, 2301 which can also be `Skill` names or fixed `Level`s, represents 2302 situations where which skills come into play depends on what 2303 capabilities the player has. In situations where the given 2304 requirement is satisfied, the `ifSatisfied` combination's 2305 effective level is used, and otherwise the `ifNot` level is 2306 used. By default `ifNot` is just the fixed level 0. 2307 """ 2308 self.requirement = requirement 2309 self.ifSatisfied = ifSatisfied 2310 self.ifNot = ifNot 2311 2312 def __eq__(self, other: Any) -> bool: 2313 return ( 2314 isinstance(other, ConditionalSkill) 2315 and other.requirement == self.requirement 2316 and other.ifSatisfied == self.ifSatisfied 2317 and other.ifNot == self.ifNot 2318 ) 2319 2320 def __hash__(self) -> int: 2321 return ( 2322 1278 2323 + hash(self.requirement) 2324 + hash(self.ifSatisfied) 2325 + hash(self.ifNot) 2326 ) 2327 2328 def __repr__(self) -> str: 2329 return ( 2330 "ConditionalSkill(" 2331 + repr(self.requirement) + ", " 2332 + repr(self.ifSatisfied) + ", " 2333 + repr(self.ifNot) 2334 + ")" 2335 ) 2336 2337 def walk(self) -> Generator[ 2338 Union[SkillCombination, Skill, Level], 2339 None, 2340 None 2341 ]: 2342 yield self 2343 for sub in (self.ifSatisfied, self.ifNot): 2344 if isinstance(sub, SkillCombination): 2345 yield from sub.walk() 2346 else: 2347 yield sub 2348 2349 def effectiveLevel(self, ctx: 'RequirementContext') -> Level: 2350 """ 2351 Determines whether the requirement is satisfied or not and then 2352 returns the effective level of either the `ifSatisfied` or 2353 `ifNot` skill combination, as appropriate. 2354 """ 2355 if self.requirement.satisfied(ctx): 2356 use = self.ifSatisfied 2357 sat = True 2358 else: 2359 use = self.ifNot 2360 sat = False 2361 2362 if isinstance(use, Level): 2363 return use 2364 elif isinstance(use, Skill): 2365 return getSkillLevel(ctx.state, use) 2366 elif isinstance(use, SkillCombination): 2367 return use.effectiveLevel(ctx) 2368 else: 2369 raise RuntimeError( 2370 f"Invalid ConditionalSkill: Requirement was" 2371 f" {'not ' if not sat else ''}satisfied, and the" 2372 f" corresponding skill value was not a level, skill, or" 2373 f" SkillCombination: {repr(use)}" 2374 ) 2375 2376 def unparse(self): 2377 result = f"if({self.requirement.unparse()}, " 2378 if isinstance(self.ifSatisfied, SkillCombination): 2379 result += self.ifSatisfied.unparse() 2380 else: 2381 result += str(self.ifSatisfied) 2382 result += ', ' 2383 if isinstance(self.ifNot, SkillCombination): 2384 result += self.ifNot.unparse() 2385 else: 2386 result += str(self.ifNot) 2387 return result + ')' 2388 2389 2390class Challenge(TypedDict): 2391 """ 2392 Represents a random binary decision between two possible outcomes, 2393 only one of which will actually occur. The 'outcome' can be set to 2394 `True` or `False` to represent that the outcome of the challenge has 2395 been observed, or to `None` (the default) to represent a pending 2396 challenge. The chance of 'success' is determined by the associated 2397 skill(s) and the challenge level, although one or both may be 2398 unknown in which case a variable is used in place of a concrete 2399 value. Probabilities that are of the form 1/2**n or (2**n - 1) / 2400 (2**n) can be represented, the specific formula for the chance of 2401 success is for a challenge with a single skill is: 2402 2403 s = interacting entity's skill level in associated skill 2404 c = challenge level 2405 P(success) = { 2406 1 - 1/2**(1 + s - c) if s > c 2407 1/2 if s == c 2408 1/2**(1 + c - s) if c > s 2409 } 2410 2411 This probability formula is equivalent to the following procedure: 2412 2413 1. Flip one coin, plus one additional coin for each level difference 2414 between the skill and challenge levels. 2415 2. If the skill level is equal to or higher than the challenge 2416 level, the outcome is success if any single coin comes up heads. 2417 3. If the skill level is less than the challenge level, then the 2418 outcome is success only if *all* coins come up heads. 2419 4. If the outcome is not success, it is failure. 2420 2421 Multiple skills can be combined into a `SkillCombination`, which can 2422 use the max or min of several skills, add skill levels together, 2423 and/or have skills which are only relevant when a certain 2424 `Requirement` is satisfied. If a challenge has no skills associated 2425 with it, then the player's skill level counts as 0. 2426 2427 The slots are: 2428 2429 - 'skills': A `SkillCombination` that specifies the relevant 2430 skill(s). 2431 - 'level': An integer specifying the level of the challenge. Along 2432 with the appropriate skill level of the interacting entity, this 2433 determines the probability of success or failure. 2434 - 'success': A `Consequence` which will happen when the outcome is 2435 success. Note that since a `Consequence` can be a `Challenge`, 2436 multi-outcome challenges can be represented by chaining multiple 2437 challenges together. 2438 - 'failure': A `Consequence` which will happen when the outcome is 2439 failure. 2440 - 'outcome': The outcome of the challenge: `True` means success, 2441 `False` means failure, and `None` means "not known (yet)." 2442 """ 2443 skills: SkillCombination 2444 level: Level 2445 success: 'Consequence' 2446 failure: 'Consequence' 2447 outcome: Optional[bool] 2448 2449 2450def challenge( 2451 skills: Optional[SkillCombination] = None, 2452 level: Level = 0, 2453 success: Optional['Consequence'] = None, 2454 failure: Optional['Consequence'] = None, 2455 outcome: Optional[bool] = None 2456): 2457 """ 2458 Factory for `Challenge`s, defaults to empty effects for both success 2459 and failure outcomes, so that you can just provide one or the other 2460 if you need to. Skills defaults to an empty list, the level defaults 2461 to 0 and the outcome defaults to `None` which means "not (yet) 2462 known." 2463 """ 2464 if skills is None: 2465 skills = BestSkill(0) 2466 if success is None: 2467 success = [] 2468 if failure is None: 2469 failure = [] 2470 return { 2471 'skills': skills, 2472 'level': level, 2473 'success': success, 2474 'failure': failure, 2475 'outcome': outcome 2476 } 2477 2478 2479class Condition(TypedDict): 2480 """ 2481 Represents a condition over `Capability`, `Token`, and/or `Mechanism` 2482 states which applies to one or more `Effect`s or `Challenge`s as part 2483 of a `Consequence`. If the specified `Requirement` is satisfied, the 2484 included `Consequence` is treated as if it were part of the 2485 `Consequence` that the `Condition` is inside of, if the requirement 2486 is not satisfied, then the internal `Consequence` is skipped and the 2487 alternate consequence is used instead. Either sub-consequence may of 2488 course be an empty list. 2489 """ 2490 condition: 'Requirement' 2491 consequence: 'Consequence' 2492 alternative: 'Consequence' 2493 2494 2495def condition( 2496 condition: 'Requirement', 2497 consequence: 'Consequence', 2498 alternative: Optional['Consequence'] = None 2499): 2500 """ 2501 Factory for conditions that just glues the given requirement, 2502 consequence, and alternative together. The alternative defaults to 2503 an empty list if not specified. 2504 """ 2505 if alternative is None: 2506 alternative = [] 2507 return { 2508 'condition': condition, 2509 'consequence': consequence, 2510 'alternative': alternative 2511 } 2512 2513 2514Consequence: 'TypeAlias' = List[Union[Challenge, Effect, Condition]] 2515""" 2516Represents a theoretical space of consequences that can occur as a 2517result of attempting an action, or as the success or failure outcome for 2518a challenge. It includes multiple effects and/or challenges, and since 2519challenges have consequences as their outcomes, consequences form a tree 2520structure, with `Effect`s as their leaves. Items in a `Consequence` are 2521applied in-order resolving all outcomes and sub-outcomes of a challenge 2522before considering the next item in the top-level consequence. 2523 2524The `Challenge`s in a `Consequence` may have their 'outcome' set to 2525`None` to represent a theoretical challenge, or it may be set to either 2526`True` or `False` to represent an observed outcome. 2527""" 2528 2529 2530ChallengePolicy: 'TypeAlias' = Literal[ 2531 'random', 2532 'mostLikely', 2533 'fewestEffects', 2534 'success', 2535 'failure', 2536 'specified', 2537] 2538""" 2539Specifies how challenges should be resolved. See 2540`observeChallengeOutcomes`. 2541""" 2542 2543 2544#-------------------------------# 2545# Consequence Utility Functions # 2546#-------------------------------# 2547 2548 2549def resetChallengeOutcomes(consequence: Consequence) -> None: 2550 """ 2551 Traverses all sub-consequences of the given consequence, setting the 2552 outcomes of any `Challenge`s it encounters to `None`, to prepare for 2553 a fresh call to `observeChallengeOutcomes`. 2554 2555 Resets all outcomes in every branch, regardless of previous 2556 outcomes. 2557 2558 For example: 2559 2560 >>> from . import core 2561 >>> e = core.emptySituation() 2562 >>> c = challenge( 2563 ... success=[effect(gain=('money', 12))], 2564 ... failure=[effect(lose=('money', 10))] 2565 ... ) # skill defaults to 'luck', level to 0, and outcome to None 2566 >>> c['outcome'] is None # default outcome is None 2567 True 2568 >>> r = observeChallengeOutcomes(e, [c], policy='mostLikely') 2569 >>> r[0]['outcome'] 2570 True 2571 >>> c['outcome'] # original outcome is changed from None 2572 True 2573 >>> r[0] is c 2574 True 2575 >>> resetChallengeOutcomes([c]) 2576 >>> c['outcome'] is None # now has been reset 2577 True 2578 >>> r[0]['outcome'] is None # same object... 2579 True 2580 >>> resetChallengeOutcomes(c) # can't reset just a Challenge 2581 Traceback (most recent call last): 2582 ... 2583 TypeError... 2584 >>> r = observeChallengeOutcomes(e, [c], policy='success') 2585 >>> r[0]['outcome'] 2586 True 2587 >>> r = observeChallengeOutcomes(e, [c], policy='failure') 2588 >>> r[0]['outcome'] # wasn't reset 2589 True 2590 >>> resetChallengeOutcomes([c]) # now reset it 2591 >>> c['outcome'] is None 2592 True 2593 >>> r = observeChallengeOutcomes(e, [c], policy='failure') 2594 >>> r[0]['outcome'] # was reset 2595 False 2596 """ 2597 if not isinstance(consequence, list): 2598 raise TypeError( 2599 f"Invalid consequence: must be a list." 2600 f"\nGot: {repr(consequence)}" 2601 ) 2602 2603 for item in consequence: 2604 if not isinstance(item, dict): 2605 raise TypeError( 2606 f"Invalid consequence: items in the list must be" 2607 f" Effects, Challenges, or Conditions." 2608 f"\nGot item: {repr(item)}" 2609 ) 2610 if 'skills' in item: # must be a Challenge 2611 item = cast(Challenge, item) 2612 item['outcome'] = None 2613 # reset both branches 2614 resetChallengeOutcomes(item['success']) 2615 resetChallengeOutcomes(item['failure']) 2616 2617 elif 'value' in item: # an Effect 2618 continue # Effects don't have sub-outcomes 2619 2620 elif 'condition' in item: # a Condition 2621 item = cast(Condition, item) 2622 resetChallengeOutcomes(item['consequence']) 2623 resetChallengeOutcomes(item['alternative']) 2624 2625 else: # bad dict 2626 raise TypeError( 2627 f"Invalid consequence: items in the list must be" 2628 f" Effects, Challenges, or Conditions (got a dictionary" 2629 f" without 'skills', 'value', or 'condition' keys)." 2630 f"\nGot item: {repr(item)}" 2631 ) 2632 2633 2634def observeChallengeOutcomes( 2635 context: RequirementContext, 2636 consequence: Consequence, 2637 location: Optional[Set[DecisionID]] = None, 2638 policy: ChallengePolicy = 'random', 2639 knownOutcomes: Optional[List[bool]] = None, 2640 makeCopy: bool = False 2641) -> Consequence: 2642 """ 2643 Given a `RequirementContext` (for `Capability`, `Token`, and `Skill` 2644 info as well as equivalences in the `DecisionGraph` and a 2645 search-from location for mechanism names) and a `Conseqeunce` to be 2646 observed, sets the 'outcome' value for each `Challenge` in it to 2647 either `True` or `False` by determining an outcome for each 2648 `Challenge` that's relevant (challenges locked behind unsatisfied 2649 `Condition`s or on untaken branches of other challenges are not 2650 given outcomes). `Challenge`s that already have assigned outcomes 2651 re-use those outcomes, call `resetChallengeOutcomes` beforehand if 2652 you want to re-decide each challenge with a new policy, and use the 2653 'specified' policy if you want to ensure only pre-specified outcomes 2654 are used. 2655 2656 Normally, the return value is just the original `consequence` 2657 object. However, if `makeCopy` is set to `True`, a deep copy is made 2658 and returned, so the original is not modified. One potential problem 2659 with this is that effects will be copied in this process, which 2660 means that if they are applied, things like delays and toggles won't 2661 update properly. `makeCopy` should thus normally not be used. 2662 2663 The 'policy' value can be one of the `ChallengePolicy` values. The 2664 default is 'random', in which case the `random.random` function is 2665 used to determine each outcome, based on the probability derived 2666 from the challenge level and the associated skill level. The other 2667 policies are: 2668 2669 - 'mostLikely': the result of each challenge will be whichever 2670 outcome is more likely, with success always happening instead of 2671 failure when the probabilities are 50/50. 2672 - 'fewestEffects`: whichever combination of outcomes leads to the 2673 fewest total number of effects will be chosen (modulo satisfying 2674 requirements of `Condition`s). Note that there's no estimation 2675 of the severity of effects, just the raw number. Ties in terms 2676 of number of effects are broken towards successes. This policy 2677 involves evaluating all possible outcome combinations to figure 2678 out which one has the fewest effects. 2679 - 'success' or 'failure': all outcomes will either succeed, or 2680 fail, as specified. Note that success/failure may cut off some 2681 challenges, so it's not the case that literally every challenge 2682 will succeed/fail; some may be skipped because of the 2683 specified success/failure of a prior challenge. 2684 - 'specified': all outcomes have already been specified, and those 2685 pre-specified outcomes should be used as-is. 2686 2687 2688 In call cases, outcomes specified via `knownOutcomes` take precedence 2689 over the challenge policy. The `knownOutcomes` list will be emptied 2690 out as this function works, but extra consequences beyond what's 2691 needed will be ignored (and left in the list). 2692 2693 Note that there are limits on the resolution of Python's random 2694 number generation; for challenges with extremely high or low levels 2695 relative to the associated skill(s) where the probability of success 2696 is very close to 1 or 0, there may not actually be any chance of 2697 success/failure at all. Typically you can ignore this, because such 2698 cases should not normally come up in practice, and because the odds 2699 of success/failure in those cases are such that to notice the 2700 missing possibility share you'd have to simulate outcomes a 2701 ridiculous number of times. 2702 2703 TODO: Location examples; move some of these to a separate testing 2704 file. 2705 2706 For example: 2707 2708 >>> random.seed(17) 2709 >>> warnings.filterwarnings('error') 2710 >>> from . import core 2711 >>> e = core.emptySituation() 2712 >>> c = challenge( 2713 ... success=[effect(gain=('money', 12))], 2714 ... failure=[effect(lose=('money', 10))] 2715 ... ) # skill defaults to 'luck', level to 0, and outcome to None 2716 >>> c['outcome'] is None # default outcome is None 2717 True 2718 >>> r = observeChallengeOutcomes(e, [c]) 2719 >>> r[0]['outcome'] 2720 False 2721 >>> c['outcome'] # original outcome is changed from None 2722 False 2723 >>> all( 2724 ... observeChallengeOutcomes(e, [c])[0]['outcome'] is False 2725 ... for i in range(20) 2726 ... ) # no reset -> same outcome 2727 True 2728 >>> resetChallengeOutcomes([c]) 2729 >>> observeChallengeOutcomes(e, [c])[0]['outcome'] # Random after reset 2730 False 2731 >>> resetChallengeOutcomes([c]) 2732 >>> observeChallengeOutcomes(e, [c])[0]['outcome'] # Random after reset 2733 False 2734 >>> resetChallengeOutcomes([c]) 2735 >>> observeChallengeOutcomes(e, [c])[0]['outcome'] # Random after reset 2736 True 2737 >>> observeChallengeOutcomes(e, c) # Can't resolve just a Challenge 2738 Traceback (most recent call last): 2739 ... 2740 TypeError... 2741 >>> allSame = [] 2742 >>> for i in range(20): 2743 ... resetChallengeOutcomes([c]) 2744 ... obs = observeChallengeOutcomes(e, [c, c]) 2745 ... allSame.append(obs[0]['outcome'] == obs[1]['outcome']) 2746 >>> allSame == [True]*20 2747 True 2748 >>> different = [] 2749 >>> for i in range(20): 2750 ... resetChallengeOutcomes([c]) 2751 ... obs = observeChallengeOutcomes(e, [c, copy.deepcopy(c)]) 2752 ... different.append(obs[0]['outcome'] == obs[1]['outcome']) 2753 >>> False in different 2754 True 2755 >>> all( # Tie breaks towards success 2756 ... ( 2757 ... resetChallengeOutcomes([c]), 2758 ... observeChallengeOutcomes(e, [c], policy='mostLikely') 2759 ... )[1][0]['outcome'] is True 2760 ... for i in range(20) 2761 ... ) 2762 True 2763 >>> all( # Tie breaks towards success 2764 ... ( 2765 ... resetChallengeOutcomes([c]), 2766 ... observeChallengeOutcomes(e, [c], policy='fewestEffects') 2767 ... )[1][0]['outcome'] is True 2768 ... for i in range(20) 2769 ... ) 2770 True 2771 >>> all( 2772 ... ( 2773 ... resetChallengeOutcomes([c]), 2774 ... observeChallengeOutcomes(e, [c], policy='success') 2775 ... )[1][0]['outcome'] is True 2776 ... for i in range(20) 2777 ... ) 2778 True 2779 >>> all( 2780 ... ( 2781 ... resetChallengeOutcomes([c]), 2782 ... observeChallengeOutcomes(e, [c], policy='failure') 2783 ... )[1][0]['outcome'] is False 2784 ... for i in range(20) 2785 ... ) 2786 True 2787 >>> c['outcome'] = False # Fix the outcome; now policy is ignored 2788 >>> observeChallengeOutcomes(e, [c], policy='success')[0]['outcome'] 2789 False 2790 >>> c = challenge( 2791 ... skills=BestSkill('charisma'), 2792 ... level=8, 2793 ... success=[ 2794 ... challenge( 2795 ... skills=BestSkill('strength'), 2796 ... success=[effect(gain='winner')] 2797 ... ) 2798 ... ], # level defaults to 0 2799 ... failure=[ 2800 ... challenge( 2801 ... skills=BestSkill('strength'), 2802 ... failure=[effect(gain='loser')] 2803 ... ), 2804 ... effect(gain='sad') 2805 ... ] 2806 ... ) 2807 >>> r = observeChallengeOutcomes(e, [c]) # random 2808 >>> r[0]['outcome'] 2809 False 2810 >>> r[0]['failure'][0]['outcome'] # also random 2811 True 2812 >>> r[0]['success'][0]['outcome'] is None # skipped so not assigned 2813 True 2814 >>> resetChallengeOutcomes([c]) 2815 >>> r2 = observeChallengeOutcomes(e, [c]) # random 2816 >>> r[0]['outcome'] 2817 False 2818 >>> r[0]['success'][0]['outcome'] is None # untaken branch no outcome 2819 True 2820 >>> r[0]['failure'][0]['outcome'] # also random 2821 False 2822 >>> def outcomeList(consequence): 2823 ... 'Lists outcomes from each challenge attempted.' 2824 ... result = [] 2825 ... for item in consequence: 2826 ... if 'skills' in item: 2827 ... result.append(item['outcome']) 2828 ... if item['outcome'] is True: 2829 ... result.extend(outcomeList(item['success'])) 2830 ... elif item['outcome'] is False: 2831 ... result.extend(outcomeList(item['failure'])) 2832 ... else: 2833 ... pass # end here 2834 ... return result 2835 >>> def skilled(**skills): 2836 ... 'Create a clone of our Situation with specific skills.' 2837 ... r = copy.deepcopy(e) 2838 ... r.state['common']['capabilities']['skills'].update(skills) 2839 ... return r 2840 >>> resetChallengeOutcomes([c]) 2841 >>> r = observeChallengeOutcomes( # 'mostLikely' policy 2842 ... skilled(charisma=9, strength=1), 2843 ... [c], 2844 ... policy='mostLikely' 2845 ... ) 2846 >>> outcomeList(r) 2847 [True, True] 2848 >>> resetChallengeOutcomes([c]) 2849 >>> outcomeList(observeChallengeOutcomes( 2850 ... skilled(charisma=7, strength=-1), 2851 ... [c], 2852 ... policy='mostLikely' 2853 ... )) 2854 [False, False] 2855 >>> resetChallengeOutcomes([c]) 2856 >>> outcomeList(observeChallengeOutcomes( 2857 ... skilled(charisma=8, strength=-1), 2858 ... [c], 2859 ... policy='mostLikely' 2860 ... )) 2861 [True, False] 2862 >>> resetChallengeOutcomes([c]) 2863 >>> outcomeList(observeChallengeOutcomes( 2864 ... skilled(charisma=7, strength=0), 2865 ... [c], 2866 ... policy='mostLikely' 2867 ... )) 2868 [False, True] 2869 >>> resetChallengeOutcomes([c]) 2870 >>> outcomeList(observeChallengeOutcomes( 2871 ... skilled(charisma=20, strength=10), 2872 ... [c], 2873 ... policy='mostLikely' 2874 ... )) 2875 [True, True] 2876 >>> resetChallengeOutcomes([c]) 2877 >>> outcomeList(observeChallengeOutcomes( 2878 ... skilled(charisma=-10, strength=-10), 2879 ... [c], 2880 ... policy='mostLikely' 2881 ... )) 2882 [False, False] 2883 >>> resetChallengeOutcomes([c]) 2884 >>> outcomeList(observeChallengeOutcomes( 2885 ... e, 2886 ... [c], 2887 ... policy='fewestEffects' 2888 ... )) 2889 [True, False] 2890 >>> resetChallengeOutcomes([c]) 2891 >>> outcomeList(observeChallengeOutcomes( 2892 ... skilled(charisma=-100, strength=100), 2893 ... [c], 2894 ... policy='fewestEffects' 2895 ... )) # unaffected by stats 2896 [True, False] 2897 >>> resetChallengeOutcomes([c]) 2898 >>> outcomeList(observeChallengeOutcomes(e, [c], policy='success')) 2899 [True, True] 2900 >>> resetChallengeOutcomes([c]) 2901 >>> outcomeList(observeChallengeOutcomes(e, [c], policy='failure')) 2902 [False, False] 2903 >>> cc = copy.deepcopy(c) 2904 >>> resetChallengeOutcomes([cc]) 2905 >>> cc['outcome'] = False 2906 >>> outcomeList(observeChallengeOutcomes( 2907 ... skilled(charisma=10, strength=10), 2908 ... [cc], 2909 ... policy='mostLikely' 2910 ... )) # pre-observed outcome won't be changed 2911 [False, True] 2912 >>> resetChallengeOutcomes([cc]) 2913 >>> cc['outcome'] = False 2914 >>> outcomeList(observeChallengeOutcomes( 2915 ... e, 2916 ... [cc], 2917 ... policy='fewestEffects' 2918 ... )) # pre-observed outcome won't be changed 2919 [False, True] 2920 >>> cc['success'][0]['outcome'] is None # not assigned on other branch 2921 True 2922 >>> resetChallengeOutcomes([cc]) 2923 >>> r = observeChallengeOutcomes(e, [cc], policy='fewestEffects') 2924 >>> r[0] is cc # results are aliases, not clones 2925 True 2926 >>> outcomeList(r) 2927 [True, False] 2928 >>> cc['success'][0]['outcome'] # inner outcome now assigned 2929 False 2930 >>> cc['failure'][0]['outcome'] is None # now this is other branch 2931 True 2932 >>> resetChallengeOutcomes([cc]) 2933 >>> r = observeChallengeOutcomes( 2934 ... e, 2935 ... [cc], 2936 ... policy='fewestEffects', 2937 ... makeCopy=True 2938 ... ) 2939 >>> r[0] is cc # now result is a clone 2940 False 2941 >>> outcomeList(r) 2942 [True, False] 2943 >>> observedEffects(genericContextForSituation(e), r) 2944 [] 2945 >>> r[0]['outcome'] # outcome was assigned 2946 True 2947 >>> cc['outcome'] is None # only to the copy, not to the original 2948 True 2949 >>> cn = [ 2950 ... condition( 2951 ... ReqCapability('boost'), 2952 ... [ 2953 ... challenge(success=[effect(gain=('$', 1))]), 2954 ... effect(gain=('$', 2)) 2955 ... ] 2956 ... ), 2957 ... challenge(failure=[effect(gain=('$', 4))]) 2958 ... ] 2959 >>> o = observeChallengeOutcomes(e, cn, policy='fewestEffects') 2960 >>> # Without 'boost', inner challenge does not get an outcome 2961 >>> o[0]['consequence'][0]['outcome'] is None 2962 True 2963 >>> o[1]['outcome'] # avoids effect 2964 True 2965 >>> hasBoost = copy.deepcopy(e) 2966 >>> hasBoost.state['common']['capabilities']['capabilities'].add('boost') 2967 >>> resetChallengeOutcomes(cn) 2968 >>> o = observeChallengeOutcomes(hasBoost, cn, policy='fewestEffects') 2969 >>> o[0]['consequence'][0]['outcome'] # now assigned an outcome 2970 False 2971 >>> o[1]['outcome'] # avoids effect 2972 True 2973 >>> from . import core 2974 >>> e = core.emptySituation() 2975 >>> c = challenge( 2976 ... skills=BestSkill('skill'), 2977 ... level=4, # very unlikely at level 0 2978 ... success=[], 2979 ... failure=[effect(lose=('money', 10))], 2980 ... outcome=True 2981 ... ) # pre-assigned outcome 2982 >>> c['outcome'] # verify 2983 True 2984 >>> r = observeChallengeOutcomes(e, [c], policy='specified') 2985 >>> r[0]['outcome'] 2986 True 2987 >>> c['outcome'] # original outcome is unchanged 2988 True 2989 >>> c['outcome'] = False # the more likely outcome 2990 >>> r = observeChallengeOutcomes(e, [c], policy='specified') 2991 >>> r[0]['outcome'] # re-uses the new outcome 2992 False 2993 >>> c['outcome'] # outcome is unchanged 2994 False 2995 >>> c['outcome'] = True # change it back 2996 >>> r = observeChallengeOutcomes(e, [c], policy='specified') 2997 >>> r[0]['outcome'] # re-use the outcome again 2998 True 2999 >>> c['outcome'] # outcome is unchanged 3000 True 3001 >>> c['outcome'] = None # set it to no info; will crash 3002 >>> r = observeChallengeOutcomes(e, [c], policy='specified') 3003 Traceback (most recent call last): 3004 ... 3005 ValueError... 3006 >>> warnings.filterwarnings('default') 3007 >>> c['outcome'] is None # same after crash 3008 True 3009 >>> r = observeChallengeOutcomes( 3010 ... e, 3011 ... [c], 3012 ... policy='specified', 3013 ... knownOutcomes=[True] 3014 ... ) 3015 >>> r[0]['outcome'] # picked up known outcome 3016 True 3017 >>> c['outcome'] # outcome is changed 3018 True 3019 >>> resetChallengeOutcomes([c]) 3020 >>> c['outcome'] is None # has been reset 3021 True 3022 >>> r = observeChallengeOutcomes( 3023 ... e, 3024 ... [c], 3025 ... policy='specified', 3026 ... knownOutcomes=[True] 3027 ... ) 3028 >>> c['outcome'] # from known outcomes 3029 True 3030 >>> ko = [False] 3031 >>> r = observeChallengeOutcomes( 3032 ... e, 3033 ... [c], 3034 ... policy='specified', 3035 ... knownOutcomes=ko 3036 ... ) 3037 >>> c['outcome'] # from known outcomes 3038 False 3039 >>> ko # known outcomes list gets used up 3040 [] 3041 >>> ko = [False, False] 3042 >>> r = observeChallengeOutcomes( 3043 ... e, 3044 ... [c], 3045 ... policy='specified', 3046 ... knownOutcomes=ko 3047 ... ) # too many outcomes is an error 3048 >>> ko 3049 [False] 3050 """ 3051 if not isinstance(consequence, list): 3052 raise TypeError( 3053 f"Invalid consequence: must be a list." 3054 f"\nGot: {repr(consequence)}" 3055 ) 3056 3057 if knownOutcomes is None: 3058 knownOutcomes = [] 3059 3060 if makeCopy: 3061 result = copy.deepcopy(consequence) 3062 else: 3063 result = consequence 3064 3065 for item in result: 3066 if not isinstance(item, dict): 3067 raise TypeError( 3068 f"Invalid consequence: items in the list must be" 3069 f" Effects, Challenges, or Conditions." 3070 f"\nGot item: {repr(item)}" 3071 ) 3072 if 'skills' in item: # must be a Challenge 3073 item = cast(Challenge, item) 3074 if len(knownOutcomes) > 0: 3075 item['outcome'] = knownOutcomes.pop(0) 3076 if item['outcome'] is not None: 3077 if item['outcome']: 3078 observeChallengeOutcomes( 3079 context, 3080 item['success'], 3081 location=location, 3082 policy=policy, 3083 knownOutcomes=knownOutcomes, 3084 makeCopy=False 3085 ) 3086 else: 3087 observeChallengeOutcomes( 3088 context, 3089 item['failure'], 3090 location=location, 3091 policy=policy, 3092 knownOutcomes=knownOutcomes, 3093 makeCopy=False 3094 ) 3095 else: # need to assign an outcome 3096 if policy == 'specified': 3097 raise ValueError( 3098 f"Challenge has unspecified outcome so the" 3099 f" 'specified' policy cannot be used when" 3100 f" observing its outcomes:" 3101 f"\n{item}" 3102 ) 3103 level = item['skills'].effectiveLevel(context) 3104 against = item['level'] 3105 if level < against: 3106 p = 1 / (2 ** (1 + against - level)) 3107 else: 3108 p = 1 - (1 / (2 ** (1 + level - against))) 3109 if policy == 'random': 3110 if random.random() < p: # success 3111 item['outcome'] = True 3112 else: 3113 item['outcome'] = False 3114 elif policy == 'mostLikely': 3115 if p >= 0.5: 3116 item['outcome'] = True 3117 else: 3118 item['outcome'] = False 3119 elif policy == 'fewestEffects': 3120 # Resolve copies so we don't affect original 3121 subSuccess = observeChallengeOutcomes( 3122 context, 3123 item['success'], 3124 location=location, 3125 policy=policy, 3126 knownOutcomes=knownOutcomes[:], 3127 makeCopy=True 3128 ) 3129 subFailure = observeChallengeOutcomes( 3130 context, 3131 item['failure'], 3132 location=location, 3133 policy=policy, 3134 knownOutcomes=knownOutcomes[:], 3135 makeCopy=True 3136 ) 3137 if ( 3138 len(observedEffects(context, subSuccess)) 3139 <= len(observedEffects(context, subFailure)) 3140 ): 3141 item['outcome'] = True 3142 else: 3143 item['outcome'] = False 3144 elif policy == 'success': 3145 item['outcome'] = True 3146 elif policy == 'failure': 3147 item['outcome'] = False 3148 3149 # Figure out outcomes for sub-consequence if we don't 3150 # already have them... 3151 if item['outcome'] not in (True, False): 3152 raise TypeError( 3153 f"Challenge has invalid outcome type" 3154 f" {type(item['outcome'])} after observation." 3155 f"\nOutcome value: {repr(item['outcome'])}" 3156 ) 3157 3158 if item['outcome']: 3159 observeChallengeOutcomes( 3160 context, 3161 item['success'], 3162 location=location, 3163 policy=policy, 3164 knownOutcomes=knownOutcomes, 3165 makeCopy=False 3166 ) 3167 else: 3168 observeChallengeOutcomes( 3169 context, 3170 item['failure'], 3171 location=location, 3172 policy=policy, 3173 knownOutcomes=knownOutcomes, 3174 makeCopy=False 3175 ) 3176 3177 elif 'value' in item: 3178 continue # Effects do not need success/failure assigned 3179 3180 elif 'condition' in item: # a Condition 3181 if item['condition'].satisfied(context): 3182 observeChallengeOutcomes( 3183 context, 3184 item['consequence'], 3185 location=location, 3186 policy=policy, 3187 knownOutcomes=knownOutcomes, 3188 makeCopy=False 3189 ) 3190 else: 3191 observeChallengeOutcomes( 3192 context, 3193 item['alternative'], 3194 location=location, 3195 policy=policy, 3196 knownOutcomes=knownOutcomes, 3197 makeCopy=False 3198 ) 3199 3200 else: # bad dict 3201 raise TypeError( 3202 f"Invalid consequence: items in the list must be" 3203 f" Effects, Challenges, or Conditions (got a dictionary" 3204 f" without 'skills', 'value', or 'condition' keys)." 3205 f"\nGot item: {repr(item)}" 3206 ) 3207 3208 # Return copy or original, now with options selected 3209 return result 3210 3211 3212class UnassignedOutcomeWarning(Warning): 3213 """ 3214 A warning issued when asking for observed effects of a `Consequence` 3215 whose `Challenge` outcomes have not been fully assigned. 3216 """ 3217 pass 3218 3219 3220def observedEffects( 3221 context: RequirementContext, 3222 observed: Consequence, 3223 skipWarning=False, 3224 baseIndex: int = 0 3225) -> List[int]: 3226 """ 3227 Given a `Situation` and a `Consequence` whose challenges have 3228 outcomes assigned, returns a tuple containing a list of the 3229 depth-first-indices of each effect to apply. You can use 3230 `consequencePart` to extract the actual `Effect` values from the 3231 consequence based on their indices. 3232 3233 Only effects that actually apply are included, based on the observed 3234 outcomes as well as which `Condition`(s) are met, although charges 3235 and delays for the effects are not taken into account. 3236 3237 `baseIndex` can be set to something other than 0 to start indexing 3238 at that value. Issues an `UnassignedOutcomeWarning` if it encounters 3239 a challenge whose outcome has not been observed, unless 3240 `skipWarning` is set to `True`. In that case, no effects are listed 3241 for outcomes of that challenge. 3242 3243 For example: 3244 3245 >>> from . import core 3246 >>> warnings.filterwarnings('error') 3247 >>> e = core.emptySituation() 3248 >>> def skilled(**skills): 3249 ... 'Create a clone of our FocalContext with specific skills.' 3250 ... r = copy.deepcopy(e) 3251 ... r.state['common']['capabilities']['skills'].update(skills) 3252 ... return r 3253 >>> c = challenge( # index 1 in [c] (index 0 is the outer list) 3254 ... skills=BestSkill('charisma'), 3255 ... level=8, 3256 ... success=[ 3257 ... effect(gain='happy'), # index 3 in [c] 3258 ... challenge( 3259 ... skills=BestSkill('strength'), 3260 ... success=[effect(gain='winner')] # index 6 in [c] 3261 ... # failure is index 7 3262 ... ) # level defaults to 0 3263 ... ], 3264 ... failure=[ 3265 ... challenge( 3266 ... skills=BestSkill('strength'), 3267 ... # success is index 10 3268 ... failure=[effect(gain='loser')] # index 12 in [c] 3269 ... ), 3270 ... effect(gain='sad') # index 13 in [c] 3271 ... ] 3272 ... ) 3273 >>> import pytest 3274 >>> with pytest.warns(UnassignedOutcomeWarning): 3275 ... observedEffects(e, [c]) 3276 [] 3277 >>> with pytest.warns(UnassignedOutcomeWarning): 3278 ... observedEffects(e, [c, c]) 3279 [] 3280 >>> observedEffects(e, [c, c], skipWarning=True) 3281 [] 3282 >>> c['outcome'] = 'invalid value' # must be True, False, or None 3283 >>> observedEffects(e, [c]) 3284 Traceback (most recent call last): 3285 ... 3286 TypeError... 3287 >>> yesYes = skilled(charisma=10, strength=5) 3288 >>> yesNo = skilled(charisma=10, strength=-1) 3289 >>> noYes = skilled(charisma=4, strength=5) 3290 >>> noNo = skilled(charisma=4, strength=-1) 3291 >>> resetChallengeOutcomes([c]) 3292 >>> observedEffects( 3293 ... yesYes, 3294 ... observeChallengeOutcomes(yesYes, [c], policy='mostLikely') 3295 ... ) 3296 [3, 6] 3297 >>> resetChallengeOutcomes([c]) 3298 >>> observedEffects( 3299 ... yesNo, 3300 ... observeChallengeOutcomes(yesNo, [c], policy='mostLikely') 3301 ... ) 3302 [3] 3303 >>> resetChallengeOutcomes([c]) 3304 >>> observedEffects( 3305 ... noYes, 3306 ... observeChallengeOutcomes(noYes, [c], policy='mostLikely') 3307 ... ) 3308 [13] 3309 >>> resetChallengeOutcomes([c]) 3310 >>> observedEffects( 3311 ... noNo, 3312 ... observeChallengeOutcomes(noNo, [c], policy='mostLikely') 3313 ... ) 3314 [12, 13] 3315 >>> warnings.filterwarnings('default') 3316 >>> # known outcomes override policy & pre-specified outcomes 3317 >>> observedEffects( 3318 ... noNo, 3319 ... observeChallengeOutcomes( 3320 ... noNo, 3321 ... [c], 3322 ... policy='mostLikely', 3323 ... knownOutcomes=[True, True]) 3324 ... ) 3325 [3, 6] 3326 >>> observedEffects( 3327 ... yesYes, 3328 ... observeChallengeOutcomes( 3329 ... yesYes, 3330 ... [c], 3331 ... policy='mostLikely', 3332 ... knownOutcomes=[False, False]) 3333 ... ) 3334 [12, 13] 3335 >>> resetChallengeOutcomes([c]) 3336 >>> observedEffects( 3337 ... yesYes, 3338 ... observeChallengeOutcomes( 3339 ... yesYes, 3340 ... [c], 3341 ... policy='mostLikely', 3342 ... knownOutcomes=[False, False]) 3343 ... ) 3344 [12, 13] 3345 """ 3346 result: List[int] = [] 3347 totalCount: int = baseIndex + 1 # +1 for the outer list 3348 if not isinstance(observed, list): 3349 raise TypeError( 3350 f"Invalid consequence: must be a list." 3351 f"\nGot: {repr(observed)}" 3352 ) 3353 for item in observed: 3354 if not isinstance(item, dict): 3355 raise TypeError( 3356 f"Invalid consequence: items in the list must be" 3357 f" Effects, Challenges, or Conditions." 3358 f"\nGot item: {repr(item)}" 3359 ) 3360 3361 if 'skills' in item: # must be a Challenge 3362 item = cast(Challenge, item) 3363 succeeded = item['outcome'] 3364 useCh: Optional[Literal['success', 'failure']] 3365 if succeeded is True: 3366 useCh = 'success' 3367 elif succeeded is False: 3368 useCh = 'failure' 3369 else: 3370 useCh = None 3371 level = item["level"] 3372 if succeeded is not None: 3373 raise TypeError( 3374 f"Invalid outcome for level-{level} challenge:" 3375 f" should be True, False, or None, but got:" 3376 f" {repr(succeeded)}" 3377 ) 3378 else: 3379 if not skipWarning: 3380 warnings.warn( 3381 ( 3382 f"A level-{level} challenge in the" 3383 f" consequence being observed has no" 3384 f" observed outcome; no effects from" 3385 f" either success or failure branches" 3386 f" will be included. Use" 3387 f" observeChallengeOutcomes to fill in" 3388 f" unobserved outcomes." 3389 ), 3390 UnassignedOutcomeWarning 3391 ) 3392 3393 if useCh is not None: 3394 skipped = 0 3395 if useCh == 'failure': 3396 skipped = countParts(item['success']) 3397 subEffects = observedEffects( 3398 context, 3399 item[useCh], 3400 skipWarning=skipWarning, 3401 baseIndex=totalCount + skipped + 1 3402 ) 3403 result.extend(subEffects) 3404 3405 # TODO: Go back to returning tuples but fix counts to include 3406 # skipped stuff; this is horribly inefficient :( 3407 totalCount += countParts(item) 3408 3409 elif 'value' in item: # an effect, not a challenge 3410 item = cast(Effect, item) 3411 result.append(totalCount) 3412 totalCount += 1 3413 3414 elif 'condition' in item: # a Condition 3415 item = cast(Condition, item) 3416 useCo: Literal['consequence', 'alternative'] 3417 if item['condition'].satisfied(context): 3418 useCo = 'consequence' 3419 skipped = 0 3420 else: 3421 useCo = 'alternative' 3422 skipped = countParts(item['consequence']) 3423 subEffects = observedEffects( 3424 context, 3425 item[useCo], 3426 skipWarning=skipWarning, 3427 baseIndex=totalCount + skipped + 1 3428 ) 3429 result.extend(subEffects) 3430 totalCount += countParts(item) 3431 3432 else: # bad dict 3433 raise TypeError( 3434 f"Invalid consequence: items in the list must be" 3435 f" Effects, Challenges, or Conditions (got a dictionary" 3436 f" without 'skills', 'value', or 'condition' keys)." 3437 f"\nGot item: {repr(item)}" 3438 ) 3439 3440 return result 3441 3442 3443#--------------# 3444# Requirements # 3445#--------------# 3446 3447MECHANISM_STATE_SUFFIX_RE = re.compile('(.*)(?<!:):([^:]+)$') 3448""" 3449Regular expression for finding mechanism state suffixes. These are a 3450single colon followed by any amount of non-colon characters until the 3451end of a token. 3452""" 3453 3454 3455class Requirement: 3456 """ 3457 Represents a precondition for traversing an edge or taking an action. 3458 This can be any boolean expression over `Capability`, mechanism (see 3459 `MechanismName`), and/or `Token` states that must obtain, with 3460 numerical values for the number of tokens required, and specific 3461 mechanism states or active capabilities necessary. For example, if 3462 the player needs either the wall-break capability or the wall-jump 3463 capability plus a balloon token, or for the switch mechanism to be 3464 on, you could represent that using: 3465 3466 ReqAny( 3467 ReqCapability('wall-break'), 3468 ReqAll( 3469 ReqCapability('wall-jump'), 3470 ReqTokens('balloon', 1) 3471 ), 3472 ReqMechanism('switch', 'on') 3473 ) 3474 3475 The subclasses define concrete requirements. 3476 3477 Note that mechanism names are searched for using `lookupMechanism`, 3478 starting from the `DecisionID`s of the decisions on either end of 3479 the transition where a requirement is being checked. You may need to 3480 rename mechanisms to avoid a `MechanismCollisionError`if decisions 3481 on either end of a transition use the same mechanism name. 3482 """ 3483 def satisfied( 3484 self, 3485 context: RequirementContext, 3486 dontRecurse: Optional[ 3487 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3488 ] = None 3489 ) -> bool: 3490 """ 3491 This will return `True` if the requirement is satisfied in the 3492 given `RequirementContext`, resolving mechanisms from the 3493 context's set of decisions and graph, and respecting the 3494 context's equivalences. It returns `False` otherwise. 3495 3496 The `dontRecurse` set should be unspecified to start, and will 3497 be used to avoid infinite recursion in cases of circular 3498 equivalences (requirements are not considered satisfied by 3499 equivalence loops). 3500 3501 TODO: Examples 3502 """ 3503 raise NotImplementedError( 3504 "Requirement is an abstract class and cannot be" 3505 " used directly." 3506 ) 3507 3508 def __eq__(self, other: Any) -> bool: 3509 raise NotImplementedError( 3510 "Requirement is an abstract class and cannot be compared." 3511 ) 3512 3513 def __hash__(self) -> int: 3514 raise NotImplementedError( 3515 "Requirement is an abstract class and cannot be hashed." 3516 ) 3517 3518 def walk(self) -> Generator['Requirement', None, None]: 3519 """ 3520 Yields every part of the requirement in depth-first traversal 3521 order. 3522 """ 3523 raise NotImplementedError( 3524 "Requirement is an abstract class and cannot be walked." 3525 ) 3526 3527 def asEffectList(self) -> List[Effect]: 3528 """ 3529 Transforms this `Requirement` into a list of `Effect` 3530 objects that gain the `Capability`, set the `Token` amounts, and 3531 set the `Mechanism` states mentioned by the requirement. The 3532 requirement must be either a `ReqTokens`, a `ReqCapability`, a 3533 `ReqMechanism`, or a `ReqAll` which includes nothing besides 3534 those types as sub-requirements. The token and capability 3535 requirements at the leaves of the tree will be collected into a 3536 list for the result (note that whether `ReqAny` or `ReqAll` is 3537 used is ignored, all of the tokens/capabilities/mechanisms 3538 mentioned are listed). For each `Capability` requirement a 3539 'gain' effect for that capability will be included. For each 3540 `Mechanism` or `Token` requirement, a 'set' effect for that 3541 mechanism state or token count will be included. Note that if 3542 the requirement has contradictory clauses (e.g., two different 3543 mechanism states) multiple effects which cancel each other out 3544 will be included. Also note that setting token amounts may end 3545 up decreasing them unnecessarily. 3546 3547 Raises a `TypeError` if this requirement is not suitable for 3548 transformation into an effect list. 3549 """ 3550 raise NotImplementedError("Requirement is an abstract class.") 3551 3552 def flatten(self) -> 'Requirement': 3553 """ 3554 Returns a simplified version of this requirement that merges 3555 multiple redundant layers of `ReqAny`/`ReqAll` into single 3556 `ReqAny`/`ReqAll` structures, including recursively. May return 3557 the original requirement if there's no simplification to be done. 3558 3559 Default implementation just returns `self`. 3560 """ 3561 return self 3562 3563 def unparse(self) -> str: 3564 """ 3565 Returns a string which would convert back into this `Requirement` 3566 object if you fed it to `parsing.ParseFormat.parseRequirement`. 3567 3568 TODO: Move this over into `parsing`? 3569 3570 Examples: 3571 3572 >>> r = ReqAny([ 3573 ... ReqCapability('capability'), 3574 ... ReqTokens('token', 3), 3575 ... ReqMechanism('mechanism', 'state') 3576 ... ]) 3577 >>> rep = r.unparse() 3578 >>> rep 3579 '(capability|token*3|mechanism:state)' 3580 >>> from . import parsing 3581 >>> pf = parsing.ParseFormat() 3582 >>> back = pf.parseRequirement(rep) 3583 >>> back == r 3584 True 3585 >>> ReqNot(ReqNothing()).unparse() 3586 '!(O)' 3587 >>> ReqImpossible().unparse() 3588 'X' 3589 >>> r = ReqAny([ReqCapability('A'), ReqCapability('B'), 3590 ... ReqCapability('C')]) 3591 >>> rep = r.unparse() 3592 >>> rep 3593 '(A|B|C)' 3594 >>> back = pf.parseRequirement(rep) 3595 >>> back == r 3596 True 3597 """ 3598 raise NotImplementedError("Requirement is an abstract class.") 3599 3600 3601class ReqAny(Requirement): 3602 """ 3603 A disjunction requirement satisfied when any one of its 3604 sub-requirements is satisfied. 3605 """ 3606 def __init__(self, subs: Iterable[Requirement]) -> None: 3607 self.subs = list(subs) 3608 3609 def __hash__(self) -> int: 3610 result = 179843 3611 for sub in self.subs: 3612 result = 31 * (result + hash(sub)) 3613 return result 3614 3615 def __eq__(self, other: Any) -> bool: 3616 return isinstance(other, ReqAny) and other.subs == self.subs 3617 3618 def __repr__(self): 3619 return "ReqAny(" + repr(self.subs) + ")" 3620 3621 def satisfied( 3622 self, 3623 context: RequirementContext, 3624 dontRecurse: Optional[ 3625 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3626 ] = None 3627 ) -> bool: 3628 """ 3629 True as long as any one of the sub-requirements is satisfied. 3630 """ 3631 return any( 3632 sub.satisfied(context, dontRecurse) 3633 for sub in self.subs 3634 ) 3635 3636 def walk(self) -> Generator[Requirement, None, None]: 3637 yield self 3638 for sub in self.subs: 3639 yield from sub.walk() 3640 3641 def asEffectList(self) -> List[Effect]: 3642 """ 3643 Raises a `TypeError` since disjunctions don't have a translation 3644 into a simple list of effects to satisfy them. 3645 """ 3646 raise TypeError( 3647 "Cannot convert ReqAny into an effect list:" 3648 " contradictory token or mechanism requirements on" 3649 " different branches are not easy to synthesize." 3650 ) 3651 3652 def flatten(self) -> Requirement: 3653 """ 3654 Flattens this requirement by merging any sub-requirements which 3655 are also `ReqAny` instances into this one. 3656 """ 3657 merged = [] 3658 for sub in self.subs: 3659 flat = sub.flatten() 3660 if isinstance(flat, ReqAny): 3661 merged.extend(flat.subs) 3662 else: 3663 merged.append(flat) 3664 3665 return ReqAny(merged) 3666 3667 def unparse(self) -> str: 3668 return '(' + '|'.join(sub.unparse() for sub in self.subs) + ')' 3669 3670 3671class ReqAll(Requirement): 3672 """ 3673 A conjunction requirement satisfied when all of its sub-requirements 3674 are satisfied. 3675 """ 3676 def __init__(self, subs: Iterable[Requirement]) -> None: 3677 self.subs = list(subs) 3678 3679 def __hash__(self) -> int: 3680 result = 182971 3681 for sub in self.subs: 3682 result = 17 * (result + hash(sub)) 3683 return result 3684 3685 def __eq__(self, other: Any) -> bool: 3686 return isinstance(other, ReqAll) and other.subs == self.subs 3687 3688 def __repr__(self): 3689 return "ReqAll(" + repr(self.subs) + ")" 3690 3691 def satisfied( 3692 self, 3693 context: RequirementContext, 3694 dontRecurse: Optional[ 3695 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3696 ] = None 3697 ) -> bool: 3698 """ 3699 True as long as all of the sub-requirements are satisfied. 3700 """ 3701 return all( 3702 sub.satisfied(context, dontRecurse) 3703 for sub in self.subs 3704 ) 3705 3706 def walk(self) -> Generator[Requirement, None, None]: 3707 yield self 3708 for sub in self.subs: 3709 yield from sub.walk() 3710 3711 def asEffectList(self) -> List[Effect]: 3712 """ 3713 Returns a gain list composed by adding together the gain lists 3714 for each sub-requirement. Note that some types of requirement 3715 will raise a `TypeError` during this process if they appear as a 3716 sub-requirement. 3717 """ 3718 result = [] 3719 for sub in self.subs: 3720 result += sub.asEffectList() 3721 3722 return result 3723 3724 def flatten(self) -> Requirement: 3725 """ 3726 Flattens this requirement by merging any sub-requirements which 3727 are also `ReqAll` instances into this one. 3728 """ 3729 merged = [] 3730 for sub in self.subs: 3731 flat = sub.flatten() 3732 if isinstance(flat, ReqAll): 3733 merged.extend(flat.subs) 3734 else: 3735 merged.append(flat) 3736 3737 return ReqAll(merged) 3738 3739 def unparse(self) -> str: 3740 return '(' + '&'.join(sub.unparse() for sub in self.subs) + ')' 3741 3742 3743class ReqNot(Requirement): 3744 """ 3745 A negation requirement satisfied when its sub-requirement is NOT 3746 satisfied. 3747 """ 3748 def __init__(self, sub: Requirement) -> None: 3749 self.sub = sub 3750 3751 def __hash__(self) -> int: 3752 return 17293 + hash(self.sub) 3753 3754 def __eq__(self, other: Any) -> bool: 3755 return isinstance(other, ReqNot) and other.sub == self.sub 3756 3757 def __repr__(self): 3758 return "ReqNot(" + repr(self.sub) + ")" 3759 3760 def satisfied( 3761 self, 3762 context: RequirementContext, 3763 dontRecurse: Optional[ 3764 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3765 ] = None 3766 ) -> bool: 3767 """ 3768 True as long as the sub-requirement is not satisfied. 3769 """ 3770 return not self.sub.satisfied(context, dontRecurse) 3771 3772 def walk(self) -> Generator[Requirement, None, None]: 3773 yield self 3774 yield self.sub 3775 3776 def asEffectList(self) -> List[Effect]: 3777 """ 3778 Raises a `TypeError` since understanding a `ReqNot` in terms of 3779 capabilities/tokens to be gained is not straightforward, and would 3780 need to be done relative to a game state in any case. 3781 """ 3782 raise TypeError( 3783 "Cannot convert ReqNot into an effect list:" 3784 " capabilities or tokens would have to be lost, not gained to" 3785 " satisfy this requirement." 3786 ) 3787 3788 def flatten(self) -> Requirement: 3789 return ReqNot(self.sub.flatten()) 3790 3791 def unparse(self) -> str: 3792 return '!(' + self.sub.unparse() + ')' 3793 3794 3795class ReqCapability(Requirement): 3796 """ 3797 A capability requirement is satisfied if the specified capability is 3798 possessed by the player according to the given state. 3799 """ 3800 def __init__(self, capability: Capability) -> None: 3801 self.capability = capability 3802 3803 def __hash__(self) -> int: 3804 return 47923 + hash(self.capability) 3805 3806 def __eq__(self, other: Any) -> bool: 3807 return ( 3808 isinstance(other, ReqCapability) 3809 and other.capability == self.capability 3810 ) 3811 3812 def __repr__(self): 3813 return "ReqCapability(" + repr(self.capability) + ")" 3814 3815 def satisfied( 3816 self, 3817 context: RequirementContext, 3818 dontRecurse: Optional[ 3819 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3820 ] = None 3821 ) -> bool: 3822 return hasCapabilityOrEquivalent( 3823 self.capability, 3824 context, 3825 dontRecurse 3826 ) 3827 3828 def walk(self) -> Generator[Requirement, None, None]: 3829 yield self 3830 3831 def asEffectList(self) -> List[Effect]: 3832 """ 3833 Returns a list containing a single 'gain' effect which grants 3834 the required capability. 3835 """ 3836 return [effect(gain=self.capability)] 3837 3838 def unparse(self) -> str: 3839 return self.capability 3840 3841 3842class ReqTokens(Requirement): 3843 """ 3844 A token requirement satisfied if the player possesses at least a 3845 certain number of a given type of token. 3846 3847 Note that checking the satisfaction of individual doors in a specific 3848 state is not enough to guarantee they're jointly traversable, since 3849 if a series of doors requires the same kind of token and they use up 3850 those tokens, further logic is needed to understand that as the 3851 tokens get used up, their requirements may no longer be satisfied. 3852 3853 Also note that a requirement for tokens does NOT mean that tokens 3854 will be subtracted when traversing the door (you can have re-usable 3855 tokens after all). To implement a token cost, use both a requirement 3856 and a 'lose' effect. 3857 """ 3858 def __init__(self, tokenType: Token, cost: TokenCount) -> None: 3859 self.tokenType = tokenType 3860 self.cost = cost 3861 3862 def __hash__(self) -> int: 3863 return (17 * hash(self.tokenType)) + (11 * self.cost) 3864 3865 def __eq__(self, other: Any) -> bool: 3866 return ( 3867 isinstance(other, ReqTokens) 3868 and other.tokenType == self.tokenType 3869 and other.cost == self.cost 3870 ) 3871 3872 def __repr__(self): 3873 return f"ReqTokens({repr(self.tokenType)}, {repr(self.cost)})" 3874 3875 def satisfied( 3876 self, 3877 context: RequirementContext, 3878 dontRecurse: Optional[ 3879 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3880 ] = None 3881 ) -> bool: 3882 return combinedTokenCount(context.state, self.tokenType) >= self.cost 3883 3884 def walk(self) -> Generator[Requirement, None, None]: 3885 yield self 3886 3887 def asEffectList(self) -> List[Effect]: 3888 """ 3889 Returns a list containing a single 'set' effect which sets the 3890 required tokens (note that this may unnecessarily subtract 3891 tokens if the state had more than enough tokens beforehand). 3892 """ 3893 return [effect(set=(self.tokenType, self.cost))] 3894 3895 def unparse(self) -> str: 3896 return f'{self.tokenType}*{self.cost}' 3897 3898 3899class ReqMechanism(Requirement): 3900 """ 3901 A mechanism requirement satisfied if the specified mechanism is in 3902 the specified state. The mechanism is specified by name and a lookup 3903 on that name will be performed when assessing the requirement, based 3904 on the specific position at which the requirement applies. However, 3905 if a `where` value is supplied, the lookup on the mechanism name will 3906 always start from that decision, regardless of where the requirement 3907 is being evaluated. 3908 """ 3909 def __init__( 3910 self, 3911 mechanism: AnyMechanismSpecifier, 3912 state: MechanismState, 3913 ) -> None: 3914 self.mechanism = mechanism 3915 self.reqState = state 3916 3917 # Normalize mechanism specifiers without any position information 3918 if isinstance(mechanism, tuple): 3919 if len(mechanism) != 4: 3920 raise ValueError( 3921 f"Mechanism specifier must have 4 parts if it's a" 3922 f" tuple. (Got: {mechanism})." 3923 ) 3924 elif all(x is None for x in mechanism[:3]): 3925 self.mechanism = mechanism[3] 3926 3927 def __hash__(self) -> int: 3928 return ( 3929 (11 * hash(self.mechanism)) 3930 + (31 * hash(self.reqState)) 3931 ) 3932 3933 def __eq__(self, other: Any) -> bool: 3934 return ( 3935 isinstance(other, ReqMechanism) 3936 and other.mechanism == self.mechanism 3937 and other.reqState == self.reqState 3938 ) 3939 3940 def __repr__(self): 3941 mRep = repr(self.mechanism) 3942 sRep = repr(self.reqState) 3943 return f"ReqMechanism({mRep}, {sRep})" 3944 3945 def satisfied( 3946 self, 3947 context: RequirementContext, 3948 dontRecurse: Optional[ 3949 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3950 ] = None 3951 ) -> bool: 3952 return mechanismInStateOrEquivalent( 3953 self.mechanism, 3954 self.reqState, 3955 context, 3956 dontRecurse 3957 ) 3958 3959 def walk(self) -> Generator[Requirement, None, None]: 3960 yield self 3961 3962 def asEffectList(self) -> List[Effect]: 3963 """ 3964 Returns a list containing a single 'set' effect which sets the 3965 required mechanism to the required state. 3966 """ 3967 return [effect(set=(self.mechanism, self.reqState))] 3968 3969 def unparse(self) -> str: 3970 if isinstance(self.mechanism, (MechanismID, MechanismName)): 3971 return f'{self.mechanism}:{self.reqState}' 3972 else: # Must be a MechanismSpecifier 3973 # TODO: This elsewhere! 3974 domain, zone, decision, mechanism = self.mechanism 3975 mspec = '' 3976 if domain is not None: 3977 mspec += domain + '//' 3978 if zone is not None: 3979 mspec += zone + '::' 3980 if decision is not None: 3981 mspec += decision + '::' 3982 mspec += mechanism 3983 return f'{mspec}:{self.reqState}' 3984 3985 3986class ReqLevel(Requirement): 3987 """ 3988 A tag requirement satisfied if a specific skill is at or above the 3989 specified level. 3990 """ 3991 def __init__( 3992 self, 3993 skill: Skill, 3994 minLevel: Level, 3995 ) -> None: 3996 self.skill = skill 3997 self.minLevel = minLevel 3998 3999 def __hash__(self) -> int: 4000 return ( 4001 (79 * hash(self.skill)) 4002 + (55 * hash(self.minLevel)) 4003 ) 4004 4005 def __eq__(self, other: Any) -> bool: 4006 return ( 4007 isinstance(other, ReqLevel) 4008 and other.skill == self.skill 4009 and other.minLevel == self.minLevel 4010 ) 4011 4012 def __repr__(self): 4013 sRep = repr(self.skill) 4014 lRep = repr(self.minLevel) 4015 return f"ReqLevel({sRep}, {lRep})" 4016 4017 def satisfied( 4018 self, 4019 context: RequirementContext, 4020 dontRecurse: Optional[ 4021 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 4022 ] = None 4023 ) -> bool: 4024 return getSkillLevel(context.state, self.skill) >= self.minLevel 4025 4026 def walk(self) -> Generator[Requirement, None, None]: 4027 yield self 4028 4029 def asEffectList(self) -> List[Effect]: 4030 """ 4031 Returns a list containing a single 'set' effect which sets the 4032 required skill to the minimum required level. Note that this may 4033 reduce a skill level that was more than sufficient to meet the 4034 requirement. 4035 """ 4036 return [effect(set=("skill", self.skill, self.minLevel))] 4037 4038 def unparse(self) -> str: 4039 return f'{self.skill}^{self.minLevel}' 4040 4041 4042class ReqTag(Requirement): 4043 """ 4044 A tag requirement satisfied if there is any active decision that has 4045 the specified value for the given tag (default value is 1 for tags 4046 where a value wasn't specified). Zone tags also satisfy the 4047 requirement if they're applied to zones that include active 4048 decisions. 4049 """ 4050 def __init__( 4051 self, 4052 tag: "Tag", 4053 value: "TagValue", 4054 ) -> None: 4055 self.tag = tag 4056 self.value = value 4057 4058 def __hash__(self) -> int: 4059 return ( 4060 (71 * hash(self.tag)) 4061 + (43 * hash(self.value)) 4062 ) 4063 4064 def __eq__(self, other: Any) -> bool: 4065 return ( 4066 isinstance(other, ReqTag) 4067 and other.tag == self.tag 4068 and other.value == self.value 4069 ) 4070 4071 def __repr__(self): 4072 tRep = repr(self.tag) 4073 vRep = repr(self.value) 4074 return f"ReqTag({tRep}, {vRep})" 4075 4076 def satisfied( 4077 self, 4078 context: RequirementContext, 4079 dontRecurse: Optional[ 4080 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 4081 ] = None 4082 ) -> bool: 4083 active = combinedDecisionSet(context.state) 4084 graph = context.graph 4085 zones = set() 4086 for decision in active: 4087 tags = graph.decisionTags(decision) 4088 if self.tag in tags and tags[self.tag] == self.value: 4089 return True 4090 zones |= graph.zoneAncestors(decision) 4091 for zone in zones: 4092 zTags = graph.zoneTags(zone) 4093 if self.tag in zTags and zTags[self.tag] == self.value: 4094 return True 4095 4096 return False 4097 4098 def walk(self) -> Generator[Requirement, None, None]: 4099 yield self 4100 4101 def asEffectList(self) -> List[Effect]: 4102 """ 4103 Returns a list containing a single 'set' effect which sets the 4104 required mechanism to the required state. 4105 """ 4106 raise TypeError( 4107 "Cannot convert ReqTag into an effect list:" 4108 " effects cannot apply/remove/change tags" 4109 ) 4110 4111 def unparse(self) -> str: 4112 return f'{self.tag}~{self.value!r}' 4113 4114 4115class ReqNothing(Requirement): 4116 """ 4117 A requirement representing that something doesn't actually have a 4118 requirement. This requirement is always satisfied. 4119 """ 4120 def __hash__(self) -> int: 4121 return 127942 4122 4123 def __eq__(self, other: Any) -> bool: 4124 return isinstance(other, ReqNothing) 4125 4126 def __repr__(self): 4127 return "ReqNothing()" 4128 4129 def satisfied( 4130 self, 4131 context: RequirementContext, 4132 dontRecurse: Optional[ 4133 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 4134 ] = None 4135 ) -> bool: 4136 return True 4137 4138 def walk(self) -> Generator[Requirement, None, None]: 4139 yield self 4140 4141 def asEffectList(self) -> List[Effect]: 4142 """ 4143 Returns an empty list, since nothing is required. 4144 """ 4145 return [] 4146 4147 def unparse(self) -> str: 4148 return 'O' 4149 4150 4151class ReqImpossible(Requirement): 4152 """ 4153 A requirement representing that something is impossible. This 4154 requirement is never satisfied. 4155 """ 4156 def __hash__(self) -> int: 4157 return 478743 4158 4159 def __eq__(self, other: Any) -> bool: 4160 return isinstance(other, ReqImpossible) 4161 4162 def __repr__(self): 4163 return "ReqImpossible()" 4164 4165 def satisfied( 4166 self, 4167 context: RequirementContext, 4168 dontRecurse: Optional[ 4169 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 4170 ] = None 4171 ) -> bool: 4172 return False 4173 4174 def walk(self) -> Generator[Requirement, None, None]: 4175 yield self 4176 4177 def asEffectList(self) -> List[Effect]: 4178 """ 4179 Raises a `TypeError` since a `ReqImpossible` cannot be converted 4180 into an effect which would allow the transition to be taken. 4181 """ 4182 raise TypeError( 4183 "Cannot convert ReqImpossible into an effect list:" 4184 " there are no powers or tokens which could be gained to" 4185 " satisfy this requirement." 4186 ) 4187 4188 def unparse(self) -> str: 4189 return 'X' 4190 4191 4192Equivalences = Dict[ 4193 Union[Capability, Tuple[MechanismID, MechanismState]], 4194 Set[Requirement] 4195] 4196""" 4197An `Equivalences` dictionary maps `Capability` names and/or 4198(`MechanismID`, `MechanismState`) pairs to `Requirement` objects, 4199indicating that that single capability or mechanism state should be 4200considered active if the specified requirement is met. Note that this 4201can lead to multiple states of the same mechanism being effectively 4202active at once if a state other than the current state is active via an 4203equivalence. 4204 4205When a circular dependency is created via equivalences, the capability or 4206mechanism state in question is considered inactive when the circular 4207dependency on it comes up, but the equivalence may still succeed (if it 4208uses a disjunction, for example). 4209""" 4210 4211 4212#----------------------# 4213# Tags and Annotations # 4214#----------------------# 4215 4216Tag: 'TypeAlias' = str 4217""" 4218A type alias: tags are strings. 4219 4220A tag is an arbitrary string key attached to a decision or transition, 4221with an associated value (default 1 to just mean "present"). 4222 4223Meanings are left up to the map-maker, but some conventions include: 4224 4225TODO: Actually use these conventions, or abandon them 4226 4227- `'hard'` indicates that an edge is non-trivial to navigate. An 4228 annotation starting with `'fail:'` can be used to name another edge 4229 which would be traversed instead if the player fails to navigate the 4230 edge (e.g., a difficult series of platforms with a pit below that 4231 takes you to another decision). This is of course entirely 4232 subjective. 4233- `'false'` indicates that an edge doesn't actually exist, although it 4234 appears to. This tag is added in the same exploration step that 4235 requirements are updated (normally to `ReqImpossible`) to indicate 4236 that although the edge appeared to be traversable, it wasn't. This 4237 distinguishes that case from a case where edge requirements actually 4238 change. 4239- `'error'` indicates that an edge does not actually exist, and it's 4240 different than `'false'` because it indicates an error on the 4241 player's part rather than intentional deception by the game (another 4242 subjective distinction). It can also be used with a colon and another 4243 tag to indicate that that tag was applied in error (e.g., a ledge 4244 thought to be too high was not actually too high). This should be 4245 used sparingly, because in most cases capturing the player's 4246 perception of the world is what's desired. This is normally applied 4247 in the step before an edge is removed from the graph. 4248- `'hidden'` indicates that an edge is non-trivial to perceive. Again 4249 this is subjective. `'hinted'` can be used as well to indicate that 4250 despite being obfuscated, there are hints that suggest the edge's 4251 existence. 4252- `'created'` indicates that this transition is newly created and 4253 represents a change to the decision layout. Normally, when entering 4254 a decision point, all visible options will be listed. When 4255 revisiting a decision, several things can happen: 4256 1. You could notice a transition you hadn't noticed before. 4257 2. You could traverse part of the room that you couldn't before, 4258 observing new transitions that have always been there (this 4259 would be represented as an internal edge to another decision 4260 node). 4261 3. You could observe that the decision had changed due to some 4262 action or event, and discover a new transition that didn't 4263 exist previously. 4264 This tag distinguishes case 3 from case 1. The presence or absence 4265 of a `'hidden'` tag in case 1 represents whether the newly-observed 4266 (but not new) transition was overlooked because it was hidden or was 4267 just overlooked accidentally. 4268""" 4269 4270TagValueTypes: Tuple = ( 4271 bool, 4272 int, 4273 float, 4274 str, 4275 list, 4276 dict, 4277 None, 4278 Requirement, 4279 Consequence 4280) 4281TagValue: 'TypeAlias' = Union[ 4282 bool, 4283 int, 4284 float, 4285 str, 4286 list, 4287 dict, 4288 None, 4289 Requirement, 4290 Consequence 4291] 4292""" 4293A type alias: tag values are any kind of JSON-serializable data (so 4294booleans, ints, floats, strings, lists, dicts, or Nones, plus 4295`Requirement` and `Consequence` which have custom serialization defined 4296(see `parsing.CustomJSONEncoder`) The default value for tags is the 4297integer 1. Note that this is not enforced recursively in some places... 4298""" 4299 4300 4301class NoTagValue: 4302 """ 4303 Class used to indicate no tag value for things that return tag values 4304 since `None` is a valid tag value. 4305 """ 4306 pass 4307 4308 4309TagUpdateFunction: 'TypeAlias' = Callable[ 4310 [Dict[Tag, TagValue], Tag, TagValue], 4311 TagValue 4312] 4313""" 4314A tag update function gets three arguments: the entire tags dictionary 4315for the thing being updated, the tag name of the tag being updated, and 4316the tag value for that tag. It must return a new tag value. 4317""" 4318 4319 4320Annotation: 'TypeAlias' = str 4321"A type alias: annotations are strings." 4322 4323 4324#-------# 4325# Zones # 4326#-------# 4327 4328class ZoneInfo(NamedTuple): 4329 """ 4330 Zone info holds a level integer (starting from 0 as the level directly 4331 above decisions), a set of parent zones, a set of child decisions 4332 and/or zones, and zone tags and annotations. Zones at a particular 4333 level may only contain zones in lower levels, although zones at any 4334 level may also contain decisions directly. The norm is for zones at 4335 level 0 to contain decisions, while zones at higher levels contain 4336 zones from the level directly below them. 4337 4338 Note that zones may have multiple parents, because one sub-zone may be 4339 contained within multiple super-zones. 4340 """ 4341 level: int 4342 parents: Set[Zone] 4343 contents: Set[Union[DecisionID, Zone]] 4344 tags: Dict[Tag, TagValue] 4345 annotations: List[Annotation] 4346 4347 4348class DefaultZone: 4349 """ 4350 Default argument for a `Zone` when `None` is used to mean "Do not add 4351 to the zone you normally would." 4352 """ 4353 pass 4354 4355 4356#----------------------------------# 4357# Exploration actions & Situations # 4358#----------------------------------# 4359 4360ExplorationActionType = Literal[ 4361 'noAction', 4362 'start', 4363 'take', 4364 'explore', 4365 'warp', 4366 'focus', 4367 'swap', 4368 'focalize', 4369 'revertTo', 4370] 4371""" 4372The valid action types for exploration actions (see 4373`ExplorationAction`). 4374""" 4375 4376ExplorationAction: 'TypeAlias' = Union[ 4377 Tuple[Literal['noAction']], 4378 Tuple[ 4379 Literal['start'], 4380 Union[DecisionID, Dict[FocalPointName, DecisionID], Set[DecisionID]], 4381 Optional[DecisionID], 4382 Domain, 4383 Optional[CapabilitySet], 4384 Optional[Dict[MechanismID, MechanismState]], 4385 Optional[dict] 4386 ], 4387 Tuple[ 4388 Literal['explore'], 4389 ContextSpecifier, 4390 DecisionID, 4391 TransitionWithOutcomes, 4392 Union[DecisionName, DecisionID, None], # new name OR target 4393 Optional[Transition], # new reciprocal name 4394 Union[Zone, None, type[DefaultZone]] # new level-0 zone 4395 ], 4396 Tuple[ 4397 Literal['explore'], 4398 FocalPointSpecifier, 4399 TransitionWithOutcomes, 4400 Union[DecisionName, DecisionID, None], # new name OR target 4401 Optional[Transition], # new reciprocal name 4402 Union[Zone, None, type[DefaultZone]] # new level-0 zone 4403 ], 4404 Tuple[ 4405 Literal['take'], 4406 ContextSpecifier, 4407 DecisionID, 4408 TransitionWithOutcomes 4409 ], 4410 Tuple[Literal['take'], FocalPointSpecifier, TransitionWithOutcomes], 4411 Tuple[Literal['warp'], ContextSpecifier, DecisionID], 4412 Tuple[Literal['warp'], FocalPointSpecifier, DecisionID], 4413 Tuple[Literal['focus'], ContextSpecifier, Set[Domain], Set[Domain]], 4414 Tuple[Literal['swap'], FocalContextName], 4415 Tuple[Literal['focalize'], FocalContextName], 4416 Tuple[Literal['revertTo'], SaveSlot, Set[str]], 4417] 4418""" 4419Represents an action taken at one step of a `DiscreteExploration`. It's a 4420always a tuple, and the first element is a string naming the action. It 4421has multiple possible configurations: 4422 4423- The string 'noAction' as a singlet means that no action has been 4424 taken, which can be used to represent waiting or an ending. In 4425 situations where the player is still deciding on an action, `None` 4426 (which is not a valid `ExplorationAction` should be used instead. 4427- The string 'start' followed by a `DecisionID` / 4428 (`FocalPointName`-to-`DecisionID` dictionary) / set-of-`DecisionID`s 4429 position(s) specifier, another `DecisionID` (or `None`), a `Domain`, 4430 and then optional `CapabilitySet`, mechanism state dictionary, and 4431 custom state dictionary objects (each of which could instead be 4432 `None` for default). This indicates setting up starting state in a 4433 new focal context. The first decision ID (or similar) specifies 4434 active decisions, the second specifies the primary decision (which 4435 ought to be one of the active ones). It always affects the active 4436 focal context, and a `BadStart` error will result if that context 4437 already has any active decisions in the specified domain. The 4438 specified domain must already exist and must have the appropriate 4439 focalization depending on the type of position(s) specifier given; 4440 use `DiscreteExploration.createDomain` to create a domain first if 4441 necessary. Likewise, any specified decisions to activate must 4442 already exist, use `DecisionGraph.addDecision` to create them before 4443 using a 'start' action. 4444 4445 When mechanism states and/or custom state is specified, these 4446 replace current mechanism/custom states for the entire current 4447 state, since these things aren't focal-context-specific. Similarly, 4448 if capabilities are provided, these replace existing capabilities 4449 for the active focal context, since those aren't domain-specific. 4450 4451- The string 'explore' followed by: 4452 * A `ContextSpecifier` indicating which context to use 4453 * A `DecisionID` indicating the starting decision 4454 * Alternatively, a `FocalPointSpecifier` can be used in place of the 4455 context specifier and decision to specify which focal point 4456 moves in a plural-focalized domain. 4457 * A `TransitionWithOutcomes` indicating the transition taken and 4458 outcomes observed (if any). 4459 * An optional `DecisionName` used to rename the destination. 4460 * An optional `Transition` used to rename the reciprocal transition. 4461 * An optional `Zone` used to place the destination into a 4462 (possibly-new) level-0 zone. 4463 This represents exploration of a previously-unexplored decision, in 4464 contrast to 'take' (see below) which represents moving across a 4465 previously-explored transition. 4466 4467- The string 'take' followed by a `ContextSpecifier`, `DecisionID`, and 4468 `TransitionWithOutcomes` represents taking that transition at that 4469 decision, updating the specified context (i.e., common vs. active; 4470 to update a non-active context first swap to it). Normal 4471 `DomainFocalization`-based rules for updating active decisions 4472 determine what happens besides transition consequences, but for a 4473 'singular'-focalized domain (as determined by the active 4474 `FocalContext` in the `DiscreteExploration`'s current `State`), the 4475 current active decision becomes inactive and the decision at the 4476 other end of the selected transition becomes active. A warning or 4477 error may be issued if the `DecisionID` used is an inactive 4478 decision. 4479 * For 'plural'-focalized domains, a `FocalPointSpecifier` is needed 4480 to know which of the plural focal points to move, this takes the 4481 place of the source `ContextSpecifier` and `DecisionID` since it 4482 provides that information. In this case the third item is still a 4483 `Transition`. 4484 4485- The string 'warp' followed by either a `DecisionID`, or a 4486 `FocalPointSpecifier` tuple followed by a `DecisionID`. This 4487 represents activating a new decision without following a 4488 transition in the decision graph, such as when a cutscene moves 4489 you. Things like teleporters can be represented by normal 4490 transitions; a warp should be used when there's a 1-time effect 4491 that has no reciprocal. 4492 4493- The string 'focus' followed by a `ContextSpecifier` and then two sets 4494 of `Domain`s. The first one lists domains that become inactive, and 4495 the second lists domains that become active. This can be used to 4496 represent opening up a menu, although if the menu can easily be 4497 closed and re-opened anywhere, it's usually not necessary to track 4498 the focus swaps (think a cutscene that forces you to make a choice 4499 before being able to continue normal exploration). A focus swap can 4500 also be the consequence of taking a transition, in which case the 4501 exploration action just identifies the transition using one of the 4502 formats above. 4503 4504- The string 'swap' is followed by a `FocalContextName` and represents 4505 a complete `FocalContext` swap. If this something the player can 4506 trigger at will (or under certain conditions) it's better to use a 4507 transition consequence and have the action be taking that transition. 4508 4509- The string 'focalize' is followed by an unused `FocalContextName` 4510 and represents the creation of a new empty focal context (which 4511 will also be swapped-to). 4512 # TODO: domain and context focus swaps as effects! 4513 4514- The string 'revertTo' followed by a `SaveSlot` and then a set of 4515 reversion aspects (see `revertedState`). This will update the 4516 situation by restoring a previous state (or potentially only parts of 4517 it). An empty set of reversion aspects invokes the default revert 4518 behavior, which reverts all aspects of the state, except that changes 4519 to the `DecisionGraph` are preserved. 4520""" 4521 4522 4523def describeExplorationAction( 4524 situation: 'Situation', 4525 action: Optional[ExplorationAction] 4526) -> str: 4527 """ 4528 Returns a string description of the action represented by an 4529 `ExplorationAction` object (or the string '(no action)' for the value 4530 `None`). Uses the provided situation to look up things like decision 4531 names, focal point positions, and destinations where relevant. Does 4532 not know details of which graph it is applied to or the outcomes of 4533 the action, so just describes what is being attempted. 4534 """ 4535 if action is None: 4536 return '(no action)' 4537 4538 if ( 4539 not isinstance(action, tuple) 4540 or len(action) == 0 4541 ): 4542 raise TypeError(f"Not an exploration action: {action!r}") 4543 4544 graph = situation.graph 4545 4546 if action[0] not in get_args(ExplorationActionType): 4547 raise ValueError(f"Invalid exploration action type: {action[0]!r}") 4548 4549 aType = action[0] 4550 if aType == 'noAction': 4551 return "wait" 4552 4553 elif aType == 'start': 4554 if len(action) != 7: 4555 raise ValueError( 4556 f"Wrong number of parts for 'start' action: {action!r}" 4557 ) 4558 ( 4559 _, 4560 startActive, 4561 primary, 4562 domain, 4563 capabilities, 4564 mechanisms, 4565 custom 4566 ) = action 4567 Union[DecisionID, Dict[FocalPointName, DecisionID], Set[DecisionID]] 4568 at: str 4569 if primary is None: 4570 if isinstance(startActive, DecisionID): 4571 at = f" at {graph.identityOf(startActive)}" 4572 elif isinstance(startActive, dict): 4573 at = f" with {len(startActive)} focal point(s)" 4574 elif isinstance(startActive, set): 4575 at = f" from {len(startActive)} decisions" 4576 else: 4577 raise TypeError( 4578 f"Invalid type for starting location:" 4579 f" {type(startActive)}" 4580 ) 4581 else: 4582 at = f" at {graph.identityOf(primary)}" 4583 if isinstance(startActive, dict): 4584 at += f" (among {len(startActive)} focal point(s))" 4585 elif isinstance(startActive, set): 4586 at += f" (among {len(startActive)} decisions)" 4587 4588 return ( 4589 f"start exploring domain {domain}{at}" 4590 ) 4591 4592 elif aType == 'explore': 4593 if len(action) == 7: 4594 assert isinstance(action[2], DecisionID) 4595 fromID = action[2] 4596 assert isinstance(action[3], tuple) 4597 transitionName, specified = action[3] 4598 assert isinstance(action[3][0], Transition) 4599 assert isinstance(action[3][1], list) 4600 assert all(isinstance(x, bool) for x in action[3][1]) 4601 elif len(action) == 6: 4602 assert isinstance(action[1], tuple) 4603 assert len(action[1]) == 3 4604 fpPos = resolvePosition(situation, action[1]) 4605 if fpPos is None: 4606 raise ValueError( 4607 f"Invalid focal point specifier: no position found" 4608 f" for:\n{action[1]}" 4609 ) 4610 else: 4611 fromID = fpPos 4612 transitionName, specified = action[2] 4613 else: 4614 raise ValueError( 4615 f"Wrong number of parts for 'explore' action: {action!r}" 4616 ) 4617 4618 destID = graph.getDestination(fromID, transitionName) 4619 4620 frDesc = graph.identityOf(fromID) 4621 deDesc = graph.identityOf(destID) 4622 4623 newNameOrDest: Union[DecisionName, DecisionID, None] = action[-3] 4624 nowWord = "now " 4625 if newNameOrDest is None: 4626 if destID is None: 4627 nowWord = "" 4628 newName = "INVALID: an unspecified + unnamed decision" 4629 else: 4630 nowWord = "" 4631 newName = graph.nameFor(destID) 4632 elif isinstance(newNameOrDest, DecisionName): 4633 newName = newNameOrDest 4634 else: 4635 assert isinstance(newNameOrDest, DecisionID) 4636 destID = newNameOrDest 4637 nowWord = "now reaches " 4638 newName = graph.identityOf(destID) 4639 4640 newZone: Union[Zone, type[DefaultZone], None] = action[-1] 4641 if newZone in (None, DefaultZone): 4642 deDesc = f"{destID} ({nowWord}{newName})" 4643 else: 4644 deDesc = f"{destID} ({nowWord}{newZone}::{newName})" 4645 # TODO: Don't hardcode '::' here? 4646 4647 oDesc = "" 4648 if len(specified) > 0: 4649 oDesc = " with outcomes: " 4650 first = True 4651 for o in specified: 4652 if first: 4653 first = False 4654 else: 4655 oDesc += ", " 4656 if o: 4657 oDesc += "success" 4658 else: 4659 oDesc += "failure" 4660 4661 return ( 4662 f"explore {transitionName} from decision {frDesc} to" 4663 f" {deDesc}{oDesc}" 4664 ) 4665 4666 elif aType == 'take': 4667 if len(action) == 4: 4668 assert action[1] in get_args(ContextSpecifier) 4669 assert isinstance(action[2], DecisionID) 4670 assert isinstance(action[3], tuple) 4671 assert len(action[3]) == 2 4672 assert isinstance(action[3][0], Transition) 4673 assert isinstance(action[3][1], list) 4674 context = action[1] 4675 fromID = action[2] 4676 transitionName, specified = action[3] 4677 destID = graph.getDestination(fromID, transitionName) 4678 oDesc = "" 4679 if len(specified) > 0: 4680 oDesc = " with outcomes: " 4681 first = True 4682 for o in specified: 4683 if first: 4684 first = False 4685 else: 4686 oDesc += ", " 4687 if o: 4688 oDesc += "success" 4689 else: 4690 oDesc += "failure" 4691 if fromID == destID: # an action 4692 return f"do action {transitionName}" 4693 else: # normal transition 4694 frDesc = graph.identityOf(fromID) 4695 deDesc = graph.identityOf(destID) 4696 4697 return ( 4698 f"take {transitionName} from decision {frDesc} to" 4699 f" {deDesc}{oDesc}" 4700 ) 4701 elif len(action) == 3: 4702 assert isinstance(action[1], tuple) 4703 assert len(action[1]) == 3 4704 assert isinstance(action[2], tuple) 4705 assert len(action[2]) == 2 4706 assert isinstance(action[2][0], Transition) 4707 assert isinstance(action[2][1], list) 4708 _, focalPoint, transition = action 4709 context, domain, name = focalPoint 4710 frID = resolvePosition(situation, focalPoint) 4711 4712 transitionName, specified = action[2] 4713 oDesc = "" 4714 if len(specified) > 0: 4715 oDesc = " with outcomes: " 4716 first = True 4717 for o in specified: 4718 if first: 4719 first = False 4720 else: 4721 oDesc += ", " 4722 if o: 4723 oDesc += "success" 4724 else: 4725 oDesc += "failure" 4726 4727 if frID is None: 4728 return ( 4729 f"invalid action (moves {focalPoint} which doesn't" 4730 f" exist)" 4731 ) 4732 else: 4733 destID = graph.getDestination(frID, transitionName) 4734 4735 if frID == destID: 4736 return "do action {transition}{oDesc}" 4737 else: 4738 frDesc = graph.identityOf(frID) 4739 deDesc = graph.identityOf(destID) 4740 return ( 4741 f"{name} takes {transition} from {frDesc} to" 4742 f" {deDesc}{oDesc}" 4743 ) 4744 else: 4745 raise ValueError( 4746 f"Wrong number of parts for 'take' action: {action!r}" 4747 ) 4748 4749 elif aType == 'warp': 4750 if len(action) != 3: 4751 raise ValueError( 4752 f"Wrong number of parts for 'warp' action: {action!r}" 4753 ) 4754 if action[1] in get_args(ContextSpecifier): 4755 assert isinstance(action[1], str) 4756 assert isinstance(action[2], DecisionID) 4757 _, context, destination = action 4758 deDesc = graph.identityOf(destination) 4759 return f"warp to {deDesc!r}" 4760 elif isinstance(action[1], tuple) and len(action[1]) == 3: 4761 assert isinstance(action[2], DecisionID) 4762 _, focalPoint, destination = action 4763 context, domain, name = focalPoint 4764 deDesc = graph.identityOf(destination) 4765 frID = resolvePosition(situation, focalPoint) 4766 frDesc = graph.identityOf(frID) 4767 return f"{name} warps to {deDesc!r}" 4768 else: 4769 raise TypeError( 4770 f"Invalid second part for 'warp' action: {action!r}" 4771 ) 4772 4773 elif aType == 'focus': 4774 if len(action) != 4: 4775 raise ValueError( 4776 "Wrong number of parts for 'focus' action: {action!r}" 4777 ) 4778 _, context, deactivate, activate = action 4779 assert isinstance(deactivate, set) 4780 assert isinstance(activate, set) 4781 result = "change in active domains: " 4782 clauses = [] 4783 if len(deactivate) > 0: 4784 clauses.append("deactivate domain(s) {', '.join(deactivate)}") 4785 if len(activate) > 0: 4786 clauses.append("activate domain(s) {', '.join(activate)}") 4787 result += '; '.join(clauses) 4788 return result 4789 4790 elif aType == 'swap': 4791 if len(action) != 2: 4792 raise ValueError( 4793 "Wrong number of parts for 'swap' action: {action!r}" 4794 ) 4795 _, fcName = action 4796 return f"swap to focal context {fcName!r}" 4797 4798 elif aType == 'focalize': 4799 if len(action) != 2: 4800 raise ValueError( 4801 "Wrong number of parts for 'focalize' action: {action!r}" 4802 ) 4803 _, fcName = action 4804 return f"create new focal context {fcName!r}" 4805 4806 else: 4807 raise RuntimeError( 4808 "Missing case for exploration action type: {action[0]!r}" 4809 ) 4810 4811 4812DecisionType = Literal[ 4813 "pending", 4814 "active", 4815 "unintended", 4816 "imposed", 4817 "consequence" 4818] 4819""" 4820The types for decisions are: 4821- 'pending': A decision that hasn't been made yet. 4822- 'active': A decision made actively and consciously (the default). 4823- 'unintended': A decision was made but the execution of that decision 4824 resulted in a different action than the one intended (note that we 4825 don't currently record the original intent). TODO: that? 4826- 'imposed': A course of action was changed/taken, but no conscious 4827 decision was made, meaning that the action was imposed by external 4828 circumstances. 4829- 'consequence': A different course of action resulted in a follow-up 4830 consequence that wasn't part of the original intent. 4831""" 4832 4833 4834class Situation(NamedTuple): 4835 """ 4836 Holds all of the pieces of an exploration's state at a single 4837 exploration step, including: 4838 4839 - 'graph': The `DecisionGraph` for that step. Continuity between 4840 graphs can be established because they use the same `DecisionID` 4841 for unchanged nodes. 4842 - 'state': The game `State` for that step, including common and 4843 active `FocalContext`s which determine both what capabilities 4844 are active in the step and which decision point(s) the player 4845 may select an option at. 4846 - 'type': The `DecisionType` for the decision made at this 4847 situation. 4848 - 'taken': an `ExplorationAction` specifying what action was taken, 4849 or `None` for situations where an action has not yet been 4850 decided on (distinct from `(`noAction`,)` for waiting). The 4851 effects of that action are represented by the following 4852 `Situation` in the `DiscreteExploration`. Note that the final 4853 situation in an exploration will also use `('noAction',)` as the 4854 'taken' value to indicate that either no further choices are 4855 possible (e.g., at an ending), or it will use `None` to indicate 4856 that no choice has been made yet. 4857 - 'saves': A dictionary mapping save-slot names to (graph, state) 4858 pairs for saved states. 4859 - 'tags': A dictionary of tag-name: tag-value information for this 4860 step, allowing custom tags with custom values to be added. 4861 - 'annotations': A list of `Annotation` strings allowing custom 4862 annotations to be applied to a situation. 4863 """ 4864 graph: 'DecisionGraph' 4865 state: State 4866 type: DecisionType 4867 action: Optional[ExplorationAction] 4868 saves: Dict[SaveSlot, Tuple['DecisionGraph', State]] 4869 tags: Dict[Tag, TagValue] 4870 annotations: List[Annotation] 4871 4872 4873#-----------------------------# 4874# Situation support functions # 4875#-----------------------------# 4876 4877def contextForTransition( 4878 situation: Situation, 4879 decision: AnyDecisionSpecifier, 4880 transition: Transition 4881) -> RequirementContext: 4882 """ 4883 Given a `Situation` along with an `AnyDecisionSpecifier` and a 4884 `Transition` that together identify a particular transition of 4885 interest, returns the appropriate `RequirementContext` to use to 4886 evaluate requirements and resolve consequences for that transition, 4887 which involves the state & graph from the specified situation, along 4888 with the two ends of that transition as the search-from location. 4889 """ 4890 return RequirementContext( 4891 graph=situation.graph, 4892 state=situation.state, 4893 searchFrom=situation.graph.bothEnds(decision, transition) 4894 ) 4895 4896 4897def genericContextForSituation( 4898 situation: Situation, 4899 searchFrom: Optional[Set[DecisionID]] = None 4900) -> RequirementContext: 4901 """ 4902 Turns a `Situation` into a `RequirementContext` without a specific 4903 transition as the origin (use `contextForTransition` if there's a 4904 relevant transition). By default, the `searchFrom` part of the 4905 requirement context will be the set of active decisions in the 4906 situation, but the search-from part can be overridden by supplying 4907 an explicit `searchFrom` set of decision IDs here. 4908 """ 4909 if searchFrom is None: 4910 searchFrom = combinedDecisionSet(situation.state) 4911 4912 return RequirementContext( 4913 state=situation.state, 4914 graph=situation.graph, 4915 searchFrom=searchFrom 4916 ) 4917 4918 4919def hasCapabilityOrEquivalent( 4920 capability: Capability, 4921 context: RequirementContext, 4922 dontRecurse: Optional[ 4923 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 4924 ] = None 4925): 4926 """ 4927 Determines whether a capability should be considered obtained for the 4928 purposes of requirements, given an entire game state and an 4929 equivalences dictionary which maps capabilities and/or 4930 mechanism/state pairs to sets of requirements that when fulfilled 4931 should count as activating that capability or mechanism state. 4932 """ 4933 if dontRecurse is None: 4934 dontRecurse = set() 4935 4936 if ( 4937 capability in context.state['common']['capabilities']['capabilities'] 4938 or capability in ( 4939 context.state['contexts'] 4940 [context.state['activeContext']] 4941 ['capabilities'] 4942 ['capabilities'] 4943 ) 4944 ): 4945 return True # Capability is explicitly obtained 4946 elif capability in dontRecurse: 4947 return False # Treat circular requirements as unsatisfied 4948 elif not context.graph.hasAnyEquivalents(capability): 4949 # No equivalences to check 4950 return False 4951 else: 4952 # Need to check for a satisfied equivalence 4953 subDont = set(dontRecurse) # Where not to recurse 4954 subDont.add(capability) 4955 # equivalences for this capability 4956 options = context.graph.allEquivalents(capability) 4957 for req in options: 4958 if req.satisfied(context, subDont): 4959 return True 4960 4961 return False 4962 4963 4964def stateOfMechanism( 4965 ctx: RequirementContext, 4966 mechanism: AnyMechanismSpecifier 4967) -> MechanismState: 4968 """ 4969 Returns the current state of the specified mechanism, returning 4970 `DEFAULT_MECHANISM_STATE` if that mechanism doesn't yet have an 4971 assigned state. 4972 """ 4973 mID = ctx.graph.resolveMechanism(mechanism, ctx.searchFrom) 4974 4975 return ctx.state['mechanisms'].get( 4976 mID, 4977 DEFAULT_MECHANISM_STATE 4978 ) 4979 4980 4981def mechanismInStateOrEquivalent( 4982 mechanism: AnyMechanismSpecifier, 4983 reqState: MechanismState, 4984 context: RequirementContext, 4985 dontRecurse: Optional[ 4986 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 4987 ] = None 4988): 4989 """ 4990 Determines whether a mechanism should be considered as being in the 4991 given state for the purposes of requirements, given an entire game 4992 state and an equivalences dictionary which maps capabilities and/or 4993 mechanism/state pairs to sets of requirements that when fulfilled 4994 should count as activating that capability or mechanism state. 4995 4996 The `dontRecurse` set of capabilities and/or mechanisms indicates 4997 requirements which should not be considered for alternate 4998 fulfillment during recursion. 4999 5000 Mechanisms with unspecified state are considered to be in the 5001 `DEFAULT_MECHANISM_STATE`, but mechanisms which don't exist are not 5002 considered to be in any state (i.e., this will always return False). 5003 """ 5004 if dontRecurse is None: 5005 dontRecurse = set() 5006 5007 mID = context.graph.resolveMechanism(mechanism, context.searchFrom) 5008 5009 currentState = stateOfMechanism(context, mID) 5010 if currentState == reqState: 5011 return True # Mechanism is explicitly in the target state 5012 elif (mID, reqState) in dontRecurse: 5013 return False # Treat circular requirements as unsatisfied 5014 elif not context.graph.hasAnyEquivalents((mID, reqState)): 5015 return False # If there are no equivalences, nothing to check 5016 else: 5017 # Need to check for a satisfied equivalence 5018 subDont = set(dontRecurse) # Where not to recurse 5019 subDont.add((mID, reqState)) 5020 # equivalences for this capability 5021 options = context.graph.allEquivalents((mID, reqState)) 5022 for req in options: 5023 if req.satisfied(context, subDont): 5024 return True 5025 5026 return False 5027 5028 5029def combinedTokenCount(state: State, tokenType: Token) -> TokenCount: 5030 """ 5031 Returns the token count for a particular token type for a state, 5032 combining tokens from the common and active `FocalContext`s. 5033 """ 5034 return ( 5035 state['common']['capabilities']['tokens'].get(tokenType, 0) 5036 + state[ 5037 'contexts' 5038 ][state['activeContext']]['capabilities']['tokens'].get(tokenType, 0) 5039 ) 5040 5041 5042def explorationStatusOf( 5043 situation: Situation, 5044 decision: AnyDecisionSpecifier 5045) -> ExplorationStatus: 5046 """ 5047 Returns the exploration status of the specified decision in the 5048 given situation, or the `DEFAULT_EXPLORATION_STATUS` if no status 5049 has been set for that decision. 5050 """ 5051 dID = situation.graph.resolveDecision(decision) 5052 return situation.state['exploration'].get( 5053 dID, 5054 DEFAULT_EXPLORATION_STATUS 5055 ) 5056 5057 5058def setExplorationStatus( 5059 situation: Situation, 5060 decision: AnyDecisionSpecifier, 5061 status: ExplorationStatus, 5062 upgradeOnly: bool = False 5063) -> None: 5064 """ 5065 Sets the exploration status of the specified decision in the 5066 given situation. If `upgradeOnly` is set to True (default is False) 5067 then the exploration status will be changed only if the new status 5068 counts as more-explored than the old one (see `moreExplored`). 5069 """ 5070 dID = situation.graph.resolveDecision(decision) 5071 eMap = situation.state['exploration'] 5072 if upgradeOnly: 5073 status = moreExplored( 5074 status, 5075 eMap.get(dID, 'unknown') 5076 ) 5077 eMap[dID] = status 5078 5079 5080def hasBeenVisited( 5081 situation: Situation, 5082 decision: AnyDecisionSpecifier 5083) -> bool: 5084 """ 5085 Returns `True` if the specified decision has an exploration status 5086 which counts as having been visited (see `base.statusVisited`). Note 5087 that this works differently from `DecisionGraph.isConfirmed` which 5088 just checks for the 'unconfirmed' tag. 5089 """ 5090 return statusVisited(explorationStatusOf(situation, decision)) 5091 5092 5093class IndexTooFarError(IndexError): 5094 """ 5095 An index error that also holds a number specifying how far beyond 5096 the end of the valid indices the given index was. If you are '0' 5097 beyond the end that means you're at the next element after the end. 5098 """ 5099 def __init__(self, msg, beyond=0): 5100 """ 5101 You need a message and can also include a 'beyond' value 5102 (default is 0). 5103 """ 5104 self.msg = msg 5105 self.beyond = beyond 5106 5107 def __str__(self): 5108 return self.msg + f" ({self.beyond} beyond sequence)" 5109 5110 def __repr(self): 5111 return f"IndexTooFarError({repr(self.msg)}, {repr(self.beyond)})" 5112 5113 5114def countParts( 5115 consequence: Union[Consequence, Challenge, Condition, Effect] 5116) -> int: 5117 """ 5118 Returns the number of parts the given consequence has for 5119 depth-first indexing purposes. The consequence itself counts as a 5120 part, plus each `Challenge`, `Condition`, and `Effect` within it, 5121 along with the part counts of any sub-`Consequence`s in challenges 5122 or conditions. 5123 5124 For example: 5125 5126 >>> countParts([]) 5127 1 5128 >>> countParts([effect(gain='jump'), effect(lose='jump')]) 5129 3 5130 >>> c = [ # 1 5131 ... challenge( # 2 5132 ... skills=BestSkill('skill'), 5133 ... level=4, 5134 ... success=[], # 3 5135 ... failure=[effect(lose=('money', 10))], # 4, 5 5136 ... outcome=True 5137 ... ), 5138 ... condition( # 6 5139 ... ReqCapability('jump'), 5140 ... [], # 7 5141 ... [effect(gain='jump')] # 8, 9 5142 ... ), 5143 ... effect(set=('door', 'open')) # 10 5144 ... ] 5145 >>> countParts(c) 5146 10 5147 >>> countParts(c[0]) 5148 4 5149 >>> countParts(c[1]) 5150 4 5151 >>> countParts(c[2]) 5152 1 5153 >>> # (last part of the 10 is the outer list itself) 5154 >>> c = [ # index 0 5155 ... effect(gain='happy'), # index 1 5156 ... challenge( # index 2 5157 ... skills=BestSkill('strength'), 5158 ... success=[effect(gain='winner')] # indices 3 & 4 5159 ... # failure is implicit; gets index 5 5160 ... ) # level defaults to 0 5161 ... ] 5162 >>> countParts(c) 5163 6 5164 >>> countParts(c[0]) 5165 1 5166 >>> countParts(c[1]) 5167 4 5168 >>> countParts(c[1]['success']) 5169 2 5170 >>> countParts(c[1]['failure']) 5171 1 5172 """ 5173 total = 1 5174 if isinstance(consequence, list): 5175 for part in consequence: 5176 total += countParts(part) 5177 elif isinstance(consequence, dict): 5178 if 'skills' in consequence: # it's a Challenge 5179 consequence = cast(Challenge, consequence) 5180 total += ( 5181 countParts(consequence['success']) 5182 + countParts(consequence['failure']) 5183 ) 5184 elif 'condition' in consequence: # it's a Condition 5185 consequence = cast(Condition, consequence) 5186 total += ( 5187 countParts(consequence['consequence']) 5188 + countParts(consequence['alternative']) 5189 ) 5190 elif 'value' in consequence: # it's an Effect 5191 pass # counted already 5192 else: # bad dict 5193 raise TypeError( 5194 f"Invalid consequence: items must be Effects," 5195 f" Challenges, or Conditions (got a dictionary without" 5196 f" 'skills', 'value', or 'condition' keys)." 5197 f"\nGot consequence: {repr(consequence)}" 5198 ) 5199 else: 5200 raise TypeError( 5201 f"Invalid consequence: must be an Effect, Challenge, or" 5202 f" Condition, or a list of those." 5203 f"\nGot part: {repr(consequence)}" 5204 ) 5205 5206 return total 5207 5208 5209def walkParts( 5210 consequence: Union[Consequence, Challenge, Condition, Effect], 5211 startIndex: int = 0 5212) -> Generator[ 5213 Tuple[int, Union[Consequence, Challenge, Condition, Effect]], 5214 None, 5215 None 5216]: 5217 """ 5218 Yields tuples containing all indices and the associated 5219 `Consequence`s in the given consequence tree, in depth-first 5220 traversal order. 5221 5222 A `startIndex` other than 0 may be supplied and the indices yielded 5223 will start there. 5224 5225 For example: 5226 5227 >>> list(walkParts([])) 5228 [(0, [])] 5229 >>> e = [] 5230 >>> list(walkParts(e))[0][1] is e 5231 True 5232 >>> c = [effect(gain='jump'), effect(lose='jump')] 5233 >>> list(walkParts(c)) == [ 5234 ... (0, c), 5235 ... (1, c[0]), 5236 ... (2, c[1]), 5237 ... ] 5238 True 5239 >>> c = [ # 1 5240 ... challenge( # 2 5241 ... skills=BestSkill('skill'), 5242 ... level=4, 5243 ... success=[], # 3 5244 ... failure=[effect(lose=('money', 10))], # 4, 5 5245 ... outcome=True 5246 ... ), 5247 ... condition( # 6 5248 ... ReqCapability('jump'), 5249 ... [], # 7 5250 ... [effect(gain='jump')] # 8, 9 5251 ... ), 5252 ... effect(set=('door', 'open')) # 10 5253 ... ] 5254 >>> list(walkParts(c)) == [ 5255 ... (0, c), 5256 ... (1, c[0]), 5257 ... (2, c[0]['success']), 5258 ... (3, c[0]['failure']), 5259 ... (4, c[0]['failure'][0]), 5260 ... (5, c[1]), 5261 ... (6, c[1]['consequence']), 5262 ... (7, c[1]['alternative']), 5263 ... (8, c[1]['alternative'][0]), 5264 ... (9, c[2]), 5265 ... ] 5266 True 5267 """ 5268 index = startIndex 5269 yield (index, consequence) 5270 index += 1 5271 if isinstance(consequence, list): 5272 for part in consequence: 5273 for (subIndex, subItem) in walkParts(part, index): 5274 yield (subIndex, subItem) 5275 index = subIndex + 1 5276 elif isinstance(consequence, dict) and 'skills' in consequence: 5277 # a Challenge 5278 challenge = cast(Challenge, consequence) 5279 for (subIndex, subItem) in walkParts(challenge['success'], index): 5280 yield (subIndex, subItem) 5281 index = subIndex + 1 5282 for (subIndex, subItem) in walkParts(challenge['failure'], index): 5283 yield (subIndex, subItem) 5284 elif isinstance(consequence, dict) and 'condition' in consequence: 5285 # a Condition 5286 condition = cast(Condition, consequence) 5287 for (subIndex, subItem) in walkParts( 5288 condition['consequence'], 5289 index 5290 ): 5291 yield (subIndex, subItem) 5292 index = subIndex + 1 5293 for (subIndex, subItem) in walkParts( 5294 condition['alternative'], 5295 index 5296 ): 5297 yield (subIndex, subItem) 5298 elif isinstance(consequence, dict) and 'value' in consequence: 5299 # an Effect; we already yielded it above 5300 pass 5301 else: 5302 raise TypeError( 5303 f"Invalid consequence: items must be lists, Effects," 5304 f" Challenges, or Conditions.\nGot part:" 5305 f" {repr(consequence)}" 5306 ) 5307 5308 5309def consequencePart( 5310 consequence: Consequence, 5311 index: int 5312) -> Union[Consequence, Challenge, Condition, Effect]: 5313 """ 5314 Given a `Consequence`, returns the part at the specified index, in 5315 depth-first traversal order, including the consequence itself at 5316 index 0. Raises an `IndexTooFarError` if the index is beyond the end 5317 of the tree; the 'beyond' value of the error will indicate how many 5318 indices beyond the end it was, with 0 for an index that's just 5319 beyond the end. 5320 5321 For example: 5322 5323 >>> c = [] 5324 >>> consequencePart(c, 0) is c 5325 True 5326 >>> try: 5327 ... consequencePart(c, 1) 5328 ... except IndexTooFarError as e: 5329 ... e.beyond 5330 0 5331 >>> try: 5332 ... consequencePart(c, 2) 5333 ... except IndexTooFarError as e: 5334 ... e.beyond 5335 1 5336 >>> c = [effect(gain='jump'), effect(lose='jump')] 5337 >>> consequencePart(c, 0) is c 5338 True 5339 >>> consequencePart(c, 1) is c[0] 5340 True 5341 >>> consequencePart(c, 2) is c[1] 5342 True 5343 >>> try: 5344 ... consequencePart(c, 3) 5345 ... except IndexTooFarError as e: 5346 ... e.beyond 5347 0 5348 >>> try: 5349 ... consequencePart(c, 4) 5350 ... except IndexTooFarError as e: 5351 ... e.beyond 5352 1 5353 >>> c = [ 5354 ... challenge( 5355 ... skills=BestSkill('skill'), 5356 ... level=4, 5357 ... success=[], 5358 ... failure=[effect(lose=('money', 10))], 5359 ... outcome=True 5360 ... ), 5361 ... condition(ReqCapability('jump'), [], [effect(gain='jump')]), 5362 ... effect(set=('door', 'open')) 5363 ... ] 5364 >>> consequencePart(c, 0) is c 5365 True 5366 >>> consequencePart(c, 1) is c[0] 5367 True 5368 >>> consequencePart(c, 2) is c[0]['success'] 5369 True 5370 >>> consequencePart(c, 3) is c[0]['failure'] 5371 True 5372 >>> consequencePart(c, 4) is c[0]['failure'][0] 5373 True 5374 >>> consequencePart(c, 5) is c[1] 5375 True 5376 >>> consequencePart(c, 6) is c[1]['consequence'] 5377 True 5378 >>> consequencePart(c, 7) is c[1]['alternative'] 5379 True 5380 >>> consequencePart(c, 8) is c[1]['alternative'][0] 5381 True 5382 >>> consequencePart(c, 9) is c[2] 5383 True 5384 >>> consequencePart(c, 10) 5385 Traceback (most recent call last): 5386 ... 5387 exploration.base.IndexTooFarError... 5388 >>> try: 5389 ... consequencePart(c, 10) 5390 ... except IndexTooFarError as e: 5391 ... e.beyond 5392 0 5393 >>> try: 5394 ... consequencePart(c, 11) 5395 ... except IndexTooFarError as e: 5396 ... e.beyond 5397 1 5398 >>> try: 5399 ... consequencePart(c, 14) 5400 ... except IndexTooFarError as e: 5401 ... e.beyond 5402 4 5403 """ 5404 if index == 0: 5405 return consequence 5406 index -= 1 5407 for part in consequence: 5408 if index == 0: 5409 return part 5410 else: 5411 index -= 1 5412 if not isinstance(part, dict): 5413 raise TypeError( 5414 f"Invalid consequence: items in the list must be" 5415 f" Effects, Challenges, or Conditions." 5416 f"\nGot part: {repr(part)}" 5417 ) 5418 elif 'skills' in part: # it's a Challenge 5419 part = cast(Challenge, part) 5420 try: 5421 return consequencePart(part['success'], index) 5422 except IndexTooFarError as e: 5423 index = e.beyond 5424 try: 5425 return consequencePart(part['failure'], index) 5426 except IndexTooFarError as e: 5427 index = e.beyond 5428 elif 'condition' in part: # it's a Condition 5429 part = cast(Condition, part) 5430 try: 5431 return consequencePart(part['consequence'], index) 5432 except IndexTooFarError as e: 5433 index = e.beyond 5434 try: 5435 return consequencePart(part['alternative'], index) 5436 except IndexTooFarError as e: 5437 index = e.beyond 5438 elif 'value' in part: # it's an Effect 5439 pass # if index was 0, we would have returned this part already 5440 else: # bad dict 5441 raise TypeError( 5442 f"Invalid consequence: items in the list must be" 5443 f" Effects, Challenges, or Conditions (got a dictionary" 5444 f" without 'skills', 'value', or 'condition' keys)." 5445 f"\nGot part: {repr(part)}" 5446 ) 5447 5448 raise IndexTooFarError( 5449 "Part index beyond end of consequence.", 5450 index 5451 ) 5452 5453 5454def lookupEffect( 5455 situation: Situation, 5456 effect: EffectSpecifier 5457) -> Effect: 5458 """ 5459 Looks up an effect within a situation. 5460 """ 5461 graph = situation.graph 5462 root = graph.getConsequence(effect[0], effect[1]) 5463 try: 5464 result = consequencePart(root, effect[2]) 5465 except IndexTooFarError: 5466 raise IndexError( 5467 f"Invalid effect specifier (consequence has too few parts):" 5468 f" {effect}" 5469 ) 5470 5471 if not isinstance(result, dict) or 'value' not in result: 5472 raise IndexError( 5473 f"Invalid effect specifier (part is not an Effect):" 5474 f" {effect}\nGot a/an {type(result)}:" 5475 f"\n {result}" 5476 ) 5477 5478 return cast(Effect, result) 5479 5480 5481def triggerCount( 5482 situation: Situation, 5483 effect: EffectSpecifier 5484) -> int: 5485 """ 5486 Looks up the trigger count for the specified effect in the given 5487 situation. This includes times the effect has been triggered but 5488 didn't actually do anything because of its delay and/or charges 5489 values. 5490 """ 5491 return situation.state['effectCounts'].get(effect, 0) 5492 5493 5494def incrementTriggerCount( 5495 situation: Situation, 5496 effect: EffectSpecifier, 5497 add: int = 1 5498) -> None: 5499 """ 5500 Adds one (or the specified `add` value) to the trigger count for the 5501 specified effect in the given situation. 5502 """ 5503 counts = situation.state['effectCounts'] 5504 if effect in counts: 5505 counts[effect] += add 5506 else: 5507 counts[effect] = add 5508 5509 5510def doTriggerEffect( 5511 situation: Situation, 5512 effect: EffectSpecifier 5513) -> Tuple[Effect, Optional[int]]: 5514 """ 5515 Looks up the trigger count for the given effect, adds one, and then 5516 returns a tuple with the effect, plus the effective trigger count or 5517 `None`, returning `None` if the effect's charges or delay values 5518 indicate that based on its new trigger count, it should not actually 5519 fire, and otherwise returning a modified trigger count that takes 5520 delay into account. 5521 5522 For example, if an effect has 2 delay and 3 charges and has been 5523 activated once, it will not actually trigger (since its delay value 5524 is still playing out). Once it hits the third attempted trigger, it 5525 will activate with an effective activation count of 1, since that's 5526 the first time it actually applies. Of course, on the 6th and 5527 subsequent activation attempts, it will once more cease to trigger 5528 because it will be out of charges. 5529 """ 5530 counts = situation.state['effectCounts'] 5531 thisCount = counts.get(effect, 0) 5532 counts[effect] = thisCount + 1 # increment the total count 5533 5534 # Get charges and delay values 5535 effectDetails = lookupEffect(situation, effect) 5536 delay = effectDetails['delay'] or 0 5537 charges = effectDetails['charges'] 5538 5539 delayRemaining = delay - thisCount 5540 if delayRemaining > 0: 5541 return (effectDetails, None) 5542 else: 5543 thisCount -= delay 5544 5545 if charges is None: 5546 return (effectDetails, thisCount) 5547 else: 5548 chargesRemaining = charges - thisCount 5549 if chargesRemaining >= 0: 5550 return (effectDetails, thisCount) 5551 else: 5552 return (effectDetails, None) 5553 5554 5555#------------------# 5556# Position support # 5557#------------------# 5558 5559def resolvePosition( 5560 situation: Situation, 5561 posSpec: Union[Tuple[ContextSpecifier, Domain], FocalPointSpecifier] 5562) -> Optional[DecisionID]: 5563 """ 5564 Given a tuple containing either a specific context plus a specific 5565 domain (which must be singular-focalized) or a full 5566 `FocalPointSpecifier`, this function returns the decision ID implied 5567 by the given specifier within the given situation, or `None` if the 5568 specifier is valid but the position for that specifier is `None` 5569 (including when the domain is not-yet-encountered). For 5570 singular-focalized domains, this is just the position value for that 5571 domain. For plural-focalized domains, you need to provide a 5572 `FocalPointSpecifier` and it's the position of that focal point. 5573 """ 5574 fpName: Optional[FocalPointName] = None 5575 if len(posSpec) == 2: 5576 posSpec = cast(Tuple[ContextSpecifier, Domain], posSpec) 5577 whichContext, domain = posSpec 5578 elif len(posSpec) == 3: 5579 posSpec = cast(FocalPointSpecifier, posSpec) 5580 whichContext, domain, fpName = posSpec 5581 else: 5582 raise ValueError( 5583 f"Invalid position specifier {repr(posSpec)}. Must be a" 5584 f" length-2 or length-3 tuple." 5585 ) 5586 5587 state = situation.state 5588 if whichContext == 'common': 5589 targetContext = state['common'] 5590 else: 5591 targetContext = state['contexts'][state['activeContext']] 5592 focalization = getDomainFocalization(targetContext, domain) 5593 5594 if fpName is None: 5595 if focalization != 'singular': 5596 raise ValueError( 5597 f"Cannot resolve position {repr(posSpec)} because the" 5598 f" domain {repr(domain)} is not singular-focalized." 5599 ) 5600 result = targetContext['activeDecisions'].get(domain) 5601 assert isinstance(result, DecisionID) 5602 return result 5603 else: 5604 if focalization != 'plural': 5605 raise ValueError( 5606 f"Cannot resolve position {repr(posSpec)} because a" 5607 f" focal point name was specified but the domain" 5608 f" {repr(domain)} is not plural-focalized." 5609 ) 5610 fpMap = targetContext['activeDecisions'].get(domain, {}) 5611 # Double-check types for map itself and at least one entry 5612 assert isinstance(fpMap, dict) 5613 if len(fpMap) > 0: 5614 exKey = next(iter(fpMap)) 5615 exVal = fpMap[exKey] 5616 assert isinstance(exKey, FocalPointName) 5617 assert exVal is None or isinstance(exVal, DecisionID) 5618 if fpName not in fpMap: 5619 raise ValueError( 5620 f"Cannot resolve position {repr(posSpec)} because no" 5621 f" focal point with name {repr(fpName)} exists in" 5622 f" domain {repr(domain)} for the {whichContext}" 5623 f" context." 5624 ) 5625 return fpMap[fpName] 5626 5627 5628def updatePosition( 5629 situation: Situation, 5630 newPosition: DecisionID, 5631 inCommon: ContextSpecifier = "active", 5632 moveWhich: Optional[FocalPointName] = None 5633) -> None: 5634 """ 5635 Given a Situation, updates the position information in that 5636 situation to represent updated player focalization. This can be as 5637 simple as a move from one virtual decision to an adjacent one, or as 5638 complicated as a cross-domain move where the previous decision point 5639 remains active and a specific focal point among a plural-focalized 5640 domain gets updated. 5641 5642 The exploration status of the destination will be set to 'exploring' 5643 if it had been an unexplored status, and the 'visiting' tag in the 5644 `DecisionGraph` will be added (set to 1). 5645 5646 TODO: Examples 5647 """ 5648 graph = situation.graph 5649 state = situation.state 5650 destDomain = graph.domainFor(newPosition) 5651 5652 # Set the primary decision of the state 5653 state['primaryDecision'] = newPosition 5654 5655 if inCommon == 'common': 5656 targetContext = state['common'] 5657 else: 5658 targetContext = state['contexts'][state['activeContext']] 5659 5660 # Figure out focalization type and active decision(s) 5661 fType = getDomainFocalization(targetContext, destDomain) 5662 domainActiveMap = targetContext['activeDecisions'] 5663 if destDomain in domainActiveMap: 5664 active = domainActiveMap[destDomain] 5665 else: 5666 if fType == 'singular': 5667 active = domainActiveMap.setdefault(destDomain, None) 5668 elif fType == 'plural': 5669 active = domainActiveMap.setdefault(destDomain, {}) 5670 else: 5671 assert fType == 'spreading' 5672 active = domainActiveMap.setdefault(destDomain, set()) 5673 5674 if fType == 'plural': 5675 assert isinstance(active, dict) 5676 if len(active) > 0: 5677 exKey = next(iter(active)) 5678 exVal = active[exKey] 5679 assert isinstance(exKey, FocalPointName) 5680 assert exVal is None or isinstance(exVal, DecisionID) 5681 if moveWhich is None and len(active) > 1: 5682 raise ValueError( 5683 f"Invalid position update: move is going to decision" 5684 f" {graph.identityOf(newPosition)} in domain" 5685 f" {repr(destDomain)}, but it did not specify which" 5686 f" focal point to move, and that domain has plural" 5687 f" focalization with more than one focal point." 5688 ) 5689 elif moveWhich is None: 5690 moveWhich = list(active)[0] 5691 5692 # Actually move the specified focal point 5693 active[moveWhich] = newPosition 5694 5695 elif moveWhich is not None: 5696 raise ValueError( 5697 f"Invalid position update: move going to decision" 5698 f" {graph.identityOf(newPosition)} in domain" 5699 f" {repr(destDomain)}, specified that focal point" 5700 f" {repr(moveWhich)} should be moved, but that domain does" 5701 f" not have plural focalization, so it does not have" 5702 f" multiple focal points to move." 5703 ) 5704 5705 elif fType == 'singular': 5706 # Update the single position: 5707 domainActiveMap[destDomain] = newPosition 5708 5709 elif fType == 'spreading': 5710 # Add the new position: 5711 assert isinstance(active, set) 5712 active.add(newPosition) 5713 5714 else: 5715 raise ValueError(f"Invalid focalization value: {repr(fType)}") 5716 5717 graph.untagDecision(newPosition, 'unconfirmed') 5718 if not hasBeenVisited(situation, newPosition): 5719 setExplorationStatus( 5720 situation, 5721 newPosition, 5722 'exploring', 5723 upgradeOnly=True 5724 ) 5725 5726 5727#--------------------------------# 5728# Geographic exploration support # 5729#--------------------------------# 5730 5731PointID: 'TypeAlias' = int 5732 5733Coords: 'TypeAlias' = Sequence[float] 5734 5735AnyPoint: 'TypeAlias' = Union[PointID, Coords] 5736 5737Feature: 'TypeAlias' = str 5738""" 5739Each feature in a `FeatureGraph` gets a globally unique id, but also has 5740an explorer-assigned name. These names may repeat themselves (especially 5741in different regions) so a region-based address, possibly with a 5742creation-order numeral, can be used to specify a feature exactly even 5743without using its ID. Any string can be used, but for ease of parsing 5744and conversion between formats, sticking to alphanumerics plus 5745underscores is usually desirable. 5746""" 5747 5748FeatureID: 'TypeAlias' = int 5749""" 5750Features in a feature graph have unique integer identifiers that are 5751assigned automatically in order of creation. 5752""" 5753 5754Part: 'TypeAlias' = str 5755""" 5756Parts of a feature are identified using strings. Standard part names 5757include 'middle', compass directions, and top/bottom. To include both a 5758compass direction and a vertical position, put the vertical position 5759first and separate with a dash, like 'top-north'. Temporal positions 5760like start/end may also apply in some cases. 5761""" 5762 5763 5764class FeatureSpecifier(NamedTuple): 5765 """ 5766 There are several ways to specify a feature within a `FeatureGraph`: 5767 Simplest is to just include the `FeatureID` directly (in that case 5768 the domain must be `None` and the 'within' sequence must be empty). 5769 A specific domain and/or a sequence of containing features (starting 5770 from most-external to most-internal) may also be specified when a 5771 string is used as the feature itself, to help disambiguate (when an 5772 ambiguous `FeatureSpecifier` is used, 5773 `AmbiguousFeatureSpecifierError` may arise in some cases). For any 5774 feature, a part may also be specified indicating which part of the 5775 feature is being referred to; this can be `None` when not referring 5776 to any specific sub-part. 5777 """ 5778 domain: Optional[Domain] 5779 within: Sequence[Feature] 5780 feature: Union[Feature, FeatureID] 5781 part: Optional[Part] 5782 5783 5784def feature( 5785 name: Feature, 5786 part: Optional[Part] = None, 5787 domain: Optional[Domain] = None, 5788 within: Optional[Sequence[Feature]] = None 5789) -> FeatureSpecifier: 5790 """ 5791 Builds a `FeatureSpecifier` with some defaults. The default domain 5792 is `None`, and by default the feature has an empty 'within' field and 5793 its part field is `None`. 5794 """ 5795 if within is None: 5796 within = [] 5797 return FeatureSpecifier( 5798 domain=domain, 5799 within=within, 5800 feature=name, 5801 part=part 5802 ) 5803 5804 5805AnyFeatureSpecifier: 'TypeAlias' = Union[ 5806 FeatureID, 5807 Feature, 5808 FeatureSpecifier 5809] 5810""" 5811A type for locations where a feature may be specified multiple different 5812ways: directly by ID, by full feature specifier, or by a string 5813identifying a feature name. You can use `normalizeFeatureSpecifier` to 5814convert one of these to a `FeatureSpecifier`. 5815""" 5816 5817 5818def normalizeFeatureSpecifier(spec: AnyFeatureSpecifier) -> FeatureSpecifier: 5819 """ 5820 Turns an `AnyFeatureSpecifier` into a `FeatureSpecifier`. Note that 5821 it does not do parsing from a complex string. Use 5822 `parsing.ParseFormat.parseFeatureSpecifier` for that. 5823 5824 It will turn a feature specifier with an int-convertible feature name 5825 into a feature-ID-based specifier, discarding any domain and/or zone 5826 parts. 5827 5828 TODO: Issue a warning if parts are discarded? 5829 """ 5830 if isinstance(spec, (FeatureID, Feature)): 5831 return FeatureSpecifier( 5832 domain=None, 5833 within=[], 5834 feature=spec, 5835 part=None 5836 ) 5837 elif isinstance(spec, FeatureSpecifier): 5838 try: 5839 fID = int(spec.feature) 5840 return FeatureSpecifier(None, [], fID, spec.part) 5841 except ValueError: 5842 return spec 5843 else: 5844 raise TypeError( 5845 f"Invalid feature specifier type: {type(spec)}" 5846 ) 5847 5848 5849class MetricSpace: 5850 """ 5851 TODO 5852 Represents a variable-dimensional coordinate system within which 5853 locations can be identified by coordinates. May (or may not) include 5854 a reference to one or more images which are visual representation(s) 5855 of the space. 5856 """ 5857 def __init__(self, name: str): 5858 self.name = name 5859 5860 self.points: Dict[PointID, Coords] = {} 5861 # Holds all IDs and their corresponding coordinates as key/value 5862 # pairs 5863 5864 self.nextID: PointID = 0 5865 # ID numbers should not be repeated or reused 5866 5867 def addPoint(self, coords: Coords) -> PointID: 5868 """ 5869 Given a sequence (list/array/etc) of int coordinates, creates a 5870 point and adds it to the metric space object 5871 5872 >>> ms = MetricSpace("test") 5873 >>> ms.addPoint([2, 3]) 5874 0 5875 >>> #expected result 5876 >>> ms.addPoint([2, 7, 0]) 5877 1 5878 """ 5879 thisID = self.nextID 5880 5881 self.nextID += 1 5882 5883 self.points[thisID] = coords # creates key value pair 5884 5885 return thisID 5886 5887 # How do we "add" things to the metric space? What data structure 5888 # is it? dictionary 5889 5890 def removePoint(self, thisID: PointID) -> None: 5891 """ 5892 Given the ID of a point/coord, checks the dictionary 5893 (points) for that key and removes the key/value pair from 5894 it. 5895 5896 >>> ms = MetricSpace("test") 5897 >>> ms.addPoint([2, 3]) 5898 0 5899 >>> ms.removePoint(0) 5900 >>> ms.removePoint(0) 5901 Traceback (most recent call last): 5902 ... 5903 KeyError... 5904 >>> #expected result should be a caught KeyNotFound exception 5905 """ 5906 self.points.pop(thisID) 5907 5908 def distance(self, origin: AnyPoint, dest: AnyPoint) -> float: 5909 """ 5910 Given an orgin point and destination point, returns the 5911 distance between the two points as a float. 5912 5913 >>> ms = MetricSpace("test") 5914 >>> ms.addPoint([4, 0]) 5915 0 5916 >>> ms.addPoint([1, 0]) 5917 1 5918 >>> ms.distance(0, 1) 5919 3.0 5920 >>> p1 = ms.addPoint([4, 3]) 5921 >>> p2 = ms.addPoint([4, 9]) 5922 >>> ms.distance(p1, p2) 5923 6.0 5924 >>> ms.distance([8, 6], [4, 6]) 5925 4.0 5926 >>> ms.distance([1, 1], [1, 1]) 5927 0.0 5928 >>> ms.distance([-2, -3], [-5, -7]) 5929 5.0 5930 >>> ms.distance([2.5, 3.7], [4.9, 6.1]) 5931 3.394112549695428 5932 """ 5933 if isinstance(origin, PointID): 5934 coord1 = self.points[origin] 5935 else: 5936 coord1 = origin 5937 5938 if isinstance(dest, PointID): 5939 coord2 = self.points[dest] 5940 else: 5941 coord2 = dest 5942 5943 inside = 0.0 5944 5945 for dim in range(max(len(coord1), len(coord2))): 5946 if dim < len(coord1): 5947 val1 = coord1[dim] 5948 else: 5949 val1 = 0 5950 if dim < len(coord2): 5951 val2 = coord2[dim] 5952 else: 5953 val2 = 0 5954 5955 inside += (val2 - val1)**2 5956 5957 result = math.sqrt(inside) 5958 return result 5959 5960 def NDCoords( 5961 self, 5962 point: AnyPoint, 5963 numDimension: int 5964 ) -> Coords: 5965 """ 5966 Given a 2D set of coordinates (x, y), converts them to the desired 5967 dimension 5968 5969 >>> ms = MetricSpace("test") 5970 >>> ms.NDCoords([5, 9], 3) 5971 [5, 9, 0] 5972 >>> ms.NDCoords([3, 1], 1) 5973 [3] 5974 """ 5975 if isinstance(point, PointID): 5976 coords = self.points[point] 5977 else: 5978 coords = point 5979 5980 seqLength = len(coords) 5981 5982 if seqLength != numDimension: 5983 5984 newCoords: Coords 5985 5986 if seqLength < numDimension: 5987 5988 newCoords = [item for item in coords] 5989 5990 for i in range(numDimension - seqLength): 5991 newCoords.append(0) 5992 5993 else: 5994 newCoords = coords[:numDimension] 5995 5996 return newCoords 5997 5998 def lastID(self) -> PointID: 5999 """ 6000 Returns the most updated ID of the metricSpace instance. The nextID 6001 field is always 1 more than the last assigned ID. Assumes that there 6002 has at least been one ID assigned to a point as a key value pair 6003 in the dictionary. Returns 0 if that is not the case. Does not 6004 consider possible counting errors if a point has been removed from 6005 the dictionary. The last ID does not neccessarily equal the number 6006 of points in the metricSpace (or in the dictionary). 6007 6008 >>> ms = MetricSpace("test") 6009 >>> ms.lastID() 6010 0 6011 >>> ms.addPoint([2, 3]) 6012 0 6013 >>> ms.addPoint([2, 7, 0]) 6014 1 6015 >>> ms.addPoint([2, 7]) 6016 2 6017 >>> ms.lastID() 6018 2 6019 >>> ms.removePoint(2) 6020 >>> ms.lastID() 6021 2 6022 """ 6023 if self.nextID < 1: 6024 return self.nextID 6025 return self.nextID - 1 6026 6027 6028def featurePart(spec: AnyFeatureSpecifier, part: Part) -> FeatureSpecifier: 6029 """ 6030 Returns a new feature specifier (and/or normalizes to one) that 6031 contains the specified part in the 'part' slot. If the provided 6032 feature specifier already contains a 'part', that will be replaced. 6033 6034 For example: 6035 6036 >>> featurePart('town', 'north') 6037 FeatureSpecifier(domain=None, within=[], feature='town', part='north') 6038 >>> featurePart(5, 'top') 6039 FeatureSpecifier(domain=None, within=[], feature=5, part='top') 6040 >>> featurePart( 6041 ... FeatureSpecifier('dom', ['one', 'two'], 'three', 'middle'), 6042 ... 'top' 6043 ... ) 6044 FeatureSpecifier(domain='dom', within=['one', 'two'], feature='three',\ 6045 part='top') 6046 >>> featurePart(FeatureSpecifier(None, ['region'], 'place', None), 'top') 6047 FeatureSpecifier(domain=None, within=['region'], feature='place',\ 6048 part='top') 6049 """ 6050 spec = normalizeFeatureSpecifier(spec) 6051 return FeatureSpecifier(spec.domain, spec.within, spec.feature, part) 6052 6053 6054FeatureType = Literal[ 6055 'node', 6056 'path', 6057 'edge', 6058 'region', 6059 'landmark', 6060 'affordance', 6061 'entity' 6062] 6063""" 6064The different types of features that a `FeatureGraph` can have: 6065 60661. Nodes, representing destinations, and/or intersections. A node is 6067 something that one can be "at" and possibly "in." 60682. Paths, connecting nodes and/or other elements. Also used to represent 6069 access points (like doorways between regions) even when they don't 6070 have length. 60713. Edges, separating regions and/or impeding movement (but a door is also 6072 a kind of edge). 60734. Regions, enclosing other elements and/or regions. Besides via 6074 containment, region-region connections are mediated by nodes, paths, 6075 and/or edges. 60765. Landmarks, which are recognizable and possibly visible from afar. 60776. Affordances, which are exploration-relevant location-specific actions 6078 that can be taken, such as a lever that can be pulled. Affordances 6079 may require positioning within multiple domains, but should only be 6080 marked in the most-relevant domain, with cross-domain linkages for 6081 things like observability. Note that the other spatial object types 6082 have their own natural affordances; this is used to mark affordances 6083 beyond those. Each affordance can have a list of `Consequence`s to 6084 indicate what happens when it is activated. 60857. Entities, which can be interacted with, such as an NPC which can be 6086 talked to. Can also be used to represent the player's avatar in a 6087 particular domain. Can have adjacent (touching) affordances to 6088 represent specific interaction options, and may have nodes which 6089 represent options for deeper interaction, but has a generic 6090 'interact' affordance as well. In general, adjacent affordances 6091 should be used to represent options for interaction that are 6092 triggerable directly within the explorable space, such as the fact 6093 that an NPC can be pushed or picked up or the like. In contrast, 6094 interaction options accessible via opening an interaction menu 6095 should be represented by a 'hasOptions' link to a node (typically in 6096 a separate domain) which has some combination of affordances and/or 6097 further interior nodes representing sub-menus. Sub-menus gated on 6098 some kind of requirement can list those requirements for entry. 6099""" 6100 6101FeatureRelationshipType = Literal[ 6102 'contains', 6103 'within', 6104 'touches', 6105 'observable', 6106 'positioned', 6107 'entranceFor', 6108 'enterTo', 6109 'optionsFor', 6110 'hasOptions', 6111 'interacting', 6112 'triggeredBy', 6113 'triggers', 6114] 6115""" 6116The possible relationships between features in a `FeatureGraph`: 6117 6118- 'contains', specifies that one element contains another. Regions can 6119 contain other elements, including other regions, and nodes can 6120 contain regions (but only indirectly other elements). A region 6121 contained by a node represents some kind of interior space for that 6122 node, and this can be used for fractal detail levels (e.g., a town is 6123 a node on the overworld but when you enter it it's a full region 6124 inside, to contrast with a town as a sub-region of the overworld with 6125 no special enter/exit mechanics). The opposite relation is 'within'. 6126- 'touches' specifies that two elements touch each other. Not used for 6127 regions directly (an edge, path, or node should intercede). The 6128 relationship is reciprocal, but not transitive. 6129- 'observable' specifies that the target element is observable (visible 6130 or otherwise perceivable) from the source element. Can be tagged to 6131 indicate things like partial observability and/or 6132 difficult-to-observe elements. By default, things that touch each 6133 other are considered mutually observable, even without an 6134 'observable' relation being added. 6135- 'positioned' to indicate a specific relative position of two objects, 6136 with tags on the edge used to indicate what the relationship is. 6137 E.g., "the table is 10 feet northwest of the chair" has multiple 6138 possible representations, one of which is a 'positioned' relation 6139 from the table to the chair, with the 'direction' tag set to 6140 'southeast' and the 'distance' tag set to '10 feet'. Note that a 6141 `MetricSpace` may also be used to track coordinate positions of 6142 things; annotating every possible position relationship is not 6143 expected. 6144- 'entranceFor' to indicate which feature contained inside of a node is 6145 enterable from outside the node (possibly from a specific part of 6146 the outside of the node). 'enterTo' is the reciprocal relationship. 6147 'entranceFor' applies from the interior region to the exterior node, 6148 while 'enterTo' goes the other way. Note that you cannot use two 6149 different part specifiers to mark the *same* region as enter-able 6150 from two parts of the same node: each pair of nodes can only have 6151 one 'enteranceFor'/'enterTo' connection between them. 6152- 'optionsFor' to indicate which node associated with an entity holds 6153 the set of options for interaction with that entity. Such nodes are 6154 typically placed within a separate domain from the main exploration 6155 space. The same node could be the options for multiple entities. The 6156 reciprocal is 'hasOptions'. In both cases, a part specifier may be 6157 used to indicate more specifically how the interaction is initiated, 6158 but note that a given pair of entities cannot have multiple 6159 'optionsFor'/'hasOption' links between them. You could have multiple 6160 separate nodes that are 'optionsFor' the same entity with different 6161 parts (or even with no part specified for either, although that 6162 would create ambiguity in terms of action outcomes). 6163- 'interacting' to indicate when one feature is taking action relative 6164 to another. This relationship will have an 'action' tag which will 6165 contain a `FeatureAction` dictionary that specifies the relationship 6166 target as its 'subject'. This does not have a reciprocal, and is 6167 normal ephemeral. 6168- 'triggeredBy' to indicate when some kind of action with a feature 6169 triggers an affordance. The reciprocal is 'triggers'. The link tag 6170 'triggerInfo' will specify: 6171 * 'action': the action whose use trips the trigger (one of the 6172 `FeatureAffordance`s) 6173 * 'directions' (optional): A set of directions, one of which must 6174 match the specified direction of a `FeatureAction` for the 6175 trigger to trip. When this key is not present, no direction 6176 filtering is applied. 6177 * 'parts' (optional): A set of part specifiers, one of which must 6178 match the specified action part for the trigger to trip. When 6179 this key is not present, no part filtering is applied. 6180 * 'entityTags' (optional): A set of entity tags, any of which must 6181 match a tag on an interacting entity for the trigger to trip. 6182 Items in the set may also be tuples of multiple tags, in which 6183 case all items in the tuple must match for the entity to 6184 qualify. 6185 6186Note that any of these relationships can be tagged as 'temporary' to 6187imply malleability. For example, a bus node could be temporarily 'at' a 6188bus stop node and 'within' a corresponding region, but then those 6189relationships could change when it moves on. 6190""" 6191 6192FREL_RECIPROCALS: Dict[ 6193 FeatureRelationshipType, 6194 FeatureRelationshipType 6195] = { 6196 "contains": "within", 6197 "within": "contains", 6198 "touches": "touches", 6199 "entranceFor": "enterTo", 6200 "enterTo": "entranceFor", 6201 "optionsFor": "hasOptions", 6202 "hasOptions": "optionsFor", 6203 "triggeredBy": "triggers", 6204 "triggers": "triggeredBy", 6205} 6206""" 6207The reciprocal feature relation types for each `FeatureRelationshipType` 6208which has a required reciprocal. 6209""" 6210 6211 6212class FeatureDecision(TypedDict): 6213 """ 6214 Represents a decision made during exploration, including the 6215 position(s) at which the explorer made the decision, which 6216 feature(s) were most relevant to the decision and what course of 6217 action was decided upon (see `FeatureAction`). Has the following 6218 slots: 6219 6220 - 'type': The type of decision (see `exploration.core.DecisionType`). 6221 - 'domains': A set of domains which are active during the decision, 6222 as opposed to domains which may be unfocused or otherwise 6223 inactive. 6224 - 'focus': An optional single `FeatureSpecifier` which represents the 6225 focal character or object for a decision. May be `None` e.g. in 6226 cases where a menu is in focus. Note that the 'positions' slot 6227 determines which positions are relevant to the decision, 6228 potentially separately from the focus but usually overlapping it. 6229 - 'positions': A dictionary mapping `core.Domain`s to sets of 6230 `FeatureSpecifier`s representing the player's position(s) in 6231 each domain. Some domains may function like tech trees, where 6232 the set of positions only expands over time. Others may function 6233 like a single avatar in a virtual world, where there is only one 6234 position. Still others might function like a group of virtual 6235 avatars, with multiple positions that can be updated 6236 independently. 6237 - 'intention': A `FeatureAction` indicating the action taken or 6238 attempted next as a result of the decision. 6239 """ 6240 # TODO: HERE 6241 pass 6242 6243 6244FeatureAffordance = Literal[ 6245 'approach', 6246 'recede', 6247 'follow', 6248 'cross', 6249 'enter', 6250 'exit', 6251 'explore', 6252 'scrutinize', 6253 'do', 6254 'interact', 6255 'focus', 6256] 6257""" 6258The list of verbs that can be used to express actions taken in relation 6259to features in a feature graph: 6260 6261- 'approach' and 'recede' apply to nodes, paths, edges, regions, and 6262 landmarks, and indicate movement towards or away from the feature. 6263- 'follow' applies to paths and edges, and indicates travelling along. 6264 May be bundled with a direction indicator, although this can 6265 sometimes be inferred (e.g., if you're starting at a node that's 6266 touching one end of a path). For edges, a side-indicator may also be 6267 included. A destination-indicator can be used to indicate where 6268 along the item you end up (according to which other feature touching 6269 it you arrive at). 6270- 'cross' applies to nodes, paths, edges, and regions, and may include a 6271 destination indicator when there are multiple possible destinations 6272 on the other side of the target from the current position. 6273- 'enter' and 'exit' apply to regions and nodes, and indicate going 6274 inside of or coming out of the feature. The 'entranceFor' and 6275 'enterTo' relations are used to indicate where you'll end up when 6276 entering a node, note that there can be multiple of these attached 6277 to different parts of the node. A destination indicator can also be 6278 specified on the action. 6279- 'explore' applies to regions, nodes, and paths, and edges, and 6280 indicates a general lower-fractal-level series of actions taken to 6281 gain more complete knowledge about the target. 6282- 'scrutinize' applies to any feature and indicates carefully probing 6283 the details of the feature to learn more about it (e.g., to look for 6284 a secret). 6285- 'do' applies to affordances, and indicates performing whatever special 6286 action they represent. 6287- 'interact' applies to entities, and indicates some kind of generic 6288 interaction with the entity. For more specific interactions, you can 6289 do one of two things: 6290 1. Place affordances touching or within the entity. 6291 2. Use an 'optionsFor' link to indicate which node (typically in a 6292 separate domain) represents the options made available by an 6293 interaction. 6294- 'focus' applies to any kind of node, but usually entities. It 6295 represents changing the focal object/character for the player. 6296 However, note that focus shifts often happen without this affordance 6297 being involved, such as when entering a menu. 6298""" 6299 6300FEATURE_TYPE_AFFORDANCES: Dict[FeatureAffordance, Set[FeatureType]] = { 6301 'approach': {'node', 'path', 'edge', 'region', 'landmark', 'entity'}, 6302 'recede': {'node', 'path', 'edge', 'region', 'landmark', 'entity'}, 6303 'follow': {'edge', 'path', 'entity'}, 6304 'cross': {'node', 'path', 'edge', 'region'}, 6305 'enter': {'node', 'region'}, 6306 'exit': {'node', 'region'}, 6307 'explore': {'node', 'path', 'edge', 'region'}, 6308 'scrutinize': { 6309 'node', 'path', 'edge', 'region', 'landmark', 'affordance', 6310 'entity' 6311 }, 6312 'do': {'affordance'}, 6313 'interact': {'node', 'entity'}, 6314} 6315""" 6316The mapping from affordances to the sets of feature types those 6317affordances apply to. 6318""" 6319 6320 6321class FeatureEffect(TypedDict): 6322 """ 6323 Similar to `Effect` but with more options for how to manipulate the 6324 game state. This represents a single concrete change to either 6325 internal game state, or to the feature graph. Multiple changes 6326 (possibly with random factors involved) can be represented by a 6327 `Consequence`; a `FeatureEffect` is used as a leaf in a `Consequence` 6328 tree. 6329 """ 6330 type: Literal[ 6331 'gain', 6332 'lose', 6333 'toggle', 6334 'deactivate', 6335 'move', 6336 'focus', 6337 'initiate' 6338 'foreground', 6339 'background', 6340 ] 6341 value: Union[ 6342 Capability, 6343 Tuple[Token, int], 6344 List[Capability], 6345 None 6346 ] 6347 charges: Optional[int] 6348 delay: Optional[int] 6349 6350 6351def featureEffect( 6352 #applyTo: ContextSpecifier = 'active', 6353 #gain: Optional[Union[ 6354 # Capability, 6355 # Tuple[Token, TokenCount], 6356 # Tuple[Literal['skill'], Skill, Level] 6357 #]] = None, 6358 #lose: Optional[Union[ 6359 # Capability, 6360 # Tuple[Token, TokenCount], 6361 # Tuple[Literal['skill'], Skill, Level] 6362 #]] = None, 6363 #set: Optional[Union[ 6364 # Tuple[Token, TokenCount], 6365 # Tuple[AnyMechanismSpecifier, MechanismState], 6366 # Tuple[Literal['skill'], Skill, Level] 6367 #]] = None, 6368 #toggle: Optional[Union[ 6369 # Tuple[AnyMechanismSpecifier, List[MechanismState]], 6370 # List[Capability] 6371 #]] = None, 6372 #deactivate: Optional[bool] = None, 6373 #edit: Optional[List[List[commands.Command]]] = None, 6374 #goto: Optional[Union[ 6375 # AnyDecisionSpecifier, 6376 # Tuple[AnyDecisionSpecifier, FocalPointName] 6377 #]] = None, 6378 #bounce: Optional[bool] = None, 6379 #delay: Optional[int] = None, 6380 #charges: Optional[int] = None, 6381 **kwargs 6382): 6383 # TODO: HERE 6384 return effect(**kwargs) 6385 6386# TODO: FeatureConsequences? 6387 6388 6389class FeatureAction(TypedDict): 6390 """ 6391 Indicates an action decided on by a `FeatureDecision`. Has the 6392 following slots: 6393 6394 - 'subject': the main feature (an `AnyFeatureSpecifier`) that 6395 performs the action (usually an 'entity'). 6396 - 'object': the main feature (an `AnyFeatureSpecifier`) with which 6397 the affordance is performed. 6398 - 'affordance': the specific `FeatureAffordance` indicating the type 6399 of action. 6400 - 'direction': The general direction of movement (especially when 6401 the affordance is `follow`). This can be either a direction in 6402 an associated `MetricSpace`, or it can be defined towards or 6403 away from the destination specified. If a destination but no 6404 direction is provided, the direction is assumed to be towards 6405 that destination. 6406 - 'part': The part within/along a feature for movement (e.g., which 6407 side of an edge are you on, or which part of a region are you 6408 traveling through). 6409 - 'destination': The destination of the action (when known ahead of 6410 time). For example, moving along a path towards a particular 6411 feature touching that path, or entering a node into a particular 6412 feature within that node. Note that entering of regions can be 6413 left implicit: if you enter a region to get to a landmark within 6414 it, noting that as approaching the landmark is more appropriate 6415 than noting that as entering the region with the landmark as the 6416 destination. The system can infer what regions you're in by 6417 which feature you're at. 6418 - 'outcome': A `Consequence` list/tree indicating one or more 6419 outcomes, possibly involving challenges. Note that the actual 6420 outcomes of an action may be altered by triggers; the outcomes 6421 listed here are the default outcomes if no triggers are tripped. 6422 6423 The 'direction', 'part', and/or 'destination' may each be None, 6424 depending on the type of affordance and/or amount of detail desired. 6425 """ 6426 subject: AnyFeatureSpecifier 6427 object: AnyFeatureSpecifier 6428 affordance: FeatureAffordance 6429 direction: Optional[Part] 6430 part: Optional[Part] 6431 destination: Optional[AnyFeatureSpecifier] 6432 outcome: Consequence
Default domain 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.
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.
205class DecisionSpecifier(NamedTuple): 206 """ 207 A decision specifier attempts to uniquely identify a decision by 208 name, rather than by ID. See `AnyDecisionSpecifier` for a type which 209 can also be an ID. 210 211 Ambiguity is possible if two decisions share the same name; the 212 decision specifier provides two means of disambiguation: a domain 213 may be specified, and a zone may be specified; if either is 214 specified only decisions within that domain and/or zone will match, 215 but of course there could still be multiple decisions that match 216 those criteria that still share names, in which case many operations 217 will end up raising an `AmbiguousDecisionSpecifierError`. 218 """ 219 domain: Optional[Domain] 220 zone: Optional[Zone] 221 name: DecisionName
A decision specifier attempts to uniquely identify a decision by
name, rather than by ID. See AnyDecisionSpecifier
for a type which
can also be an ID.
Ambiguity is possible if two decisions share the same name; the
decision specifier provides two means of disambiguation: a domain
may be specified, and a zone may be specified; if either is
specified only decisions within that domain and/or zone will match,
but of course there could still be multiple decisions that match
those criteria that still share names, in which case many operations
will end up raising an AmbiguousDecisionSpecifierError
.
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.
233class InvalidDecisionSpecifierError(ValueError): 234 """ 235 An error used when a decision specifier is in the wrong format. 236 """
An error used when a decision specifier is in the wrong format.
Inherited Members
- builtins.ValueError
- ValueError
- builtins.BaseException
- with_traceback
- add_note
- args
239class InvalidMechanismSpecifierError(ValueError): 240 """ 241 An error used when a mechanism specifier is invalid. 242 """
An error used when a mechanism specifier is invalid.
Inherited Members
- builtins.ValueError
- ValueError
- builtins.BaseException
- with_traceback
- add_note
- args
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
.
275def nameAndOutcomes(transition: AnyTransition) -> TransitionWithOutcomes: 276 """ 277 Returns a `TransitionWithOutcomes` when given either one of those 278 already or a base `Transition`. Outcomes will be an empty list when 279 given a transition alone. Checks that the type actually matches. 280 """ 281 if isinstance(transition, Transition): 282 return (transition, []) 283 else: 284 if not isinstance(transition, tuple) or len(transition) != 2: 285 raise TypeError( 286 f"Transition with outcomes must be a length-2 tuple." 287 f" Got: {transition!r}" 288 ) 289 name, outcomes = transition 290 if not isinstance(name, Transition): 291 raise TypeError( 292 f"Transition name must be a string." 293 f" Got: {name!r}" 294 ) 295 if ( 296 not isinstance(outcomes, list) 297 or not all(isinstance(x, bool) for x in outcomes) 298 ): 299 raise TypeError( 300 f"Transition outcomes must be a list of booleans." 301 f" Got: {outcomes!r}" 302 ) 303 return transition
Returns a TransitionWithOutcomes
when given either one of those
already or a base Transition
. Outcomes will be an empty list when
given a transition alone. Checks that the type actually matches.
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.
413class MechanismSpecifier(NamedTuple): 414 """ 415 Specifies a mechanism either just by name, or with domain and/or 416 zone and/or decision name hints. 417 """ 418 domain: Optional[Domain] 419 zone: Optional[Zone] 420 decision: Optional[DecisionName] 421 name: MechanismName
Specifies a mechanism either just by name, or with domain and/or zone and/or decision name hints.
Create new instance of MechanismSpecifier(domain, zone, decision, name)
Inherited Members
- builtins.tuple
- index
- count
424def mechanismAt( 425 name: MechanismName, 426 domain: Optional[Domain] = None, 427 zone: Optional[Zone] = None, 428 decision: Optional[DecisionName] = None 429) -> MechanismSpecifier: 430 """ 431 Builds a `MechanismSpecifier` using `None` default hints but 432 accepting `domain`, `zone`, and/or `decision` hints. 433 """ 434 return MechanismSpecifier(domain, zone, decision, name)
Builds a MechanismSpecifier
using None
default hints but
accepting domain
, zone
, and/or decision
hints.
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.
469class CapabilitySet(TypedDict): 470 """ 471 Represents a set of capabilities, including boolean on/off 472 `Capability` names, countable `Token`s accumulated, and 473 integer-leveled skills. It has three slots: 474 475 - 'capabilities': A set representing which `Capability`s this 476 `CapabilitySet` includes. 477 - 'tokens': A dictionary mapping `Token` types to integers 478 representing how many of that token type this `CapabilitySet` has 479 accumulated. 480 - 'skills': A dictionary mapping `Skill` types to `Level` integers, 481 representing what skill levels this `CapabilitySet` has. 482 """ 483 capabilities: Set[Capability] 484 tokens: Dict[Token, TokenCount] 485 skills: Dict[Skill, Level]
Represents a set of capabilities, including boolean on/off
Capability
names, countable 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
).
554class FocalContext(TypedDict): 555 """ 556 Focal contexts identify an avatar or similar situation where the player 557 has certain capabilities available (a `CapabilitySet`) and may also have 558 position information in one or more `Domain`s (see `State` and 559 `DomainFocalization`). Normally, only a single `FocalContext` is needed, 560 but situations where the player swaps between capability sets and/or 561 positions sometimes call for more. 562 563 At each decision step, only a single `FocalContext` is active, and the 564 capabilities of that context (plus capabilities of the 'common' 565 context) determine what transitions are traversable. At the same time, 566 the set of reachable transitions is determined by the focal context's 567 per-domain position information, including its per-domain 568 `DomainFocalization` type. 569 570 The slots are: 571 572 - 'capabilities': A `CapabilitySet` representing what capabilities, 573 tokens, and skills this context has. Note that capabilities from 574 the common `FocalContext` are added to these to determine what 575 transition requirements are met in a given step. 576 - 'focalization': A mapping from `Domain`s to `DomainFocalization` 577 specifying how this context is focalized in each domain. 578 - 'activeDomains': A set of `Domain`s indicating which `Domain`(s) are 579 active for this focal context right now. 580 - 'activeDecisions': A mapping from `Domain`s to either single 581 `DecisionID`s, dictionaries mapping `FocalPointName`s to 582 optional `DecisionID`s, or sets of `DecisionID`s. Which one is 583 used depends on the `DomainFocalization` of this context for 584 that domain. May also be `None` for domains in which no 585 decisions are active (and in 'plural'-focalization lists, 586 individual entries may be `None`). Active decisions from the 587 common `FocalContext` are also considered active at each step. 588 """ 589 capabilities: CapabilitySet 590 focalization: Dict[Domain, DomainFocalization] 591 activeDomains: Set[Domain] 592 activeDecisions: Dict[ 593 Domain, 594 Union[ 595 None, 596 DecisionID, 597 Dict[FocalPointName, Optional[DecisionID]], 598 Set[DecisionID] 599 ] 600 ]
Focal contexts identify an avatar or similar situation where the player
has certain capabilities available (a CapabilitySet
) and may also have
position information in one or more 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.
611def getDomainFocalization( 612 context: FocalContext, 613 domain: Domain, 614 defaultFocalization: DomainFocalization = 'singular' 615) -> DomainFocalization: 616 """ 617 Fetches the focalization value for the given domain in the given 618 focal context, setting it to the provided default first if that 619 focal context didn't have an entry for that domain yet. 620 """ 621 return context['focalization'].setdefault(domain, defaultFocalization)
Fetches the focalization value for the given domain in the given focal context, setting it to the provided default first if that focal context didn't have an entry for that domain yet.
624class State(TypedDict): 625 """ 626 Represents a game state, including certain exploration-relevant 627 information, plus possibly extra custom information. Has the 628 following slots: 629 630 - 'common': A single `FocalContext` containing capability and position 631 information which is always active in addition to the current 632 `FocalContext`'s information. 633 - 'contexts': A dictionary mapping strings to `FocalContext`s, which 634 store capability and position information. 635 - 'activeContext': A string naming the currently-active 636 `FocalContext` (a key of the 'contexts' slot). 637 - 'primaryDecision': A `DecisionID` (or `None`) indicating the 638 primary decision that is being considered in this state. Whereas 639 the focalization structures can and often will indicate multiple 640 active decisions, whichever decision the player just arrived at 641 via the transition selected in a previous state will be the most 642 relevant, and we track that here. Of course, for some states 643 (like a pre-starting initial state) there is no primary 644 decision. 645 - 'mechanisms': A dictionary mapping `Mechanism` IDs to 646 `MechanismState` strings. 647 - 'exploration': A dictionary mapping decision IDs to exploration 648 statuses, which tracks how much knowledge the player has of 649 different decisions. 650 - 'effectCounts': A dictionary mapping `EffectSpecifier`s to 651 integers specifying how many times that effect has been 652 triggered since the beginning of the exploration (including 653 times that the actual effect was not applied due to delays 654 and/or charges. This is used to figure out when effects with 655 charges and/or delays should be applied. 656 - 'deactivated': A set of (`DecisionID`, `Transition`) tuples 657 specifying which transitions have been deactivated. This is used 658 in addition to transition requirements to figure out which 659 transitions are traversable. 660 - 'custom': An arbitrary sub-dictionary representing any kind of 661 custom game state. In most cases, things can be reasonably 662 approximated via capabilities and tokens and custom game state is 663 not needed. 664 """ 665 common: FocalContext 666 contexts: Dict[FocalContextName, FocalContext] 667 activeContext: FocalContextName 668 primaryDecision: Optional[DecisionID] 669 mechanisms: Dict[MechanismID, MechanismState] 670 exploration: Dict[DecisionID, 'ExplorationStatus'] 671 effectCounts: Dict[EffectSpecifier, int] 672 deactivated: Set[Tuple[DecisionID, Transition]] 673 custom: dict
Represents a game state, including certain exploration-relevant information, plus possibly extra custom information. Has the following slots:
- 'common': A single
FocalContext
containing capability and position information which is always active in addition to the 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.
680def idOrDecisionSpecifier( 681 ds: DecisionSpecifier 682) -> Union[DecisionSpecifier, int]: 683 """ 684 Given a decision specifier which might use a name that's convertible 685 to an integer ID, returns the appropriate ID if so, and the original 686 decision specifier if not, raising an 687 `InvalidDecisionSpecifierError` if given a specifier with a 688 convertible name that also has other parts. 689 """ 690 try: 691 dID = int(ds.name) 692 except ValueError: 693 return ds 694 695 if ds.domain is None and ds.zone is None: 696 return dID 697 else: 698 raise InvalidDecisionSpecifierError( 699 f"Specifier {ds} has an ID name but also includes" 700 f" domain and/or zone information." 701 )
Given a decision specifier which might use a name that's convertible
to an integer ID, returns the appropriate ID if so, and the original
decision specifier if not, raising an
InvalidDecisionSpecifierError
if given a specifier with a
convertible name that also has other parts.
704def spliceDecisionSpecifiers( 705 base: DecisionSpecifier, 706 default: DecisionSpecifier 707) -> DecisionSpecifier: 708 """ 709 Copies domain and/or zone info from the `default` specifier into the 710 `base` specifier, returning a new `DecisionSpecifier` without 711 modifying either argument. Info is only copied where the `base` 712 specifier has a missing value, although if the base specifier has a 713 domain but no zone and the domain is different from that of the 714 default specifier, no zone info is copied. 715 716 For example: 717 718 >>> d1 = DecisionSpecifier('main', 'zone', 'name') 719 >>> d2 = DecisionSpecifier('niam', 'enoz', 'eman') 720 >>> spliceDecisionSpecifiers(d1, d2) 721 DecisionSpecifier(domain='main', zone='zone', name='name') 722 >>> spliceDecisionSpecifiers(d2, d1) 723 DecisionSpecifier(domain='niam', zone='enoz', name='eman') 724 >>> d3 = DecisionSpecifier(None, None, 'three') 725 >>> spliceDecisionSpecifiers(d3, d1) 726 DecisionSpecifier(domain='main', zone='zone', name='three') 727 >>> spliceDecisionSpecifiers(d3, d2) 728 DecisionSpecifier(domain='niam', zone='enoz', name='three') 729 >>> d4 = DecisionSpecifier('niam', None, 'four') 730 >>> spliceDecisionSpecifiers(d4, d1) # diff domain -> no zone 731 DecisionSpecifier(domain='niam', zone=None, name='four') 732 >>> spliceDecisionSpecifiers(d4, d2) # same domian -> copy zone 733 DecisionSpecifier(domain='niam', zone='enoz', name='four') 734 >>> d5 = DecisionSpecifier(None, 'cone', 'five') 735 >>> spliceDecisionSpecifiers(d4, d5) # None domain -> copy zone 736 DecisionSpecifier(domain='niam', zone='cone', name='four') 737 """ 738 newDomain = base.domain 739 if newDomain is None: 740 newDomain = default.domain 741 newZone = base.zone 742 if ( 743 newZone is None 744 and (newDomain == default.domain or default.domain is None) 745 ): 746 newZone = default.zone 747 748 return DecisionSpecifier(domain=newDomain, zone=newZone, name=base.name)
Copies domain and/or zone info from the default
specifier into the
base
specifier, returning a new DecisionSpecifier
without
modifying either argument. Info is only copied where the base
specifier has a missing value, although if the base specifier has a
domain but no zone and the domain is different from that of the
default specifier, no zone info is copied.
For example:
>>> d1 = DecisionSpecifier('main', 'zone', 'name')
>>> d2 = DecisionSpecifier('niam', 'enoz', 'eman')
>>> spliceDecisionSpecifiers(d1, d2)
DecisionSpecifier(domain='main', zone='zone', name='name')
>>> spliceDecisionSpecifiers(d2, d1)
DecisionSpecifier(domain='niam', zone='enoz', name='eman')
>>> d3 = DecisionSpecifier(None, None, 'three')
>>> spliceDecisionSpecifiers(d3, d1)
DecisionSpecifier(domain='main', zone='zone', name='three')
>>> spliceDecisionSpecifiers(d3, d2)
DecisionSpecifier(domain='niam', zone='enoz', name='three')
>>> d4 = DecisionSpecifier('niam', None, 'four')
>>> spliceDecisionSpecifiers(d4, d1) # diff domain -> no zone
DecisionSpecifier(domain='niam', zone=None, name='four')
>>> spliceDecisionSpecifiers(d4, d2) # same domian -> copy zone
DecisionSpecifier(domain='niam', zone='enoz', name='four')
>>> d5 = DecisionSpecifier(None, 'cone', 'five')
>>> spliceDecisionSpecifiers(d4, d5) # None domain -> copy zone
DecisionSpecifier(domain='niam', zone='cone', name='four')
751def mergeCapabilitySets(A: CapabilitySet, B: CapabilitySet) -> CapabilitySet: 752 """ 753 Merges two capability sets into a new one, where all capabilities in 754 either original set are active, and token counts and skill levels are 755 summed. 756 757 Example: 758 759 >>> cs1 = { 760 ... 'capabilities': {'fly', 'whistle'}, 761 ... 'tokens': {'subway': 3}, 762 ... 'skills': {'agility': 1, 'puzzling': 3}, 763 ... } 764 >>> cs2 = { 765 ... 'capabilities': {'dig', 'fly'}, 766 ... 'tokens': {'subway': 1, 'goat': 2}, 767 ... 'skills': {'agility': -1}, 768 ... } 769 >>> ms = mergeCapabilitySets(cs1, cs2) 770 >>> ms['capabilities'] == {'fly', 'whistle', 'dig'} 771 True 772 >>> ms['tokens'] == {'subway': 4, 'goat': 2} 773 True 774 >>> ms['skills'] == {'agility': 0, 'puzzling': 3} 775 True 776 """ 777 # Set up our result 778 result: CapabilitySet = { 779 'capabilities': set(), 780 'tokens': {}, 781 'skills': {} 782 } 783 784 # Merge capabilities 785 result['capabilities'].update(A['capabilities']) 786 result['capabilities'].update(B['capabilities']) 787 788 # Merge tokens 789 tokensA = A['tokens'] 790 tokensB = B['tokens'] 791 resultTokens = result['tokens'] 792 for tType, val in tokensA.items(): 793 if tType not in resultTokens: 794 resultTokens[tType] = val 795 else: 796 resultTokens[tType] += val 797 for tType, val in tokensB.items(): 798 if tType not in resultTokens: 799 resultTokens[tType] = val 800 else: 801 resultTokens[tType] += val 802 803 # Merge skills 804 skillsA = A['skills'] 805 skillsB = B['skills'] 806 resultSkills = result['skills'] 807 for skill, level in skillsA.items(): 808 if skill not in resultSkills: 809 resultSkills[skill] = level 810 else: 811 resultSkills[skill] += level 812 for skill, level in skillsB.items(): 813 if skill not in resultSkills: 814 resultSkills[skill] = level 815 else: 816 resultSkills[skill] += level 817 818 return result
Merges two capability sets into a new one, where all capabilities in either original set are active, and token counts and skill levels are summed.
Example:
>>> cs1 = {
... 'capabilities': {'fly', 'whistle'},
... 'tokens': {'subway': 3},
... 'skills': {'agility': 1, 'puzzling': 3},
... }
>>> cs2 = {
... 'capabilities': {'dig', 'fly'},
... 'tokens': {'subway': 1, 'goat': 2},
... 'skills': {'agility': -1},
... }
>>> ms = mergeCapabilitySets(cs1, cs2)
>>> ms['capabilities'] == {'fly', 'whistle', 'dig'}
True
>>> ms['tokens'] == {'subway': 4, 'goat': 2}
True
>>> ms['skills'] == {'agility': 0, 'puzzling': 3}
True
821def emptyFocalContext() -> FocalContext: 822 """ 823 Returns a completely empty focal context, which has no capabilities 824 and which has no associated domains. 825 """ 826 return { 827 'capabilities': { 828 'capabilities': set(), 829 'tokens': {}, 830 'skills': {} 831 }, 832 'focalization': {}, 833 'activeDomains': set(), 834 'activeDecisions': {} 835 }
Returns a completely empty focal context, which has no capabilities and which has no associated domains.
838def basicFocalContext( 839 domain: Optional[Domain] = None, 840 focalization: DomainFocalization = 'singular' 841): 842 """ 843 Returns a basic focal context, which has no capabilities and which 844 uses the given focalization (default 'singular') for a single 845 domain with the given name (default `DEFAULT_DOMAIN`) which is 846 active but which has no position specified. 847 """ 848 if domain is None: 849 domain = DEFAULT_DOMAIN 850 return { 851 'capabilities': { 852 'capabilities': set(), 853 'tokens': {}, 854 'skills': {} 855 }, 856 'focalization': {domain: focalization}, 857 'activeDomains': {domain}, 858 'activeDecisions': {domain: None} 859 }
Returns a basic focal context, which has no capabilities and which
uses the given focalization (default 'singular') for a single
domain with the given name (default DEFAULT_DOMAIN
) which is
active but which has no position specified.
862def emptyState() -> State: 863 """ 864 Returns an empty `State` dictionary. The empty dictionary uses 865 `DEFAULT_FOCAL_CONTEXT_NAME` as the name of the active 866 `FocalContext`. 867 """ 868 return { 869 'common': emptyFocalContext(), 870 'contexts': {DEFAULT_FOCAL_CONTEXT_NAME: basicFocalContext()}, 871 'activeContext': DEFAULT_FOCAL_CONTEXT_NAME, 872 'primaryDecision': None, 873 'mechanisms': {}, 874 'exploration': {}, 875 'effectCounts': {}, 876 'deactivated': set(), 877 'custom': {} 878 }
Returns an empty State
dictionary. The empty dictionary uses
DEFAULT_FOCAL_CONTEXT_NAME
as the name of the active
FocalContext
.
881def basicState( 882 context: Optional[FocalContextName] = None, 883 domain: Optional[Domain] = None, 884 focalization: DomainFocalization = 'singular' 885) -> State: 886 """ 887 Returns a `State` dictionary with a newly created single active 888 focal context that uses the given name (default 889 `DEFAULT_FOCAL_CONTEXT_NAME`). This context is created using 890 `basicFocalContext` with the given domain and focalization as 891 arguments (defaults `DEFAULT_DOMAIN` and 'singular'). 892 """ 893 if context is None: 894 context = DEFAULT_FOCAL_CONTEXT_NAME 895 return { 896 'common': emptyFocalContext(), 897 'contexts': {context: basicFocalContext(domain, focalization)}, 898 'activeContext': context, 899 'primaryDecision': None, 900 'mechanisms': {}, 901 'exploration': {}, 902 'effectCounts': {}, 903 'deactivated': set(), 904 'custom': {} 905 }
Returns a State
dictionary with a newly created single active
focal context that uses the given name (default
DEFAULT_FOCAL_CONTEXT_NAME
). This context is created using
basicFocalContext
with the given domain and focalization as
arguments (defaults DEFAULT_DOMAIN
and 'singular').
908def effectiveCapabilitySet(state: State) -> CapabilitySet: 909 """ 910 Given a `baseTypes.State` object, computes the effective capability 911 set for that state, which merges capabilities and tokens from the 912 common `baseTypes.FocalContext` with those of the active one. 913 914 Returns a `CapabilitySet`. 915 """ 916 # Grab relevant contexts 917 commonContext = state['common'] 918 activeContext = state['contexts'][state['activeContext']] 919 920 # Extract capability dictionaries 921 commonCapabilities = commonContext['capabilities'] 922 activeCapabilities = activeContext['capabilities'] 923 924 return mergeCapabilitySets( 925 commonCapabilities, 926 activeCapabilities 927 )
Given a baseTypes.State
object, computes the effective capability
set for that state, which merges capabilities and tokens from the
common baseTypes.FocalContext
with those of the active one.
Returns a CapabilitySet
.
930def combinedDecisionSet(state: State) -> Set[DecisionID]: 931 """ 932 Given a `State` object, computes the active decision set for that 933 state, which is the set of decisions at which the player can make an 934 immediate decision. This depends on the 'common' `FocalContext` as 935 well as the active focal context, and of course each `FocalContext` 936 may specify separate active decisions for different domains, separate 937 sets of active domains, etc. See `FocalContext` and 938 `DomainFocalization` for more details, as well as `activeDecisionSet`. 939 940 Returns a set of `DecisionID`s. 941 """ 942 commonContext = state['common'] 943 activeContext = state['contexts'][state['activeContext']] 944 result = set() 945 for ctx in (commonContext, activeContext): 946 result |= activeDecisionSet(ctx) 947 948 return result
Given a State
object, computes the active decision set for that
state, which is the set of decisions at which the player can make an
immediate decision. This depends on the 'common' FocalContext
as
well as the active focal context, and of course each FocalContext
may specify separate active decisions for different domains, separate
sets of active domains, etc. See FocalContext
and
DomainFocalization
for more details, as well as activeDecisionSet
.
Returns a set of DecisionID
s.
951def activeDecisionSet(context: FocalContext) -> Set[DecisionID]: 952 """ 953 Given a `FocalContext`, returns the set of all `DecisionID`s which 954 are active in that focal context. This includes only decisions which 955 are in active domains. 956 957 For example: 958 959 >>> fc = emptyFocalContext() 960 >>> activeDecisionSet(fc) 961 set() 962 >>> fc['focalization'] = { 963 ... 'Si': 'singular', 964 ... 'Pl': 'plural', 965 ... 'Sp': 'spreading' 966 ... } 967 >>> fc['activeDomains'] = {'Si'} 968 >>> fc['activeDecisions'] = { 969 ... 'Si': 0, 970 ... 'Pl': {'one': 1, 'two': 2}, 971 ... 'Sp': {3, 4} 972 ... } 973 >>> activeDecisionSet(fc) 974 {0} 975 >>> fc['activeDomains'] = {'Si', 'Pl'} 976 >>> sorted(activeDecisionSet(fc)) 977 [0, 1, 2] 978 >>> fc['activeDomains'] = {'Pl'} 979 >>> sorted(activeDecisionSet(fc)) 980 [1, 2] 981 >>> fc['activeDomains'] = {'Sp'} 982 >>> sorted(activeDecisionSet(fc)) 983 [3, 4] 984 >>> fc['activeDomains'] = {'Si', 'Pl', 'Sp'} 985 >>> sorted(activeDecisionSet(fc)) 986 [0, 1, 2, 3, 4] 987 """ 988 result = set() 989 decisionsMap = context['activeDecisions'] 990 for domain in context['activeDomains']: 991 activeGroup = decisionsMap[domain] 992 if activeGroup is None: 993 pass 994 elif isinstance(activeGroup, DecisionID): 995 result.add(activeGroup) 996 elif isinstance(activeGroup, dict): 997 for x in activeGroup.values(): 998 if x is not None: 999 result.add(x) 1000 elif isinstance(activeGroup, set): 1001 result.update(activeGroup) 1002 else: 1003 raise TypeError( 1004 f"The FocalContext {repr(context)} has an invalid" 1005 f" active group for domain {repr(domain)}." 1006 f"\nGroup is: {repr(activeGroup)}" 1007 ) 1008 1009 return result
Given a FocalContext
, returns the set of all 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.
1071def moreExplored( 1072 a: ExplorationStatus, 1073 b: ExplorationStatus 1074) -> ExplorationStatus: 1075 """ 1076 Returns whichever of the two exploration statuses counts as 'more 1077 explored'. 1078 """ 1079 eArgs = get_args(ExplorationStatus) 1080 try: 1081 aIndex = eArgs.index(a) 1082 except ValueError: 1083 raise ValueError( 1084 f"Status {a!r} is not a valid exploration status. Must be" 1085 f" one of: {eArgs!r}" 1086 ) 1087 try: 1088 bIndex = eArgs.index(b) 1089 except ValueError: 1090 raise ValueError( 1091 f"Status {b!r} is not a valid exploration status. Must be" 1092 f" one of: {eArgs!r}" 1093 ) 1094 if aIndex > bIndex: 1095 return a 1096 else: 1097 return b
Returns whichever of the two exploration statuses counts as 'more explored'.
1100def statusVisited(status: ExplorationStatus) -> bool: 1101 """ 1102 Returns true or false depending on whether the provided status 1103 indicates a decision has been visited or not. The 'exploring' and 1104 'explored' statuses imply a decision has been visisted, but other 1105 statuses do not. 1106 """ 1107 return status in ('exploring', 'explored')
Returns true or false depending on whether the provided status indicates a decision has been visited or not. The 'exploring' and 'explored' statuses imply a decision has been visisted, but other statuses do not.
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.
1139def revertedState( 1140 currentStuff: Tuple['DecisionGraph', State], 1141 savedStuff: Tuple['DecisionGraph', State], 1142 revisionAspects: Set[str] 1143) -> Tuple['DecisionGraph', State]: 1144 """ 1145 Given two (graph, state) pairs, as well as a set of reversion aspect 1146 strings, returns a (graph, state) pair representing the reverted 1147 graph and state. The provided graphs and states will not be 1148 modified, and the return value will not include references to them, 1149 so modifying the returned state will not modify the original or 1150 saved states or graphs. 1151 1152 If the `revisionAspects` set is empty, then all aspects except 1153 skills, exploration statuses, and the graph will be reverted. 1154 1155 Note that the reversion process can lead to impossible states if the 1156 wrong combination of reversion aspects is used (e.g., reverting the 1157 graph but not focal context position information might lead to 1158 positions that refer to decisions which do not exist). 1159 1160 Valid reversion aspect strings are: 1161 - "common-capabilities", "common-tokens", "common-skills," 1162 "common-positions" or just "common" for all four. These 1163 control the parts of the common context's `CapabilitySet` 1164 that get reverted, as well as whether the focalization, 1165 active domains, and active decisions get reverted (those 1166 three as "positions"). 1167 - "c-*NAME*-capabilities" as well as -tokens, -skills, 1168 -positions, and without a suffix, where *NAME* is the name of 1169 a specific focal context. 1170 - "all-capabilities" as well as -tokens, -skills, -positions, 1171 and -contexts, reverting the relevant part of all focal 1172 contexts except the common one, with "all-contexts" reverting 1173 every part of all non-common focal contexts. 1174 - "current-capabilities" as well as -tokens, -skills, -positions, 1175 and without a suffix, for the currently-active focal context. 1176 - "primary" which reverts the primary decision (some positions should 1177 also be reverted in this case). 1178 - "mechanisms" which reverts mechanism states. 1179 - "exploration" which reverts the exploration state of decisions 1180 (note that the `DecisionGraph` also stores "unconfirmed" tags 1181 which are NOT affected by a revert unless "graph" is specified). 1182 - "effects" which reverts the record of how many times transition 1183 effects have been triggered, plus whether transitions have 1184 been disabled or not. 1185 - "custom" which reverts custom state. 1186 - "graph" reverts the graph itself (but this is usually not 1187 desired). This will still preserve the next-ID value for 1188 assigning new nodes, so that nodes created in a reverted graph 1189 will not re-use IDs from nodes created before the reversion. 1190 - "-*NAME*" where *NAME* is a custom reversion specification 1191 defined using `core.DecisionGraph.reversionType` and available 1192 in the "current" decision graph (note the colon is required 1193 before the custom name). This allows complex reversion systems 1194 to be set up once and referenced repeatedly. Any strings 1195 specified along with a custom reversion type will revert the 1196 specified state in addition to what the custom reversion type 1197 specifies. 1198 1199 For example: 1200 1201 >>> from . import core 1202 >>> g = core.DecisionGraph.example("simple") # A - B - C triangle 1203 >>> g.setTransitionRequirement('B', 'next', ReqCapability('helmet')) 1204 >>> g.addAction( 1205 ... 'A', 1206 ... 'getHelmet', 1207 ... consequence=[effect(gain='helmet'), effect(deactivate=True)] 1208 ... ) 1209 >>> s0 = basicState() 1210 >>> fc0 = s0['contexts']['main'] 1211 >>> fc0['activeDecisions']['main'] = 0 # A 1212 >>> s1 = basicState() 1213 >>> fc1 = s1['contexts']['main'] 1214 >>> fc1['capabilities']['capabilities'].add('helmet') 1215 >>> fc1['activeDecisions']['main'] = 1 # B 1216 >>> s1['exploration'] = {0: 'explored', 1: 'exploring', 2: 'unknown'} 1217 >>> s1['effectCounts'] = {(0, 'getHelmet', 1): 1} 1218 >>> s1['deactivated'] = {(0, "getHelmet")} 1219 >>> # Basic reversion of everything except graph & exploration 1220 >>> rg, rs = revertedState((g, s1), (g, s0), set()) 1221 >>> rg == g 1222 True 1223 >>> rg is g 1224 False 1225 >>> rs == s0 1226 False 1227 >>> rs is s0 1228 False 1229 >>> rs['contexts'] == s0['contexts'] 1230 True 1231 >>> rs['exploration'] == s1['exploration'] 1232 True 1233 >>> rs['effectCounts'] = s0['effectCounts'] 1234 >>> rs['deactivated'] = s0['deactivated'] 1235 >>> # Reverting capabilities but not position, exploration, or effects 1236 >>> rg, rs = revertedState((g, s1), (g, s0), {"current-capabilities"}) 1237 >>> rg == g 1238 True 1239 >>> rs == s0 or rs == s1 1240 False 1241 >>> s1['contexts']['main']['capabilities']['capabilities'] 1242 {'helmet'} 1243 >>> s0['contexts']['main']['capabilities']['capabilities'] 1244 set() 1245 >>> rs['contexts']['main']['capabilities']['capabilities'] 1246 set() 1247 >>> s1['contexts']['main']['activeDecisions']['main'] 1248 1 1249 >>> s0['contexts']['main']['activeDecisions']['main'] 1250 0 1251 >>> rs['contexts']['main']['activeDecisions']['main'] 1252 1 1253 >>> # Restore position and effects; that's all that wasn't reverted 1254 >>> rs['contexts']['main']['activeDecisions']['main'] = 0 1255 >>> rs['exploration'] = {} 1256 >>> rs['effectCounts'] = {} 1257 >>> rs['deactivated'] = set() 1258 >>> rs == s0 1259 True 1260 >>> # Reverting position but not state 1261 >>> rg, rs = revertedState((g, s1), (g, s0), {"current-positions"}) 1262 >>> rg == g 1263 True 1264 >>> s1['contexts']['main']['capabilities']['capabilities'] 1265 {'helmet'} 1266 >>> s0['contexts']['main']['capabilities']['capabilities'] 1267 set() 1268 >>> rs['contexts']['main']['capabilities']['capabilities'] 1269 {'helmet'} 1270 >>> s1['contexts']['main']['activeDecisions']['main'] 1271 1 1272 >>> s0['contexts']['main']['activeDecisions']['main'] 1273 0 1274 >>> rs['contexts']['main']['activeDecisions']['main'] 1275 0 1276 >>> # Reverting based on specific focal context name 1277 >>> rg2, rs2 = revertedState((g, s1), (g, s0), {"c-main-positions"}) 1278 >>> rg2 == rg 1279 True 1280 >>> rs2 == rs 1281 True 1282 >>> # Test of graph reversion 1283 >>> import copy 1284 >>> g2 = copy.deepcopy(g) 1285 >>> g2.addDecision('D') 1286 3 1287 >>> g2.addTransition(2, 'alt', 'D', 'return') 1288 >>> rg, rs = revertedState((g2, s1), (g, s0), {'graph'}) 1289 >>> rg == g 1290 True 1291 >>> rg is g 1292 False 1293 1294 TODO: More tests for various other reversion aspects 1295 TODO: Implement per-token-type / per-capability / per-mechanism / 1296 per-skill reversion. 1297 """ 1298 # Expand custom references 1299 expandedAspects = set() 1300 queue = list(revisionAspects) 1301 if len(queue) == 0: 1302 queue = [ # don't revert skills, exploration, and graph 1303 "common-capabilities", 1304 "common-tokens", 1305 "common-positions", 1306 "all-capabilities", 1307 "all-tokens", 1308 "all-positions", 1309 "mechanisms", 1310 "primary", 1311 "effects", 1312 "custom" 1313 ] # we do not include "graph" or "exploration" here... 1314 customLookup = currentStuff[0].reversionTypes 1315 while len(queue) > 0: 1316 aspect = queue.pop(0) 1317 if aspect.startswith('-'): 1318 customName = aspect[1:] 1319 if customName not in customLookup: 1320 raise ValueError( 1321 f"Custom reversion type {aspect[1:]!r} is invalid" 1322 f" because that reversion type has not been" 1323 f" defined. Defined types are:" 1324 f"\n{list(customLookup.keys())}" 1325 ) 1326 queue.extend(customLookup[customName]) 1327 else: 1328 expandedAspects.add(aspect) 1329 1330 # Further expand focal-context-part collectives 1331 if "common" in expandedAspects: 1332 expandedAspects.add("common-capabilities") 1333 expandedAspects.add("common-tokens") 1334 expandedAspects.add("common-skills") 1335 expandedAspects.add("common-positions") 1336 1337 if "all-contexts" in expandedAspects: 1338 expandedAspects.add("all-capabilities") 1339 expandedAspects.add("all-tokens") 1340 expandedAspects.add("all-skills") 1341 expandedAspects.add("all-positions") 1342 1343 if "current" in expandedAspects: 1344 expandedAspects.add("current-capabilities") 1345 expandedAspects.add("current-tokens") 1346 expandedAspects.add("current-skills") 1347 expandedAspects.add("current-positions") 1348 1349 # Figure out things to revert that are specific to named focal 1350 # contexts 1351 perFC: Dict[FocalContextName, Set[RestoreFCPart]] = {} 1352 currentFCName = currentStuff[1]['activeContext'] 1353 for aspect in expandedAspects: 1354 # For current- stuff, look up current context name 1355 if aspect.startswith("current"): 1356 found = False 1357 part: RestoreFCPart 1358 for part in get_args(RestoreFCPart): 1359 if aspect == f"current-{part}": 1360 perFC.setdefault(currentFCName, set()).add(part) 1361 found = True 1362 if not found and aspect != "current": 1363 raise RuntimeError(f"Invalid reversion aspect: {aspect!r}") 1364 elif aspect.startswith("c-"): 1365 if aspect.endswith("-capabilities"): 1366 fcName = aspect[2:-13] 1367 perFC.setdefault(fcName, set()).add("capabilities") 1368 elif aspect.endswith("-tokens"): 1369 fcName = aspect[2:-7] 1370 perFC.setdefault(fcName, set()).add("tokens") 1371 elif aspect.endswith("-skills"): 1372 fcName = aspect[2:-7] 1373 perFC.setdefault(fcName, set()).add("skills") 1374 elif aspect.endswith("-positions"): 1375 fcName = aspect[2:-10] 1376 perFC.setdefault(fcName, set()).add("positions") 1377 else: 1378 fcName = aspect[2:] 1379 forThis = perFC.setdefault(fcName, set()) 1380 forThis.add("capabilities") 1381 forThis.add("tokens") 1382 forThis.add("skills") 1383 forThis.add("positions") 1384 1385 currentState = currentStuff[1] 1386 savedState = savedStuff[1] 1387 1388 # Expand all-FC reversions to per-FC entries for each FC in both 1389 # current and prior states 1390 allFCs = set(currentState['contexts']) | set(savedState['contexts']) 1391 for part in get_args(RestoreFCPart): 1392 if f"all-{part}" in expandedAspects: 1393 for fcName in allFCs: 1394 perFC.setdefault(fcName, set()).add(part) 1395 1396 # Revert graph or not 1397 if "graph" in expandedAspects: 1398 resultGraph = copy.deepcopy(savedStuff[0]) 1399 # Patch nextID to avoid spurious ID matches 1400 resultGraph.nextID = currentStuff[0].nextID 1401 else: 1402 resultGraph = copy.deepcopy(currentStuff[0]) 1403 1404 # Start from non-reverted state copy 1405 resultState = copy.deepcopy(currentState) 1406 1407 # Revert primary decision or not 1408 if "primary" in expandedAspects: 1409 resultState['primaryDecision'] = savedState['primaryDecision'] 1410 1411 # Revert specified aspects of the common focal context 1412 savedCommon = savedState['common'] 1413 capKey: RestoreCapabilityPart 1414 for capKey in get_args(RestoreCapabilityPart): 1415 if f"common-{part}" in expandedAspects: 1416 resultState['common']['capabilities'][capKey] = copy.deepcopy( 1417 savedCommon['capabilities'][capKey] 1418 ) 1419 if "common-positions" in expandedAspects: 1420 fcKey: RestoreFCKey 1421 for fcKey in get_args(RestoreFCKey): 1422 resultState['common'][fcKey] = copy.deepcopy(savedCommon[fcKey]) 1423 1424 # Update focal context parts for named focal contexts: 1425 savedContextMap = savedState['contexts'] 1426 for fcName, restore in perFC.items(): 1427 thisFC = resultState['contexts'].setdefault( 1428 fcName, 1429 emptyFocalContext() 1430 ) # Create FC by name if it didn't exist already 1431 thatFC = savedContextMap.get(fcName) 1432 if thatFC is None: # what if it's a new one? 1433 if restore == set(get_args(RestoreFCPart)): 1434 # If we're restoring everything and the context didn't 1435 # exist in the prior state, delete it in the restored 1436 # state 1437 del resultState['contexts'][fcName] 1438 else: 1439 # Otherwise update parts of it to be blank since prior 1440 # state didn't have any info 1441 for part in restore: 1442 if part == "positions": 1443 thisFC['focalization'] = {} 1444 thisFC['activeDomains'] = set() 1445 thisFC['activeDecisions'] = {} 1446 elif part == "capabilities": 1447 thisFC['capabilities'][part] = set() 1448 else: 1449 thisFC['capabilities'][part] = {} 1450 else: # same context existed in saved data; update parts 1451 for part in restore: 1452 if part == "positions": 1453 for fcKey in get_args(RestoreFCKey): # typed above 1454 thisFC[fcKey] = copy.deepcopy(thatFC[fcKey]) 1455 else: 1456 thisFC['capabilities'][part] = copy.deepcopy( 1457 thatFC['capabilities'][part] 1458 ) 1459 1460 # Revert mechanisms, exploration, and/or custom state if specified 1461 statePart: RestoreStatePart 1462 for statePart in get_args(RestoreStatePart): 1463 if statePart in expandedAspects: 1464 resultState[statePart] = copy.deepcopy(savedState[statePart]) 1465 1466 # Revert effect tracking if specified 1467 if "effects" in expandedAspects: 1468 resultState['effectCounts'] = copy.deepcopy( 1469 savedState['effectCounts'] 1470 ) 1471 resultState['deactivated'] = copy.deepcopy(savedState['deactivated']) 1472 1473 return (resultGraph, resultState)
Given two (graph, state) pairs, as well as a set of reversion aspect strings, returns a (graph, state) pair representing the reverted graph and state. The provided graphs and states will not be modified, and the return value will not include references to them, so modifying the returned state will not modify the original or saved states or graphs.
If the revisionAspects
set is empty, then all aspects except
skills, exploration statuses, and the graph will be reverted.
Note that the reversion process can lead to impossible states if the wrong combination of reversion aspects is used (e.g., reverting the graph but not focal context position information might lead to positions that refer to decisions which do not exist).
Valid reversion aspect strings are:
- "common-capabilities", "common-tokens", "common-skills,"
"common-positions" or just "common" for all four. These
control the parts of the common context's
CapabilitySet
that get reverted, as well as whether the focalization, active domains, and active decisions get reverted (those three as "positions"). - "c-NAME-capabilities" as well as -tokens, -skills, -positions, and without a suffix, where NAME is the name of a specific focal context.
- "all-capabilities" as well as -tokens, -skills, -positions, and -contexts, reverting the relevant part of all focal contexts except the common one, with "all-contexts" reverting every part of all non-common focal contexts.
- "current-capabilities" as well as -tokens, -skills, -positions, and without a suffix, for the currently-active focal context.
- "primary" which reverts the primary decision (some positions should also be reverted in this case).
- "mechanisms" which reverts mechanism states.
- "exploration" which reverts the exploration state of decisions
(note that the
DecisionGraph
also stores "unconfirmed" tags which are NOT affected by a revert unless "graph" is specified). - "effects" which reverts the record of how many times transition effects have been triggered, plus whether transitions have been disabled or not.
- "custom" which reverts custom state.
- "graph" reverts the graph itself (but this is usually not desired). This will still preserve the next-ID value for assigning new nodes, so that nodes created in a reverted graph will not re-use IDs from nodes created before the reversion.
- "-NAME" where NAME is a custom reversion specification
defined using
core.DecisionGraph.reversionType
and available in the "current" decision graph (note the colon is required before the custom name). This allows complex reversion systems to be set up once and referenced repeatedly. Any strings specified along with a custom reversion type will revert the specified state in addition to what the custom reversion type specifies.
For example:
>>> from . import core
>>> g = core.DecisionGraph.example("simple") # A - B - C triangle
>>> g.setTransitionRequirement('B', 'next', ReqCapability('helmet'))
>>> g.addAction(
... 'A',
... 'getHelmet',
... consequence=[effect(gain='helmet'), effect(deactivate=True)]
... )
>>> s0 = basicState()
>>> fc0 = s0['contexts']['main']
>>> fc0['activeDecisions']['main'] = 0 # A
>>> s1 = basicState()
>>> fc1 = s1['contexts']['main']
>>> fc1['capabilities']['capabilities'].add('helmet')
>>> fc1['activeDecisions']['main'] = 1 # B
>>> s1['exploration'] = {0: 'explored', 1: 'exploring', 2: 'unknown'}
>>> s1['effectCounts'] = {(0, 'getHelmet', 1): 1}
>>> s1['deactivated'] = {(0, "getHelmet")}
>>> # Basic reversion of everything except graph & exploration
>>> rg, rs = revertedState((g, s1), (g, s0), set())
>>> rg == g
True
>>> rg is g
False
>>> rs == s0
False
>>> rs is s0
False
>>> rs['contexts'] == s0['contexts']
True
>>> rs['exploration'] == s1['exploration']
True
>>> rs['effectCounts'] = s0['effectCounts']
>>> rs['deactivated'] = s0['deactivated']
>>> # Reverting capabilities but not position, exploration, or effects
>>> rg, rs = revertedState((g, s1), (g, s0), {"current-capabilities"})
>>> rg == g
True
>>> rs == s0 or rs == s1
False
>>> s1['contexts']['main']['capabilities']['capabilities']
{'helmet'}
>>> s0['contexts']['main']['capabilities']['capabilities']
set()
>>> rs['contexts']['main']['capabilities']['capabilities']
set()
>>> s1['contexts']['main']['activeDecisions']['main']
1
>>> s0['contexts']['main']['activeDecisions']['main']
0
>>> rs['contexts']['main']['activeDecisions']['main']
1
>>> # Restore position and effects; that's all that wasn't reverted
>>> rs['contexts']['main']['activeDecisions']['main'] = 0
>>> rs['exploration'] = {}
>>> rs['effectCounts'] = {}
>>> rs['deactivated'] = set()
>>> rs == s0
True
>>> # Reverting position but not state
>>> rg, rs = revertedState((g, s1), (g, s0), {"current-positions"})
>>> rg == g
True
>>> s1['contexts']['main']['capabilities']['capabilities']
{'helmet'}
>>> s0['contexts']['main']['capabilities']['capabilities']
set()
>>> rs['contexts']['main']['capabilities']['capabilities']
{'helmet'}
>>> s1['contexts']['main']['activeDecisions']['main']
1
>>> s0['contexts']['main']['activeDecisions']['main']
0
>>> rs['contexts']['main']['activeDecisions']['main']
0
>>> # Reverting based on specific focal context name
>>> rg2, rs2 = revertedState((g, s1), (g, s0), {"c-main-positions"})
>>> rg2 == rg
True
>>> rs2 == rs
True
>>> # Test of graph reversion
>>> import copy
>>> g2 = copy.deepcopy(g)
>>> g2.addDecision('D')
3
>>> g2.addTransition(2, 'alt', 'D', 'return')
>>> rg, rs = revertedState((g2, s1), (g, s0), {'graph'})
>>> rg == g
True
>>> rg is g
False
TODO: More tests for various other reversion aspects TODO: Implement per-token-type / per-capability / per-mechanism / per-skill reversion.
1480class RequirementContext(NamedTuple): 1481 """ 1482 The context necessary to check whether a requirement is satisfied or 1483 not. Also used for computing effective skill levels for 1484 `SkillCombination`s. Includes a `State` that specifies `Capability` 1485 and `Token` states, a `DecisionGraph` (which includes equivalences), 1486 and a set of `DecisionID`s to use as the starting place for finding 1487 mechanisms by name. 1488 """ 1489 state: State 1490 graph: 'DecisionGraph' 1491 searchFrom: Set[DecisionID]
The context necessary to check whether a requirement is satisfied or
not. Also used for computing effective skill levels for
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
1494def getSkillLevel(state: State, skill: Skill) -> Level: 1495 """ 1496 Given a `State` and a `Skill`, looks up that skill in both the 1497 common and active `FocalContext`s of the state, and adds those 1498 numbers together to get an effective skill level for that skill. 1499 Note that `SkillCombination`s can be used to set up more complex 1500 logic for skill combinations across different skills; if adding 1501 levels isn't desired between `FocalContext`s, use different skill 1502 names. 1503 1504 If the skill isn't mentioned, the level will count as 0. 1505 """ 1506 commonContext = state['common'] 1507 activeContext = state['contexts'][state['activeContext']] 1508 return ( 1509 commonContext['capabilities']['skills'].get(skill, 0) 1510 + activeContext['capabilities']['skills'].get(skill, 0) 1511 )
Given a State
and a Skill
, looks up that skill in both the
common and active 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.
1552class Effect(TypedDict): 1553 """ 1554 Represents one effect of a transition on the decision graph and/or 1555 game state. The `type` slot is an `EffectType` that indicates what 1556 type of effect it is, and determines what the `value` slot will hold. 1557 The `charges` slot is normally `None`, but when set to an integer, 1558 the effect will only trigger that many times, subtracting one charge 1559 each time until it reaches 0, after which the effect will remain but 1560 be ignored. The `delay` slot is also normally `None`, but when set to 1561 an integer, the effect won't trigger but will instead subtract one 1562 from the delay until it reaches zero, at which point it will start to 1563 trigger (and use up charges if there are any). The 'applyTo' slot 1564 should be either 'common' or 'active' (a `ContextSpecifier`) and 1565 determines which focal context the effect applies to. 1566 1567 The `value` values for each `type` are: 1568 1569 - `'gain'`: A `Capability`, (`Token`, `TokenCount`) pair, or 1570 ('skill', `Skill`, `Level`) triple indicating a capability 1571 gained, some tokens acquired, or skill levels gained. 1572 - `'lose'`: A `Capability`, (`Token`, `TokenCount`) pair, or 1573 ('skill', `Skill`, `Level`) triple indicating a capability lost, 1574 some tokens spent, or skill levels lost. Note that the literal 1575 string 'skill' is added to disambiguate skills from tokens. 1576 - `'set'`: A (`Token`, `TokenCount`) pair, a (`MechanismSpecifier`, 1577 `MechanismState`) pair, or a ('skill', `Skill`, `Level`) triple 1578 indicating the new number of tokens, new mechanism state, or new 1579 skill level to establish. Ignores the old count/level, unlike 1580 'gain' and 'lose.' 1581 - `'toggle'`: A list of capabilities which will be toggled on one 1582 after the other, toggling the rest off, OR, a tuple containing a 1583 mechanism name followed by a list of states to be set one after 1584 the other. Does not work for tokens or skills. If a `Capability` 1585 list only has one item, it will be toggled on or off depending 1586 on whether the player currently has that capability or not, 1587 otherwise, whichever capability in the toggle list is currently 1588 active will determine which one gets activated next (the 1589 subsequent one in the list, wrapping from the end to the start). 1590 Note that equivalences are NOT considered when determining which 1591 capability to turn on, and ALL capabilities in the toggle list 1592 except the next one to turn on are turned off. Also, if none of 1593 the capabilities in the list is currently activated, the first 1594 one will be. For mechanisms, `DEFAULT_MECHANISM_STATE` will be 1595 used as the default state if only one state is provided, since 1596 mechanisms can't be "not in a state." `Mechanism` toggles 1597 function based on the current mechanism state; if it's not in 1598 the list they set the first given state. 1599 - `'deactivate'`: `None`. When the effect is activated, the 1600 transition it applies on will be added to the deactivated set in 1601 the current state. This effect type ignores the 'applyTo' value 1602 since it does not make changes to a `FocalContext`. 1603 - `'edit'`: A list of lists of `Command`s, with each list to be 1604 applied in succession on every subsequent activation of the 1605 transition (like toggle). These can use extra variables '$@' to 1606 refer to the source decision of the transition the edit effect is 1607 attached to, '$@d' to refer to the destination decision, '$@t' to 1608 refer to the transition, and '$@r' to refer to its reciprocal. 1609 Commands are powerful and might edit more than just the 1610 specified focal context. 1611 TODO: How to handle list-of-lists format? 1612 - `'goto'`: Either an `AnyDecisionSpecifier` specifying where the 1613 player should end up, or an (`AnyDecisionSpecifier`, 1614 `FocalPointName`) specifying both where they should end up and 1615 which focal point in the relevant domain should be moved. If 1616 multiple 'goto' values are present on different effects of a 1617 transition, they each trigger in turn (and e.g., might activate 1618 multiple decision points in a spreading-focalized domain). Every 1619 transition has a destination, so 'goto' is not necessary: use it 1620 only when an attempt to take a transition is diverted (and 1621 normally, in conjunction with 'charges', 'delay', and/or as an 1622 effect that's behind a `Challenge` or `Conditional`). If a goto 1623 specifies a destination in a plural-focalized domain, but does 1624 not include a focal point name, then the focal point which was 1625 taking the transition will be the one to move. If that 1626 information is not available, the first focal point created in 1627 that domain will be moved by default. Note that when using 1628 something other than a destination ID as the 1629 `AnyDecisionSpecifier`, it's up to you to ensure that the 1630 specifier is not ambiguous, otherwise taking the transition will 1631 crash the program. 1632 - `'bounce'`: Value will be `None`. Prevents the normal position 1633 update associated with a transition that this effect applies to. 1634 Normally, a transition should be marked with an appropriate 1635 requirement to prevent access, even in cases where access seems 1636 possible until tested (just add the requirement on a step after 1637 the transition is observed where relevant). However, 'bounce' can 1638 be used in cases where there's a challenge to fail, for example. 1639 `bounce` is redundant with `goto`: if a `goto` effect applies on 1640 a certain transition, the presence or absence of `bounce` on the 1641 same transition is ignored, since the new position will be 1642 specified by the `goto` value anyways. 1643 - `'follow'`: Value will be a `Transition` name. A transition with 1644 that name must exist at the destination of the action, and when 1645 the follow effect triggers, the player will immediately take 1646 that transition (triggering any consequences it has) after 1647 arriving at their normal destination (so the exploration status 1648 of the normal destination will also be updated). This can result 1649 in an infinite loop if two 'follow' effects imply transitions 1650 which trigger each other, so don't do that. 1651 - `'save'`: Value will be a string indicating a save-slot name. 1652 Indicates a save point, which can be returned to using a 1653 'revertTo' `ExplorationAction`. The entire game state and current 1654 graph is recorded, including effects of the current consequence 1655 before, but not after, the 'save' effect. However, the graph 1656 configuration is not restored by default (see 'revert'). A revert 1657 effect may specify only parts of the state to revert. 1658 1659 TODO: 1660 'focus', 1661 'foreground', 1662 'background', 1663 """ 1664 type: EffectType 1665 applyTo: ContextSpecifier 1666 value: AnyEffectValue 1667 charges: Optional[int] 1668 delay: Optional[int] 1669 hidden: bool
Represents one effect of a transition on the decision graph and/or
game state. The type
slot is an EffectType
that indicates what
type of effect it is, and determines what the value
slot will hold.
The charges
slot is normally None
, but when set to an integer,
the effect will only trigger that many times, subtracting one charge
each time until it reaches 0, after which the effect will remain but
be ignored. The delay
slot is also normally None
, but when set to
an integer, the effect won't trigger but will instead subtract one
from the delay until it reaches zero, at which point it will start to
trigger (and use up charges if there are any). The 'applyTo' slot
should be either 'common' or 'active' (a ContextSpecifier
) and
determines which focal context the effect applies to.
The value
values for each type
are:
'gain'
: 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',
1672def effect( 1673 *, 1674 applyTo: ContextSpecifier = 'active', 1675 gain: Optional[Union[ 1676 Capability, 1677 Tuple[Token, TokenCount], 1678 Tuple[Literal['skill'], Skill, Level] 1679 ]] = None, 1680 lose: Optional[Union[ 1681 Capability, 1682 Tuple[Token, TokenCount], 1683 Tuple[Literal['skill'], Skill, Level] 1684 ]] = None, 1685 set: Optional[Union[ 1686 Tuple[Token, TokenCount], 1687 Tuple[AnyMechanismSpecifier, MechanismState], 1688 Tuple[Literal['skill'], Skill, Level] 1689 ]] = None, 1690 toggle: Optional[Union[ 1691 Tuple[AnyMechanismSpecifier, List[MechanismState]], 1692 List[Capability] 1693 ]] = None, 1694 deactivate: Optional[bool] = None, 1695 edit: Optional[List[List[commands.Command]]] = None, 1696 goto: Optional[Union[ 1697 AnyDecisionSpecifier, 1698 Tuple[AnyDecisionSpecifier, FocalPointName] 1699 ]] = None, 1700 bounce: Optional[bool] = None, 1701 follow: Optional[Transition] = None, 1702 save: Optional[SaveSlot] = None, 1703 delay: Optional[int] = None, 1704 charges: Optional[int] = None, 1705 hidden: bool = False 1706) -> Effect: 1707 """ 1708 Factory for a transition effect which includes default values so you 1709 can just specify effect types that are relevant to a particular 1710 situation. You may not supply values for more than one of 1711 gain/lose/set/toggle/deactivate/edit/goto/bounce, since which one 1712 you use determines the effect type. 1713 """ 1714 tCount = len([ 1715 x 1716 for x in ( 1717 gain, 1718 lose, 1719 set, 1720 toggle, 1721 deactivate, 1722 edit, 1723 goto, 1724 bounce, 1725 follow, 1726 save 1727 ) 1728 if x is not None 1729 ]) 1730 if tCount == 0: 1731 raise ValueError( 1732 "You must specify one of gain, lose, set, toggle, deactivate," 1733 " edit, goto, bounce, follow, or save." 1734 ) 1735 elif tCount > 1: 1736 raise ValueError( 1737 f"You may only specify one of gain, lose, set, toggle," 1738 f" deactivate, edit, goto, bounce, follow, or save" 1739 f" (you provided values for {tCount} of those)." 1740 ) 1741 1742 result: Effect = { 1743 'type': 'edit', 1744 'applyTo': applyTo, 1745 'value': [], 1746 'delay': delay, 1747 'charges': charges, 1748 'hidden': hidden 1749 } 1750 1751 if gain is not None: 1752 result['type'] = 'gain' 1753 result['value'] = gain 1754 elif lose is not None: 1755 result['type'] = 'lose' 1756 result['value'] = lose 1757 elif set is not None: 1758 result['type'] = 'set' 1759 if ( 1760 len(set) == 2 1761 and isinstance(set[0], MechanismName) 1762 and isinstance(set[1], MechanismState) 1763 ): 1764 result['value'] = ( 1765 MechanismSpecifier(None, None, None, set[0]), 1766 set[1] 1767 ) 1768 else: 1769 result['value'] = set 1770 elif toggle is not None: 1771 result['type'] = 'toggle' 1772 result['value'] = toggle 1773 elif deactivate is not None: 1774 result['type'] = 'deactivate' 1775 result['value'] = None 1776 elif edit is not None: 1777 result['type'] = 'edit' 1778 result['value'] = edit 1779 elif goto is not None: 1780 result['type'] = 'goto' 1781 result['value'] = goto 1782 elif bounce is not None: 1783 result['type'] = 'bounce' 1784 result['value'] = None 1785 elif follow is not None: 1786 result['type'] = 'follow' 1787 result['value'] = follow 1788 elif save is not None: 1789 result['type'] = 'save' 1790 result['value'] = save 1791 else: 1792 raise RuntimeError( 1793 "No effect specified in effect function & check failed." 1794 ) 1795 1796 return result
Factory for a transition effect which includes default values so you can just specify effect types that are relevant to a particular situation. You may not supply values for more than one of gain/lose/set/toggle/deactivate/edit/goto/bounce, since which one you use determines the effect type.
1799class SkillCombination: 1800 """ 1801 Represents which skill(s) are used for a `Challenge`, including under 1802 what circumstances different skills might apply using 1803 `Requirement`s. This is an abstract class, use the subclasses 1804 `BestSkill`, `WorstSkill`, `CombinedSkill`, `InverseSkill`, and/or 1805 `ConditionalSkill` to represent a specific situation. To represent a 1806 single required skill, use a `BestSkill` or `CombinedSkill` with 1807 that skill as the only skill. 1808 1809 Use `SkillCombination.effectiveLevel` to figure out the effective 1810 level of the entire requirement in a given situation. Note that 1811 levels from the common and active `FocalContext`s are added together 1812 whenever a specific skill level is referenced. 1813 1814 Some examples: 1815 1816 >>> from . import core 1817 >>> ctx = RequirementContext(emptyState(), core.DecisionGraph(), set()) 1818 >>> ctx.state['common']['capabilities']['skills']['brawn'] = 1 1819 >>> ctx.state['common']['capabilities']['skills']['brains'] = 3 1820 >>> ctx.state['common']['capabilities']['skills']['luck'] = -1 1821 1822 1. To represent using just the 'brains' skill, you would use: 1823 1824 `BestSkill('brains')` 1825 1826 >>> sr = BestSkill('brains') 1827 >>> sr.effectiveLevel(ctx) 1828 3 1829 1830 If a skill isn't listed, its level counts as 0: 1831 1832 >>> sr = BestSkill('agility') 1833 >>> sr.effectiveLevel(ctx) 1834 0 1835 1836 To represent using the higher of 'brains' or 'brawn' you'd use: 1837 1838 `BestSkill('brains', 'brawn')` 1839 1840 >>> sr = BestSkill('brains', 'brawn') 1841 >>> sr.effectiveLevel(ctx) 1842 3 1843 1844 The zero default only applies if an unknown skill is in the mix: 1845 1846 >>> sr = BestSkill('luck') 1847 >>> sr.effectiveLevel(ctx) 1848 -1 1849 >>> sr = BestSkill('luck', 'agility') 1850 >>> sr.effectiveLevel(ctx) 1851 0 1852 1853 2. To represent using the lower of 'brains' or 'brawn' you'd use: 1854 1855 `WorstSkill('brains', 'brawn')` 1856 1857 >>> sr = WorstSkill('brains', 'brawn') 1858 >>> sr.effectiveLevel(ctx) 1859 1 1860 1861 3. To represent using 'brawn' if the focal context has the 'brawny' 1862 capability, but brains if not, use: 1863 1864 ``` 1865 ConditionalSkill( 1866 ReqCapability('brawny'), 1867 'brawn', 1868 'brains' 1869 ) 1870 ``` 1871 1872 >>> sr = ConditionalSkill( 1873 ... ReqCapability('brawny'), 1874 ... 'brawn', 1875 ... 'brains' 1876 ... ) 1877 >>> sr.effectiveLevel(ctx) 1878 3 1879 >>> brawny = copy.deepcopy(ctx) 1880 >>> brawny.state['common']['capabilities']['capabilities'].add( 1881 ... 'brawny' 1882 ... ) 1883 >>> sr.effectiveLevel(brawny) 1884 1 1885 1886 If the player can still choose to use 'brains' even when they 1887 have the 'brawny' capability, you would do: 1888 1889 >>> sr = ConditionalSkill( 1890 ... ReqCapability('brawny'), 1891 ... BestSkill('brawn', 'brains'), 1892 ... 'brains' 1893 ... ) 1894 >>> sr.effectiveLevel(ctx) 1895 3 1896 >>> sr.effectiveLevel(brawny) # can still use brains if better 1897 3 1898 1899 4. To represent using the combined level of the 'brains' and 1900 'brawn' skills, you would use: 1901 1902 `CombinedSkill('brains', 'brawn')` 1903 1904 >>> sr = CombinedSkill('brains', 'brawn') 1905 >>> sr.effectiveLevel(ctx) 1906 4 1907 1908 5. Skill names can be replaced by entire sub-`SkillCombination`s in 1909 any position, so more complex forms are possible: 1910 1911 >>> sr = BestSkill(CombinedSkill('brains', 'luck'), 'brawn') 1912 >>> sr.effectiveLevel(ctx) 1913 2 1914 >>> sr = BestSkill( 1915 ... ConditionalSkill( 1916 ... ReqCapability('brawny'), 1917 ... 'brawn', 1918 ... 'brains', 1919 ... ), 1920 ... CombinedSkill('brains', 'luck') 1921 ... ) 1922 >>> sr.effectiveLevel(ctx) 1923 3 1924 >>> sr.effectiveLevel(brawny) 1925 2 1926 """ 1927 def effectiveLevel(self, context: 'RequirementContext') -> Level: 1928 """ 1929 Returns the effective `Level` of the skill combination, given 1930 the situation specified by the provided `RequirementContext`. 1931 """ 1932 raise NotImplementedError( 1933 "SkillCombination is an abstract class. Use one of its" 1934 " subclsases instead." 1935 ) 1936 1937 def __eq__(self, other: Any) -> bool: 1938 raise NotImplementedError( 1939 "SkillCombination is an abstract class and cannot be compared." 1940 ) 1941 1942 def __hash__(self) -> int: 1943 raise NotImplementedError( 1944 "SkillCombination is an abstract class and cannot be hashed." 1945 ) 1946 1947 def walk(self) -> Generator[ 1948 Union['SkillCombination', Skill, Level], 1949 None, 1950 None 1951 ]: 1952 """ 1953 Yields this combination and each sub-part in depth-first 1954 traversal order. 1955 """ 1956 raise NotImplementedError( 1957 "SkillCombination is an abstract class and cannot be walked." 1958 ) 1959 1960 def unparse(self) -> str: 1961 """ 1962 Returns a string that `SkillCombination.parse` would turn back 1963 into a `SkillCombination` equivalent to this one. For example: 1964 1965 >>> BestSkill('brains').unparse() 1966 'best(brains)' 1967 >>> WorstSkill('brains', 'brawn').unparse() 1968 'worst(brains, brawn)' 1969 >>> CombinedSkill( 1970 ... ConditionalSkill(ReqTokens('orb', 3), 'brains', 0), 1971 ... InverseSkill('luck') 1972 ... ).unparse() 1973 'sum(if(orb*3, brains, 0), ~luck)' 1974 """ 1975 raise NotImplementedError( 1976 "SkillCombination is an abstract class and cannot be" 1977 " unparsed." 1978 )
Represents which skill(s) are used for a Challenge
, including under
what circumstances different skills might apply using
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
1927 def effectiveLevel(self, context: 'RequirementContext') -> Level: 1928 """ 1929 Returns the effective `Level` of the skill combination, given 1930 the situation specified by the provided `RequirementContext`. 1931 """ 1932 raise NotImplementedError( 1933 "SkillCombination is an abstract class. Use one of its" 1934 " subclsases instead." 1935 )
Returns the effective Level
of the skill combination, given
the situation specified by the provided RequirementContext
.
1947 def walk(self) -> Generator[ 1948 Union['SkillCombination', Skill, Level], 1949 None, 1950 None 1951 ]: 1952 """ 1953 Yields this combination and each sub-part in depth-first 1954 traversal order. 1955 """ 1956 raise NotImplementedError( 1957 "SkillCombination is an abstract class and cannot be walked." 1958 )
Yields this combination and each sub-part in depth-first traversal order.
1960 def unparse(self) -> str: 1961 """ 1962 Returns a string that `SkillCombination.parse` would turn back 1963 into a `SkillCombination` equivalent to this one. For example: 1964 1965 >>> BestSkill('brains').unparse() 1966 'best(brains)' 1967 >>> WorstSkill('brains', 'brawn').unparse() 1968 'worst(brains, brawn)' 1969 >>> CombinedSkill( 1970 ... ConditionalSkill(ReqTokens('orb', 3), 'brains', 0), 1971 ... InverseSkill('luck') 1972 ... ).unparse() 1973 'sum(if(orb*3, brains, 0), ~luck)' 1974 """ 1975 raise NotImplementedError( 1976 "SkillCombination is an abstract class and cannot be" 1977 " unparsed." 1978 )
Returns a string that SkillCombination.parse
would turn back
into a SkillCombination
equivalent to this one. For example:
>>> BestSkill('brains').unparse()
'best(brains)'
>>> WorstSkill('brains', 'brawn').unparse()
'worst(brains, brawn)'
>>> CombinedSkill(
... ConditionalSkill(ReqTokens('orb', 3), 'brains', 0),
... InverseSkill('luck')
... ).unparse()
'sum(if(orb*3, brains, 0), ~luck)'
1981class BestSkill(SkillCombination): 1982 def __init__( 1983 self, 1984 *skills: Union[SkillCombination, Skill, Level] 1985 ): 1986 """ 1987 Given one or more `SkillCombination` sub-items and/or skill 1988 names or levels, represents a situation where the highest 1989 effective level among the sub-items is used. Skill names 1990 translate to the player's level for that skill (with 0 as a 1991 default) while level numbers translate to that number. 1992 """ 1993 if len(skills) == 0: 1994 raise ValueError( 1995 "Cannot create a `BestSkill` with 0 sub-skills." 1996 ) 1997 self.skills = skills 1998 1999 def __eq__(self, other: Any) -> bool: 2000 return isinstance(other, BestSkill) and other.skills == self.skills 2001 2002 def __hash__(self) -> int: 2003 result = 1829 2004 for sk in self.skills: 2005 result += hash(sk) 2006 return result 2007 2008 def __repr__(self) -> str: 2009 subs = ', '.join(repr(sk) for sk in self.skills) 2010 return "BestSkill(" + subs + ")" 2011 2012 def walk(self) -> Generator[ 2013 Union[SkillCombination, Skill, Level], 2014 None, 2015 None 2016 ]: 2017 yield self 2018 for sub in self.skills: 2019 if isinstance(sub, (Skill, Level)): 2020 yield sub 2021 else: 2022 yield from sub.walk() 2023 2024 def effectiveLevel(self, ctx: 'RequirementContext') -> Level: 2025 """ 2026 Determines the effective level of each sub-skill-combo and 2027 returns the highest of those. 2028 """ 2029 result = None 2030 level: Level 2031 if len(self.skills) == 0: 2032 raise RuntimeError("Invalid BestSkill: has zero sub-skills.") 2033 for sk in self.skills: 2034 if isinstance(sk, Level): 2035 level = sk 2036 elif isinstance(sk, Skill): 2037 level = getSkillLevel(ctx.state, sk) 2038 elif isinstance(sk, SkillCombination): 2039 level = sk.effectiveLevel(ctx) 2040 else: 2041 raise RuntimeError( 2042 f"Invalid BestSkill: found sub-skill '{repr(sk)}'" 2043 f" which is not a skill name string, level integer," 2044 f" or SkillCombination." 2045 ) 2046 if result is None or result < level: 2047 result = level 2048 2049 assert result is not None 2050 return result 2051 2052 def unparse(self): 2053 result = "best(" 2054 for sk in self.skills: 2055 if isinstance(sk, SkillCombination): 2056 result += sk.unparse() 2057 else: 2058 result += str(sk) 2059 result += ', ' 2060 return result[:-2] + ')'
Represents which skill(s) are used for a Challenge
, including under
what circumstances different skills might apply using
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
1982 def __init__( 1983 self, 1984 *skills: Union[SkillCombination, Skill, Level] 1985 ): 1986 """ 1987 Given one or more `SkillCombination` sub-items and/or skill 1988 names or levels, represents a situation where the highest 1989 effective level among the sub-items is used. Skill names 1990 translate to the player's level for that skill (with 0 as a 1991 default) while level numbers translate to that number. 1992 """ 1993 if len(skills) == 0: 1994 raise ValueError( 1995 "Cannot create a `BestSkill` with 0 sub-skills." 1996 ) 1997 self.skills = skills
Given one or more SkillCombination
sub-items and/or skill
names or levels, represents a situation where the highest
effective level among the sub-items is used. Skill names
translate to the player's level for that skill (with 0 as a
default) while level numbers translate to that number.
2012 def walk(self) -> Generator[ 2013 Union[SkillCombination, Skill, Level], 2014 None, 2015 None 2016 ]: 2017 yield self 2018 for sub in self.skills: 2019 if isinstance(sub, (Skill, Level)): 2020 yield sub 2021 else: 2022 yield from sub.walk()
Yields this combination and each sub-part in depth-first traversal order.
2024 def effectiveLevel(self, ctx: 'RequirementContext') -> Level: 2025 """ 2026 Determines the effective level of each sub-skill-combo and 2027 returns the highest of those. 2028 """ 2029 result = None 2030 level: Level 2031 if len(self.skills) == 0: 2032 raise RuntimeError("Invalid BestSkill: has zero sub-skills.") 2033 for sk in self.skills: 2034 if isinstance(sk, Level): 2035 level = sk 2036 elif isinstance(sk, Skill): 2037 level = getSkillLevel(ctx.state, sk) 2038 elif isinstance(sk, SkillCombination): 2039 level = sk.effectiveLevel(ctx) 2040 else: 2041 raise RuntimeError( 2042 f"Invalid BestSkill: found sub-skill '{repr(sk)}'" 2043 f" which is not a skill name string, level integer," 2044 f" or SkillCombination." 2045 ) 2046 if result is None or result < level: 2047 result = level 2048 2049 assert result is not None 2050 return result
Determines the effective level of each sub-skill-combo and returns the highest of those.
2052 def unparse(self): 2053 result = "best(" 2054 for sk in self.skills: 2055 if isinstance(sk, SkillCombination): 2056 result += sk.unparse() 2057 else: 2058 result += str(sk) 2059 result += ', ' 2060 return result[:-2] + ')'
Returns a string that SkillCombination.parse
would turn back
into a SkillCombination
equivalent to this one. For example:
>>> BestSkill('brains').unparse()
'best(brains)'
>>> WorstSkill('brains', 'brawn').unparse()
'worst(brains, brawn)'
>>> CombinedSkill(
... ConditionalSkill(ReqTokens('orb', 3), 'brains', 0),
... InverseSkill('luck')
... ).unparse()
'sum(if(orb*3, brains, 0), ~luck)'
2063class WorstSkill(SkillCombination): 2064 def __init__( 2065 self, 2066 *skills: Union[SkillCombination, Skill, Level] 2067 ): 2068 """ 2069 Given one or more `SkillCombination` sub-items and/or skill 2070 names or levels, represents a situation where the lowest 2071 effective level among the sub-items is used. Skill names 2072 translate to the player's level for that skill (with 0 as a 2073 default) while level numbers translate to that number. 2074 """ 2075 if len(skills) == 0: 2076 raise ValueError( 2077 "Cannot create a `WorstSkill` with 0 sub-skills." 2078 ) 2079 self.skills = skills 2080 2081 def __eq__(self, other: Any) -> bool: 2082 return isinstance(other, WorstSkill) and other.skills == self.skills 2083 2084 def __hash__(self) -> int: 2085 result = 7182 2086 for sk in self.skills: 2087 result += hash(sk) 2088 return result 2089 2090 def __repr__(self) -> str: 2091 subs = ', '.join(repr(sk) for sk in self.skills) 2092 return "WorstSkill(" + subs + ")" 2093 2094 def walk(self) -> Generator[ 2095 Union[SkillCombination, Skill, Level], 2096 None, 2097 None 2098 ]: 2099 yield self 2100 for sub in self.skills: 2101 if isinstance(sub, (Skill, Level)): 2102 yield sub 2103 else: 2104 yield from sub.walk() 2105 2106 def effectiveLevel(self, ctx: 'RequirementContext') -> Level: 2107 """ 2108 Determines the effective level of each sub-skill-combo and 2109 returns the lowest of those. 2110 """ 2111 result = None 2112 level: Level 2113 if len(self.skills) == 0: 2114 raise RuntimeError("Invalid WorstSkill: has zero sub-skills.") 2115 for sk in self.skills: 2116 if isinstance(sk, Level): 2117 level = sk 2118 elif isinstance(sk, Skill): 2119 level = getSkillLevel(ctx.state, sk) 2120 elif isinstance(sk, SkillCombination): 2121 level = sk.effectiveLevel(ctx) 2122 else: 2123 raise RuntimeError( 2124 f"Invalid WorstSkill: found sub-skill '{repr(sk)}'" 2125 f" which is not a skill name string, level integer," 2126 f" or SkillCombination." 2127 ) 2128 if result is None or result > level: 2129 result = level 2130 2131 assert result is not None 2132 return result 2133 2134 def unparse(self): 2135 result = "worst(" 2136 for sk in self.skills: 2137 if isinstance(sk, SkillCombination): 2138 result += sk.unparse() 2139 else: 2140 result += str(sk) 2141 result += ', ' 2142 return result[:-2] + ')'
Represents which skill(s) are used for a Challenge
, including under
what circumstances different skills might apply using
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
2064 def __init__( 2065 self, 2066 *skills: Union[SkillCombination, Skill, Level] 2067 ): 2068 """ 2069 Given one or more `SkillCombination` sub-items and/or skill 2070 names or levels, represents a situation where the lowest 2071 effective level among the sub-items is used. Skill names 2072 translate to the player's level for that skill (with 0 as a 2073 default) while level numbers translate to that number. 2074 """ 2075 if len(skills) == 0: 2076 raise ValueError( 2077 "Cannot create a `WorstSkill` with 0 sub-skills." 2078 ) 2079 self.skills = skills
Given one or more SkillCombination
sub-items and/or skill
names or levels, represents a situation where the lowest
effective level among the sub-items is used. Skill names
translate to the player's level for that skill (with 0 as a
default) while level numbers translate to that number.
2094 def walk(self) -> Generator[ 2095 Union[SkillCombination, Skill, Level], 2096 None, 2097 None 2098 ]: 2099 yield self 2100 for sub in self.skills: 2101 if isinstance(sub, (Skill, Level)): 2102 yield sub 2103 else: 2104 yield from sub.walk()
Yields this combination and each sub-part in depth-first traversal order.
2106 def effectiveLevel(self, ctx: 'RequirementContext') -> Level: 2107 """ 2108 Determines the effective level of each sub-skill-combo and 2109 returns the lowest of those. 2110 """ 2111 result = None 2112 level: Level 2113 if len(self.skills) == 0: 2114 raise RuntimeError("Invalid WorstSkill: has zero sub-skills.") 2115 for sk in self.skills: 2116 if isinstance(sk, Level): 2117 level = sk 2118 elif isinstance(sk, Skill): 2119 level = getSkillLevel(ctx.state, sk) 2120 elif isinstance(sk, SkillCombination): 2121 level = sk.effectiveLevel(ctx) 2122 else: 2123 raise RuntimeError( 2124 f"Invalid WorstSkill: found sub-skill '{repr(sk)}'" 2125 f" which is not a skill name string, level integer," 2126 f" or SkillCombination." 2127 ) 2128 if result is None or result > level: 2129 result = level 2130 2131 assert result is not None 2132 return result
Determines the effective level of each sub-skill-combo and returns the lowest of those.
2134 def unparse(self): 2135 result = "worst(" 2136 for sk in self.skills: 2137 if isinstance(sk, SkillCombination): 2138 result += sk.unparse() 2139 else: 2140 result += str(sk) 2141 result += ', ' 2142 return result[:-2] + ')'
Returns a string that SkillCombination.parse
would turn back
into a SkillCombination
equivalent to this one. For example:
>>> BestSkill('brains').unparse()
'best(brains)'
>>> WorstSkill('brains', 'brawn').unparse()
'worst(brains, brawn)'
>>> CombinedSkill(
... ConditionalSkill(ReqTokens('orb', 3), 'brains', 0),
... InverseSkill('luck')
... ).unparse()
'sum(if(orb*3, brains, 0), ~luck)'
2145class CombinedSkill(SkillCombination): 2146 def __init__( 2147 self, 2148 *skills: Union[SkillCombination, Skill, Level] 2149 ): 2150 """ 2151 Given one or more `SkillCombination` sub-items and/or skill 2152 names or levels, represents a situation where the sum of the 2153 effective levels of each sub-item is used. Skill names 2154 translate to the player's level for that skill (with 0 as a 2155 default) while level numbers translate to that number. 2156 """ 2157 if len(skills) == 0: 2158 raise ValueError( 2159 "Cannot create a `CombinedSkill` with 0 sub-skills." 2160 ) 2161 self.skills = skills 2162 2163 def __eq__(self, other: Any) -> bool: 2164 return ( 2165 isinstance(other, CombinedSkill) 2166 and other.skills == self.skills 2167 ) 2168 2169 def __hash__(self) -> int: 2170 result = 2871 2171 for sk in self.skills: 2172 result += hash(sk) 2173 return result 2174 2175 def __repr__(self) -> str: 2176 subs = ', '.join(repr(sk) for sk in self.skills) 2177 return "CombinedSkill(" + subs + ")" 2178 2179 def walk(self) -> Generator[ 2180 Union[SkillCombination, Skill, Level], 2181 None, 2182 None 2183 ]: 2184 yield self 2185 for sub in self.skills: 2186 if isinstance(sub, (Skill, Level)): 2187 yield sub 2188 else: 2189 yield from sub.walk() 2190 2191 def effectiveLevel(self, ctx: 'RequirementContext') -> Level: 2192 """ 2193 Determines the effective level of each sub-skill-combo and 2194 returns the sum of those, with 0 as a default. 2195 """ 2196 result = 0 2197 level: Level 2198 if len(self.skills) == 0: 2199 raise RuntimeError( 2200 "Invalid CombinedSkill: has zero sub-skills." 2201 ) 2202 for sk in self.skills: 2203 if isinstance(sk, Level): 2204 level = sk 2205 elif isinstance(sk, Skill): 2206 level = getSkillLevel(ctx.state, sk) 2207 elif isinstance(sk, SkillCombination): 2208 level = sk.effectiveLevel(ctx) 2209 else: 2210 raise RuntimeError( 2211 f"Invalid CombinedSkill: found sub-skill '{repr(sk)}'" 2212 f" which is not a skill name string, level integer," 2213 f" or SkillCombination." 2214 ) 2215 result += level 2216 2217 assert result is not None 2218 return result 2219 2220 def unparse(self): 2221 result = "sum(" 2222 for sk in self.skills: 2223 if isinstance(sk, SkillCombination): 2224 result += sk.unparse() 2225 else: 2226 result += str(sk) 2227 result += ', ' 2228 return result[:-2] + ')'
Represents which skill(s) are used for a Challenge
, including under
what circumstances different skills might apply using
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
2146 def __init__( 2147 self, 2148 *skills: Union[SkillCombination, Skill, Level] 2149 ): 2150 """ 2151 Given one or more `SkillCombination` sub-items and/or skill 2152 names or levels, represents a situation where the sum of the 2153 effective levels of each sub-item is used. Skill names 2154 translate to the player's level for that skill (with 0 as a 2155 default) while level numbers translate to that number. 2156 """ 2157 if len(skills) == 0: 2158 raise ValueError( 2159 "Cannot create a `CombinedSkill` with 0 sub-skills." 2160 ) 2161 self.skills = skills
Given one or more SkillCombination
sub-items and/or skill
names or levels, represents a situation where the sum of the
effective levels of each sub-item is used. Skill names
translate to the player's level for that skill (with 0 as a
default) while level numbers translate to that number.
2179 def walk(self) -> Generator[ 2180 Union[SkillCombination, Skill, Level], 2181 None, 2182 None 2183 ]: 2184 yield self 2185 for sub in self.skills: 2186 if isinstance(sub, (Skill, Level)): 2187 yield sub 2188 else: 2189 yield from sub.walk()
Yields this combination and each sub-part in depth-first traversal order.
2191 def effectiveLevel(self, ctx: 'RequirementContext') -> Level: 2192 """ 2193 Determines the effective level of each sub-skill-combo and 2194 returns the sum of those, with 0 as a default. 2195 """ 2196 result = 0 2197 level: Level 2198 if len(self.skills) == 0: 2199 raise RuntimeError( 2200 "Invalid CombinedSkill: has zero sub-skills." 2201 ) 2202 for sk in self.skills: 2203 if isinstance(sk, Level): 2204 level = sk 2205 elif isinstance(sk, Skill): 2206 level = getSkillLevel(ctx.state, sk) 2207 elif isinstance(sk, SkillCombination): 2208 level = sk.effectiveLevel(ctx) 2209 else: 2210 raise RuntimeError( 2211 f"Invalid CombinedSkill: found sub-skill '{repr(sk)}'" 2212 f" which is not a skill name string, level integer," 2213 f" or SkillCombination." 2214 ) 2215 result += level 2216 2217 assert result is not None 2218 return result
Determines the effective level of each sub-skill-combo and returns the sum of those, with 0 as a default.
2220 def unparse(self): 2221 result = "sum(" 2222 for sk in self.skills: 2223 if isinstance(sk, SkillCombination): 2224 result += sk.unparse() 2225 else: 2226 result += str(sk) 2227 result += ', ' 2228 return result[:-2] + ')'
Returns a string that SkillCombination.parse
would turn back
into a SkillCombination
equivalent to this one. For example:
>>> BestSkill('brains').unparse()
'best(brains)'
>>> WorstSkill('brains', 'brawn').unparse()
'worst(brains, brawn)'
>>> CombinedSkill(
... ConditionalSkill(ReqTokens('orb', 3), 'brains', 0),
... InverseSkill('luck')
... ).unparse()
'sum(if(orb*3, brains, 0), ~luck)'
2231class InverseSkill(SkillCombination): 2232 def __init__( 2233 self, 2234 invert: Union[SkillCombination, Skill, Level] 2235 ): 2236 """ 2237 Represents the effective level of the given `SkillCombination`, 2238 the level of the given `Skill`, or just the provided specific 2239 `Level`, except inverted (multiplied by -1). 2240 """ 2241 self.invert = invert 2242 2243 def __eq__(self, other: Any) -> bool: 2244 return ( 2245 isinstance(other, InverseSkill) 2246 and other.invert == self.invert 2247 ) 2248 2249 def __hash__(self) -> int: 2250 return 3193 + hash(self.invert) 2251 2252 def __repr__(self) -> str: 2253 return "InverseSkill(" + repr(self.invert) + ")" 2254 2255 def walk(self) -> Generator[ 2256 Union[SkillCombination, Skill, Level], 2257 None, 2258 None 2259 ]: 2260 yield self 2261 if isinstance(self.invert, SkillCombination): 2262 yield from self.invert.walk() 2263 else: 2264 yield self.invert 2265 2266 def effectiveLevel(self, ctx: 'RequirementContext') -> Level: 2267 """ 2268 Determines whether the requirement is satisfied or not and then 2269 returns the effective level of either the `ifSatisfied` or 2270 `ifNot` skill combination, as appropriate. 2271 """ 2272 if isinstance(self.invert, Level): 2273 return -self.invert 2274 elif isinstance(self.invert, Skill): 2275 return -getSkillLevel(ctx.state, self.invert) 2276 elif isinstance(self.invert, SkillCombination): 2277 return -self.invert.effectiveLevel(ctx) 2278 else: 2279 raise RuntimeError( 2280 f"Invalid InverseSkill: invert value {repr(self.invert)}" 2281 f" The invert value must be a Level (int), a Skill" 2282 f" (str), or a SkillCombination." 2283 ) 2284 2285 def unparse(self): 2286 # TODO: Move these to `parsing` to avoid hard-coded tokens here? 2287 if isinstance(self.invert, SkillCombination): 2288 return '~' + self.invert.unparse() 2289 else: 2290 return '~' + str(self.invert)
Represents which skill(s) are used for a Challenge
, including under
what circumstances different skills might apply using
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
2232 def __init__( 2233 self, 2234 invert: Union[SkillCombination, Skill, Level] 2235 ): 2236 """ 2237 Represents the effective level of the given `SkillCombination`, 2238 the level of the given `Skill`, or just the provided specific 2239 `Level`, except inverted (multiplied by -1). 2240 """ 2241 self.invert = invert
Represents the effective level of the given SkillCombination
,
the level of the given Skill
, or just the provided specific
Level
, except inverted (multiplied by -1).
2255 def walk(self) -> Generator[ 2256 Union[SkillCombination, Skill, Level], 2257 None, 2258 None 2259 ]: 2260 yield self 2261 if isinstance(self.invert, SkillCombination): 2262 yield from self.invert.walk() 2263 else: 2264 yield self.invert
Yields this combination and each sub-part in depth-first traversal order.
2266 def effectiveLevel(self, ctx: 'RequirementContext') -> Level: 2267 """ 2268 Determines whether the requirement is satisfied or not and then 2269 returns the effective level of either the `ifSatisfied` or 2270 `ifNot` skill combination, as appropriate. 2271 """ 2272 if isinstance(self.invert, Level): 2273 return -self.invert 2274 elif isinstance(self.invert, Skill): 2275 return -getSkillLevel(ctx.state, self.invert) 2276 elif isinstance(self.invert, SkillCombination): 2277 return -self.invert.effectiveLevel(ctx) 2278 else: 2279 raise RuntimeError( 2280 f"Invalid InverseSkill: invert value {repr(self.invert)}" 2281 f" The invert value must be a Level (int), a Skill" 2282 f" (str), or a SkillCombination." 2283 )
Determines whether the requirement is satisfied or not and then
returns the effective level of either the ifSatisfied
or
ifNot
skill combination, as appropriate.
2285 def unparse(self): 2286 # TODO: Move these to `parsing` to avoid hard-coded tokens here? 2287 if isinstance(self.invert, SkillCombination): 2288 return '~' + self.invert.unparse() 2289 else: 2290 return '~' + str(self.invert)
Returns a string that SkillCombination.parse
would turn back
into a SkillCombination
equivalent to this one. For example:
>>> BestSkill('brains').unparse()
'best(brains)'
>>> WorstSkill('brains', 'brawn').unparse()
'worst(brains, brawn)'
>>> CombinedSkill(
... ConditionalSkill(ReqTokens('orb', 3), 'brains', 0),
... InverseSkill('luck')
... ).unparse()
'sum(if(orb*3, brains, 0), ~luck)'
2293class ConditionalSkill(SkillCombination): 2294 def __init__( 2295 self, 2296 requirement: 'Requirement', 2297 ifSatisfied: Union[SkillCombination, Skill, Level], 2298 ifNot: Union[SkillCombination, Skill, Level] = 0 2299 ): 2300 """ 2301 Given a `Requirement` and two different sub-`SkillCombination`s, 2302 which can also be `Skill` names or fixed `Level`s, represents 2303 situations where which skills come into play depends on what 2304 capabilities the player has. In situations where the given 2305 requirement is satisfied, the `ifSatisfied` combination's 2306 effective level is used, and otherwise the `ifNot` level is 2307 used. By default `ifNot` is just the fixed level 0. 2308 """ 2309 self.requirement = requirement 2310 self.ifSatisfied = ifSatisfied 2311 self.ifNot = ifNot 2312 2313 def __eq__(self, other: Any) -> bool: 2314 return ( 2315 isinstance(other, ConditionalSkill) 2316 and other.requirement == self.requirement 2317 and other.ifSatisfied == self.ifSatisfied 2318 and other.ifNot == self.ifNot 2319 ) 2320 2321 def __hash__(self) -> int: 2322 return ( 2323 1278 2324 + hash(self.requirement) 2325 + hash(self.ifSatisfied) 2326 + hash(self.ifNot) 2327 ) 2328 2329 def __repr__(self) -> str: 2330 return ( 2331 "ConditionalSkill(" 2332 + repr(self.requirement) + ", " 2333 + repr(self.ifSatisfied) + ", " 2334 + repr(self.ifNot) 2335 + ")" 2336 ) 2337 2338 def walk(self) -> Generator[ 2339 Union[SkillCombination, Skill, Level], 2340 None, 2341 None 2342 ]: 2343 yield self 2344 for sub in (self.ifSatisfied, self.ifNot): 2345 if isinstance(sub, SkillCombination): 2346 yield from sub.walk() 2347 else: 2348 yield sub 2349 2350 def effectiveLevel(self, ctx: 'RequirementContext') -> Level: 2351 """ 2352 Determines whether the requirement is satisfied or not and then 2353 returns the effective level of either the `ifSatisfied` or 2354 `ifNot` skill combination, as appropriate. 2355 """ 2356 if self.requirement.satisfied(ctx): 2357 use = self.ifSatisfied 2358 sat = True 2359 else: 2360 use = self.ifNot 2361 sat = False 2362 2363 if isinstance(use, Level): 2364 return use 2365 elif isinstance(use, Skill): 2366 return getSkillLevel(ctx.state, use) 2367 elif isinstance(use, SkillCombination): 2368 return use.effectiveLevel(ctx) 2369 else: 2370 raise RuntimeError( 2371 f"Invalid ConditionalSkill: Requirement was" 2372 f" {'not ' if not sat else ''}satisfied, and the" 2373 f" corresponding skill value was not a level, skill, or" 2374 f" SkillCombination: {repr(use)}" 2375 ) 2376 2377 def unparse(self): 2378 result = f"if({self.requirement.unparse()}, " 2379 if isinstance(self.ifSatisfied, SkillCombination): 2380 result += self.ifSatisfied.unparse() 2381 else: 2382 result += str(self.ifSatisfied) 2383 result += ', ' 2384 if isinstance(self.ifNot, SkillCombination): 2385 result += self.ifNot.unparse() 2386 else: 2387 result += str(self.ifNot) 2388 return result + ')'
Represents which skill(s) are used for a Challenge
, including under
what circumstances different skills might apply using
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
2294 def __init__( 2295 self, 2296 requirement: 'Requirement', 2297 ifSatisfied: Union[SkillCombination, Skill, Level], 2298 ifNot: Union[SkillCombination, Skill, Level] = 0 2299 ): 2300 """ 2301 Given a `Requirement` and two different sub-`SkillCombination`s, 2302 which can also be `Skill` names or fixed `Level`s, represents 2303 situations where which skills come into play depends on what 2304 capabilities the player has. In situations where the given 2305 requirement is satisfied, the `ifSatisfied` combination's 2306 effective level is used, and otherwise the `ifNot` level is 2307 used. By default `ifNot` is just the fixed level 0. 2308 """ 2309 self.requirement = requirement 2310 self.ifSatisfied = ifSatisfied 2311 self.ifNot = ifNot
Given a Requirement
and two different sub-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.
2338 def walk(self) -> Generator[ 2339 Union[SkillCombination, Skill, Level], 2340 None, 2341 None 2342 ]: 2343 yield self 2344 for sub in (self.ifSatisfied, self.ifNot): 2345 if isinstance(sub, SkillCombination): 2346 yield from sub.walk() 2347 else: 2348 yield sub
Yields this combination and each sub-part in depth-first traversal order.
2350 def effectiveLevel(self, ctx: 'RequirementContext') -> Level: 2351 """ 2352 Determines whether the requirement is satisfied or not and then 2353 returns the effective level of either the `ifSatisfied` or 2354 `ifNot` skill combination, as appropriate. 2355 """ 2356 if self.requirement.satisfied(ctx): 2357 use = self.ifSatisfied 2358 sat = True 2359 else: 2360 use = self.ifNot 2361 sat = False 2362 2363 if isinstance(use, Level): 2364 return use 2365 elif isinstance(use, Skill): 2366 return getSkillLevel(ctx.state, use) 2367 elif isinstance(use, SkillCombination): 2368 return use.effectiveLevel(ctx) 2369 else: 2370 raise RuntimeError( 2371 f"Invalid ConditionalSkill: Requirement was" 2372 f" {'not ' if not sat else ''}satisfied, and the" 2373 f" corresponding skill value was not a level, skill, or" 2374 f" SkillCombination: {repr(use)}" 2375 )
Determines whether the requirement is satisfied or not and then
returns the effective level of either the ifSatisfied
or
ifNot
skill combination, as appropriate.
2377 def unparse(self): 2378 result = f"if({self.requirement.unparse()}, " 2379 if isinstance(self.ifSatisfied, SkillCombination): 2380 result += self.ifSatisfied.unparse() 2381 else: 2382 result += str(self.ifSatisfied) 2383 result += ', ' 2384 if isinstance(self.ifNot, SkillCombination): 2385 result += self.ifNot.unparse() 2386 else: 2387 result += str(self.ifNot) 2388 return result + ')'
Returns a string that SkillCombination.parse
would turn back
into a SkillCombination
equivalent to this one. For example:
>>> BestSkill('brains').unparse()
'best(brains)'
>>> WorstSkill('brains', 'brawn').unparse()
'worst(brains, brawn)'
>>> CombinedSkill(
... ConditionalSkill(ReqTokens('orb', 3), 'brains', 0),
... InverseSkill('luck')
... ).unparse()
'sum(if(orb*3, brains, 0), ~luck)'
2391class Challenge(TypedDict): 2392 """ 2393 Represents a random binary decision between two possible outcomes, 2394 only one of which will actually occur. The 'outcome' can be set to 2395 `True` or `False` to represent that the outcome of the challenge has 2396 been observed, or to `None` (the default) to represent a pending 2397 challenge. The chance of 'success' is determined by the associated 2398 skill(s) and the challenge level, although one or both may be 2399 unknown in which case a variable is used in place of a concrete 2400 value. Probabilities that are of the form 1/2**n or (2**n - 1) / 2401 (2**n) can be represented, the specific formula for the chance of 2402 success is for a challenge with a single skill is: 2403 2404 s = interacting entity's skill level in associated skill 2405 c = challenge level 2406 P(success) = { 2407 1 - 1/2**(1 + s - c) if s > c 2408 1/2 if s == c 2409 1/2**(1 + c - s) if c > s 2410 } 2411 2412 This probability formula is equivalent to the following procedure: 2413 2414 1. Flip one coin, plus one additional coin for each level difference 2415 between the skill and challenge levels. 2416 2. If the skill level is equal to or higher than the challenge 2417 level, the outcome is success if any single coin comes up heads. 2418 3. If the skill level is less than the challenge level, then the 2419 outcome is success only if *all* coins come up heads. 2420 4. If the outcome is not success, it is failure. 2421 2422 Multiple skills can be combined into a `SkillCombination`, which can 2423 use the max or min of several skills, add skill levels together, 2424 and/or have skills which are only relevant when a certain 2425 `Requirement` is satisfied. If a challenge has no skills associated 2426 with it, then the player's skill level counts as 0. 2427 2428 The slots are: 2429 2430 - 'skills': A `SkillCombination` that specifies the relevant 2431 skill(s). 2432 - 'level': An integer specifying the level of the challenge. Along 2433 with the appropriate skill level of the interacting entity, this 2434 determines the probability of success or failure. 2435 - 'success': A `Consequence` which will happen when the outcome is 2436 success. Note that since a `Consequence` can be a `Challenge`, 2437 multi-outcome challenges can be represented by chaining multiple 2438 challenges together. 2439 - 'failure': A `Consequence` which will happen when the outcome is 2440 failure. 2441 - 'outcome': The outcome of the challenge: `True` means success, 2442 `False` means failure, and `None` means "not known (yet)." 2443 """ 2444 skills: SkillCombination 2445 level: Level 2446 success: 'Consequence' 2447 failure: 'Consequence' 2448 outcome: Optional[bool]
Represents a random binary decision between two possible outcomes,
only one of which will actually occur. The 'outcome' can be set to
True
or False
to represent that the outcome of the challenge has
been observed, or to None
(the default) to represent a pending
challenge. The chance of 'success' is determined by the associated
skill(s) and the challenge level, although one or both may be
unknown in which case a variable is used in place of a concrete
value. Probabilities that are of the form 1/2n or (2n - 1) /
(2**n) can be represented, the specific formula for the chance of
success is for a challenge with a single skill is:
s = interacting entity's skill level in associated skill
c = challenge level
P(success) = {
1 - 1/2**(1 + s - c) if s > c
1/2 if s == c
1/2**(1 + c - s) if c > s
}
This probability formula is equivalent to the following procedure:
- 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)."
2451def challenge( 2452 skills: Optional[SkillCombination] = None, 2453 level: Level = 0, 2454 success: Optional['Consequence'] = None, 2455 failure: Optional['Consequence'] = None, 2456 outcome: Optional[bool] = None 2457): 2458 """ 2459 Factory for `Challenge`s, defaults to empty effects for both success 2460 and failure outcomes, so that you can just provide one or the other 2461 if you need to. Skills defaults to an empty list, the level defaults 2462 to 0 and the outcome defaults to `None` which means "not (yet) 2463 known." 2464 """ 2465 if skills is None: 2466 skills = BestSkill(0) 2467 if success is None: 2468 success = [] 2469 if failure is None: 2470 failure = [] 2471 return { 2472 'skills': skills, 2473 'level': level, 2474 'success': success, 2475 'failure': failure, 2476 'outcome': outcome 2477 }
Factory for 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."
2480class Condition(TypedDict): 2481 """ 2482 Represents a condition over `Capability`, `Token`, and/or `Mechanism` 2483 states which applies to one or more `Effect`s or `Challenge`s as part 2484 of a `Consequence`. If the specified `Requirement` is satisfied, the 2485 included `Consequence` is treated as if it were part of the 2486 `Consequence` that the `Condition` is inside of, if the requirement 2487 is not satisfied, then the internal `Consequence` is skipped and the 2488 alternate consequence is used instead. Either sub-consequence may of 2489 course be an empty list. 2490 """ 2491 condition: 'Requirement' 2492 consequence: 'Consequence' 2493 alternative: 'Consequence'
Represents a condition over Capability
, Token
, and/or Mechanism
states which applies to one or more 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.
2496def condition( 2497 condition: 'Requirement', 2498 consequence: 'Consequence', 2499 alternative: Optional['Consequence'] = None 2500): 2501 """ 2502 Factory for conditions that just glues the given requirement, 2503 consequence, and alternative together. The alternative defaults to 2504 an empty list if not specified. 2505 """ 2506 if alternative is None: 2507 alternative = [] 2508 return { 2509 'condition': condition, 2510 'consequence': consequence, 2511 'alternative': alternative 2512 }
Factory for conditions that just glues the given requirement, consequence, and alternative together. The alternative defaults to an empty list if not specified.
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
.
2550def resetChallengeOutcomes(consequence: Consequence) -> None: 2551 """ 2552 Traverses all sub-consequences of the given consequence, setting the 2553 outcomes of any `Challenge`s it encounters to `None`, to prepare for 2554 a fresh call to `observeChallengeOutcomes`. 2555 2556 Resets all outcomes in every branch, regardless of previous 2557 outcomes. 2558 2559 For example: 2560 2561 >>> from . import core 2562 >>> e = core.emptySituation() 2563 >>> c = challenge( 2564 ... success=[effect(gain=('money', 12))], 2565 ... failure=[effect(lose=('money', 10))] 2566 ... ) # skill defaults to 'luck', level to 0, and outcome to None 2567 >>> c['outcome'] is None # default outcome is None 2568 True 2569 >>> r = observeChallengeOutcomes(e, [c], policy='mostLikely') 2570 >>> r[0]['outcome'] 2571 True 2572 >>> c['outcome'] # original outcome is changed from None 2573 True 2574 >>> r[0] is c 2575 True 2576 >>> resetChallengeOutcomes([c]) 2577 >>> c['outcome'] is None # now has been reset 2578 True 2579 >>> r[0]['outcome'] is None # same object... 2580 True 2581 >>> resetChallengeOutcomes(c) # can't reset just a Challenge 2582 Traceback (most recent call last): 2583 ... 2584 TypeError... 2585 >>> r = observeChallengeOutcomes(e, [c], policy='success') 2586 >>> r[0]['outcome'] 2587 True 2588 >>> r = observeChallengeOutcomes(e, [c], policy='failure') 2589 >>> r[0]['outcome'] # wasn't reset 2590 True 2591 >>> resetChallengeOutcomes([c]) # now reset it 2592 >>> c['outcome'] is None 2593 True 2594 >>> r = observeChallengeOutcomes(e, [c], policy='failure') 2595 >>> r[0]['outcome'] # was reset 2596 False 2597 """ 2598 if not isinstance(consequence, list): 2599 raise TypeError( 2600 f"Invalid consequence: must be a list." 2601 f"\nGot: {repr(consequence)}" 2602 ) 2603 2604 for item in consequence: 2605 if not isinstance(item, dict): 2606 raise TypeError( 2607 f"Invalid consequence: items in the list must be" 2608 f" Effects, Challenges, or Conditions." 2609 f"\nGot item: {repr(item)}" 2610 ) 2611 if 'skills' in item: # must be a Challenge 2612 item = cast(Challenge, item) 2613 item['outcome'] = None 2614 # reset both branches 2615 resetChallengeOutcomes(item['success']) 2616 resetChallengeOutcomes(item['failure']) 2617 2618 elif 'value' in item: # an Effect 2619 continue # Effects don't have sub-outcomes 2620 2621 elif 'condition' in item: # a Condition 2622 item = cast(Condition, item) 2623 resetChallengeOutcomes(item['consequence']) 2624 resetChallengeOutcomes(item['alternative']) 2625 2626 else: # bad dict 2627 raise TypeError( 2628 f"Invalid consequence: items in the list must be" 2629 f" Effects, Challenges, or Conditions (got a dictionary" 2630 f" without 'skills', 'value', or 'condition' keys)." 2631 f"\nGot item: {repr(item)}" 2632 )
Traverses all sub-consequences of the given consequence, setting the
outcomes of any 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
2635def observeChallengeOutcomes( 2636 context: RequirementContext, 2637 consequence: Consequence, 2638 location: Optional[Set[DecisionID]] = None, 2639 policy: ChallengePolicy = 'random', 2640 knownOutcomes: Optional[List[bool]] = None, 2641 makeCopy: bool = False 2642) -> Consequence: 2643 """ 2644 Given a `RequirementContext` (for `Capability`, `Token`, and `Skill` 2645 info as well as equivalences in the `DecisionGraph` and a 2646 search-from location for mechanism names) and a `Conseqeunce` to be 2647 observed, sets the 'outcome' value for each `Challenge` in it to 2648 either `True` or `False` by determining an outcome for each 2649 `Challenge` that's relevant (challenges locked behind unsatisfied 2650 `Condition`s or on untaken branches of other challenges are not 2651 given outcomes). `Challenge`s that already have assigned outcomes 2652 re-use those outcomes, call `resetChallengeOutcomes` beforehand if 2653 you want to re-decide each challenge with a new policy, and use the 2654 'specified' policy if you want to ensure only pre-specified outcomes 2655 are used. 2656 2657 Normally, the return value is just the original `consequence` 2658 object. However, if `makeCopy` is set to `True`, a deep copy is made 2659 and returned, so the original is not modified. One potential problem 2660 with this is that effects will be copied in this process, which 2661 means that if they are applied, things like delays and toggles won't 2662 update properly. `makeCopy` should thus normally not be used. 2663 2664 The 'policy' value can be one of the `ChallengePolicy` values. The 2665 default is 'random', in which case the `random.random` function is 2666 used to determine each outcome, based on the probability derived 2667 from the challenge level and the associated skill level. The other 2668 policies are: 2669 2670 - 'mostLikely': the result of each challenge will be whichever 2671 outcome is more likely, with success always happening instead of 2672 failure when the probabilities are 50/50. 2673 - 'fewestEffects`: whichever combination of outcomes leads to the 2674 fewest total number of effects will be chosen (modulo satisfying 2675 requirements of `Condition`s). Note that there's no estimation 2676 of the severity of effects, just the raw number. Ties in terms 2677 of number of effects are broken towards successes. This policy 2678 involves evaluating all possible outcome combinations to figure 2679 out which one has the fewest effects. 2680 - 'success' or 'failure': all outcomes will either succeed, or 2681 fail, as specified. Note that success/failure may cut off some 2682 challenges, so it's not the case that literally every challenge 2683 will succeed/fail; some may be skipped because of the 2684 specified success/failure of a prior challenge. 2685 - 'specified': all outcomes have already been specified, and those 2686 pre-specified outcomes should be used as-is. 2687 2688 2689 In call cases, outcomes specified via `knownOutcomes` take precedence 2690 over the challenge policy. The `knownOutcomes` list will be emptied 2691 out as this function works, but extra consequences beyond what's 2692 needed will be ignored (and left in the list). 2693 2694 Note that there are limits on the resolution of Python's random 2695 number generation; for challenges with extremely high or low levels 2696 relative to the associated skill(s) where the probability of success 2697 is very close to 1 or 0, there may not actually be any chance of 2698 success/failure at all. Typically you can ignore this, because such 2699 cases should not normally come up in practice, and because the odds 2700 of success/failure in those cases are such that to notice the 2701 missing possibility share you'd have to simulate outcomes a 2702 ridiculous number of times. 2703 2704 TODO: Location examples; move some of these to a separate testing 2705 file. 2706 2707 For example: 2708 2709 >>> random.seed(17) 2710 >>> warnings.filterwarnings('error') 2711 >>> from . import core 2712 >>> e = core.emptySituation() 2713 >>> c = challenge( 2714 ... success=[effect(gain=('money', 12))], 2715 ... failure=[effect(lose=('money', 10))] 2716 ... ) # skill defaults to 'luck', level to 0, and outcome to None 2717 >>> c['outcome'] is None # default outcome is None 2718 True 2719 >>> r = observeChallengeOutcomes(e, [c]) 2720 >>> r[0]['outcome'] 2721 False 2722 >>> c['outcome'] # original outcome is changed from None 2723 False 2724 >>> all( 2725 ... observeChallengeOutcomes(e, [c])[0]['outcome'] is False 2726 ... for i in range(20) 2727 ... ) # no reset -> same outcome 2728 True 2729 >>> resetChallengeOutcomes([c]) 2730 >>> observeChallengeOutcomes(e, [c])[0]['outcome'] # Random after reset 2731 False 2732 >>> resetChallengeOutcomes([c]) 2733 >>> observeChallengeOutcomes(e, [c])[0]['outcome'] # Random after reset 2734 False 2735 >>> resetChallengeOutcomes([c]) 2736 >>> observeChallengeOutcomes(e, [c])[0]['outcome'] # Random after reset 2737 True 2738 >>> observeChallengeOutcomes(e, c) # Can't resolve just a Challenge 2739 Traceback (most recent call last): 2740 ... 2741 TypeError... 2742 >>> allSame = [] 2743 >>> for i in range(20): 2744 ... resetChallengeOutcomes([c]) 2745 ... obs = observeChallengeOutcomes(e, [c, c]) 2746 ... allSame.append(obs[0]['outcome'] == obs[1]['outcome']) 2747 >>> allSame == [True]*20 2748 True 2749 >>> different = [] 2750 >>> for i in range(20): 2751 ... resetChallengeOutcomes([c]) 2752 ... obs = observeChallengeOutcomes(e, [c, copy.deepcopy(c)]) 2753 ... different.append(obs[0]['outcome'] == obs[1]['outcome']) 2754 >>> False in different 2755 True 2756 >>> all( # Tie breaks towards success 2757 ... ( 2758 ... resetChallengeOutcomes([c]), 2759 ... observeChallengeOutcomes(e, [c], policy='mostLikely') 2760 ... )[1][0]['outcome'] is True 2761 ... for i in range(20) 2762 ... ) 2763 True 2764 >>> all( # Tie breaks towards success 2765 ... ( 2766 ... resetChallengeOutcomes([c]), 2767 ... observeChallengeOutcomes(e, [c], policy='fewestEffects') 2768 ... )[1][0]['outcome'] is True 2769 ... for i in range(20) 2770 ... ) 2771 True 2772 >>> all( 2773 ... ( 2774 ... resetChallengeOutcomes([c]), 2775 ... observeChallengeOutcomes(e, [c], policy='success') 2776 ... )[1][0]['outcome'] is True 2777 ... for i in range(20) 2778 ... ) 2779 True 2780 >>> all( 2781 ... ( 2782 ... resetChallengeOutcomes([c]), 2783 ... observeChallengeOutcomes(e, [c], policy='failure') 2784 ... )[1][0]['outcome'] is False 2785 ... for i in range(20) 2786 ... ) 2787 True 2788 >>> c['outcome'] = False # Fix the outcome; now policy is ignored 2789 >>> observeChallengeOutcomes(e, [c], policy='success')[0]['outcome'] 2790 False 2791 >>> c = challenge( 2792 ... skills=BestSkill('charisma'), 2793 ... level=8, 2794 ... success=[ 2795 ... challenge( 2796 ... skills=BestSkill('strength'), 2797 ... success=[effect(gain='winner')] 2798 ... ) 2799 ... ], # level defaults to 0 2800 ... failure=[ 2801 ... challenge( 2802 ... skills=BestSkill('strength'), 2803 ... failure=[effect(gain='loser')] 2804 ... ), 2805 ... effect(gain='sad') 2806 ... ] 2807 ... ) 2808 >>> r = observeChallengeOutcomes(e, [c]) # random 2809 >>> r[0]['outcome'] 2810 False 2811 >>> r[0]['failure'][0]['outcome'] # also random 2812 True 2813 >>> r[0]['success'][0]['outcome'] is None # skipped so not assigned 2814 True 2815 >>> resetChallengeOutcomes([c]) 2816 >>> r2 = observeChallengeOutcomes(e, [c]) # random 2817 >>> r[0]['outcome'] 2818 False 2819 >>> r[0]['success'][0]['outcome'] is None # untaken branch no outcome 2820 True 2821 >>> r[0]['failure'][0]['outcome'] # also random 2822 False 2823 >>> def outcomeList(consequence): 2824 ... 'Lists outcomes from each challenge attempted.' 2825 ... result = [] 2826 ... for item in consequence: 2827 ... if 'skills' in item: 2828 ... result.append(item['outcome']) 2829 ... if item['outcome'] is True: 2830 ... result.extend(outcomeList(item['success'])) 2831 ... elif item['outcome'] is False: 2832 ... result.extend(outcomeList(item['failure'])) 2833 ... else: 2834 ... pass # end here 2835 ... return result 2836 >>> def skilled(**skills): 2837 ... 'Create a clone of our Situation with specific skills.' 2838 ... r = copy.deepcopy(e) 2839 ... r.state['common']['capabilities']['skills'].update(skills) 2840 ... return r 2841 >>> resetChallengeOutcomes([c]) 2842 >>> r = observeChallengeOutcomes( # 'mostLikely' policy 2843 ... skilled(charisma=9, strength=1), 2844 ... [c], 2845 ... policy='mostLikely' 2846 ... ) 2847 >>> outcomeList(r) 2848 [True, True] 2849 >>> resetChallengeOutcomes([c]) 2850 >>> outcomeList(observeChallengeOutcomes( 2851 ... skilled(charisma=7, strength=-1), 2852 ... [c], 2853 ... policy='mostLikely' 2854 ... )) 2855 [False, False] 2856 >>> resetChallengeOutcomes([c]) 2857 >>> outcomeList(observeChallengeOutcomes( 2858 ... skilled(charisma=8, strength=-1), 2859 ... [c], 2860 ... policy='mostLikely' 2861 ... )) 2862 [True, False] 2863 >>> resetChallengeOutcomes([c]) 2864 >>> outcomeList(observeChallengeOutcomes( 2865 ... skilled(charisma=7, strength=0), 2866 ... [c], 2867 ... policy='mostLikely' 2868 ... )) 2869 [False, True] 2870 >>> resetChallengeOutcomes([c]) 2871 >>> outcomeList(observeChallengeOutcomes( 2872 ... skilled(charisma=20, strength=10), 2873 ... [c], 2874 ... policy='mostLikely' 2875 ... )) 2876 [True, True] 2877 >>> resetChallengeOutcomes([c]) 2878 >>> outcomeList(observeChallengeOutcomes( 2879 ... skilled(charisma=-10, strength=-10), 2880 ... [c], 2881 ... policy='mostLikely' 2882 ... )) 2883 [False, False] 2884 >>> resetChallengeOutcomes([c]) 2885 >>> outcomeList(observeChallengeOutcomes( 2886 ... e, 2887 ... [c], 2888 ... policy='fewestEffects' 2889 ... )) 2890 [True, False] 2891 >>> resetChallengeOutcomes([c]) 2892 >>> outcomeList(observeChallengeOutcomes( 2893 ... skilled(charisma=-100, strength=100), 2894 ... [c], 2895 ... policy='fewestEffects' 2896 ... )) # unaffected by stats 2897 [True, False] 2898 >>> resetChallengeOutcomes([c]) 2899 >>> outcomeList(observeChallengeOutcomes(e, [c], policy='success')) 2900 [True, True] 2901 >>> resetChallengeOutcomes([c]) 2902 >>> outcomeList(observeChallengeOutcomes(e, [c], policy='failure')) 2903 [False, False] 2904 >>> cc = copy.deepcopy(c) 2905 >>> resetChallengeOutcomes([cc]) 2906 >>> cc['outcome'] = False 2907 >>> outcomeList(observeChallengeOutcomes( 2908 ... skilled(charisma=10, strength=10), 2909 ... [cc], 2910 ... policy='mostLikely' 2911 ... )) # pre-observed outcome won't be changed 2912 [False, True] 2913 >>> resetChallengeOutcomes([cc]) 2914 >>> cc['outcome'] = False 2915 >>> outcomeList(observeChallengeOutcomes( 2916 ... e, 2917 ... [cc], 2918 ... policy='fewestEffects' 2919 ... )) # pre-observed outcome won't be changed 2920 [False, True] 2921 >>> cc['success'][0]['outcome'] is None # not assigned on other branch 2922 True 2923 >>> resetChallengeOutcomes([cc]) 2924 >>> r = observeChallengeOutcomes(e, [cc], policy='fewestEffects') 2925 >>> r[0] is cc # results are aliases, not clones 2926 True 2927 >>> outcomeList(r) 2928 [True, False] 2929 >>> cc['success'][0]['outcome'] # inner outcome now assigned 2930 False 2931 >>> cc['failure'][0]['outcome'] is None # now this is other branch 2932 True 2933 >>> resetChallengeOutcomes([cc]) 2934 >>> r = observeChallengeOutcomes( 2935 ... e, 2936 ... [cc], 2937 ... policy='fewestEffects', 2938 ... makeCopy=True 2939 ... ) 2940 >>> r[0] is cc # now result is a clone 2941 False 2942 >>> outcomeList(r) 2943 [True, False] 2944 >>> observedEffects(genericContextForSituation(e), r) 2945 [] 2946 >>> r[0]['outcome'] # outcome was assigned 2947 True 2948 >>> cc['outcome'] is None # only to the copy, not to the original 2949 True 2950 >>> cn = [ 2951 ... condition( 2952 ... ReqCapability('boost'), 2953 ... [ 2954 ... challenge(success=[effect(gain=('$', 1))]), 2955 ... effect(gain=('$', 2)) 2956 ... ] 2957 ... ), 2958 ... challenge(failure=[effect(gain=('$', 4))]) 2959 ... ] 2960 >>> o = observeChallengeOutcomes(e, cn, policy='fewestEffects') 2961 >>> # Without 'boost', inner challenge does not get an outcome 2962 >>> o[0]['consequence'][0]['outcome'] is None 2963 True 2964 >>> o[1]['outcome'] # avoids effect 2965 True 2966 >>> hasBoost = copy.deepcopy(e) 2967 >>> hasBoost.state['common']['capabilities']['capabilities'].add('boost') 2968 >>> resetChallengeOutcomes(cn) 2969 >>> o = observeChallengeOutcomes(hasBoost, cn, policy='fewestEffects') 2970 >>> o[0]['consequence'][0]['outcome'] # now assigned an outcome 2971 False 2972 >>> o[1]['outcome'] # avoids effect 2973 True 2974 >>> from . import core 2975 >>> e = core.emptySituation() 2976 >>> c = challenge( 2977 ... skills=BestSkill('skill'), 2978 ... level=4, # very unlikely at level 0 2979 ... success=[], 2980 ... failure=[effect(lose=('money', 10))], 2981 ... outcome=True 2982 ... ) # pre-assigned outcome 2983 >>> c['outcome'] # verify 2984 True 2985 >>> r = observeChallengeOutcomes(e, [c], policy='specified') 2986 >>> r[0]['outcome'] 2987 True 2988 >>> c['outcome'] # original outcome is unchanged 2989 True 2990 >>> c['outcome'] = False # the more likely outcome 2991 >>> r = observeChallengeOutcomes(e, [c], policy='specified') 2992 >>> r[0]['outcome'] # re-uses the new outcome 2993 False 2994 >>> c['outcome'] # outcome is unchanged 2995 False 2996 >>> c['outcome'] = True # change it back 2997 >>> r = observeChallengeOutcomes(e, [c], policy='specified') 2998 >>> r[0]['outcome'] # re-use the outcome again 2999 True 3000 >>> c['outcome'] # outcome is unchanged 3001 True 3002 >>> c['outcome'] = None # set it to no info; will crash 3003 >>> r = observeChallengeOutcomes(e, [c], policy='specified') 3004 Traceback (most recent call last): 3005 ... 3006 ValueError... 3007 >>> warnings.filterwarnings('default') 3008 >>> c['outcome'] is None # same after crash 3009 True 3010 >>> r = observeChallengeOutcomes( 3011 ... e, 3012 ... [c], 3013 ... policy='specified', 3014 ... knownOutcomes=[True] 3015 ... ) 3016 >>> r[0]['outcome'] # picked up known outcome 3017 True 3018 >>> c['outcome'] # outcome is changed 3019 True 3020 >>> resetChallengeOutcomes([c]) 3021 >>> c['outcome'] is None # has been reset 3022 True 3023 >>> r = observeChallengeOutcomes( 3024 ... e, 3025 ... [c], 3026 ... policy='specified', 3027 ... knownOutcomes=[True] 3028 ... ) 3029 >>> c['outcome'] # from known outcomes 3030 True 3031 >>> ko = [False] 3032 >>> r = observeChallengeOutcomes( 3033 ... e, 3034 ... [c], 3035 ... policy='specified', 3036 ... knownOutcomes=ko 3037 ... ) 3038 >>> c['outcome'] # from known outcomes 3039 False 3040 >>> ko # known outcomes list gets used up 3041 [] 3042 >>> ko = [False, False] 3043 >>> r = observeChallengeOutcomes( 3044 ... e, 3045 ... [c], 3046 ... policy='specified', 3047 ... knownOutcomes=ko 3048 ... ) # too many outcomes is an error 3049 >>> ko 3050 [False] 3051 """ 3052 if not isinstance(consequence, list): 3053 raise TypeError( 3054 f"Invalid consequence: must be a list." 3055 f"\nGot: {repr(consequence)}" 3056 ) 3057 3058 if knownOutcomes is None: 3059 knownOutcomes = [] 3060 3061 if makeCopy: 3062 result = copy.deepcopy(consequence) 3063 else: 3064 result = consequence 3065 3066 for item in result: 3067 if not isinstance(item, dict): 3068 raise TypeError( 3069 f"Invalid consequence: items in the list must be" 3070 f" Effects, Challenges, or Conditions." 3071 f"\nGot item: {repr(item)}" 3072 ) 3073 if 'skills' in item: # must be a Challenge 3074 item = cast(Challenge, item) 3075 if len(knownOutcomes) > 0: 3076 item['outcome'] = knownOutcomes.pop(0) 3077 if item['outcome'] is not None: 3078 if item['outcome']: 3079 observeChallengeOutcomes( 3080 context, 3081 item['success'], 3082 location=location, 3083 policy=policy, 3084 knownOutcomes=knownOutcomes, 3085 makeCopy=False 3086 ) 3087 else: 3088 observeChallengeOutcomes( 3089 context, 3090 item['failure'], 3091 location=location, 3092 policy=policy, 3093 knownOutcomes=knownOutcomes, 3094 makeCopy=False 3095 ) 3096 else: # need to assign an outcome 3097 if policy == 'specified': 3098 raise ValueError( 3099 f"Challenge has unspecified outcome so the" 3100 f" 'specified' policy cannot be used when" 3101 f" observing its outcomes:" 3102 f"\n{item}" 3103 ) 3104 level = item['skills'].effectiveLevel(context) 3105 against = item['level'] 3106 if level < against: 3107 p = 1 / (2 ** (1 + against - level)) 3108 else: 3109 p = 1 - (1 / (2 ** (1 + level - against))) 3110 if policy == 'random': 3111 if random.random() < p: # success 3112 item['outcome'] = True 3113 else: 3114 item['outcome'] = False 3115 elif policy == 'mostLikely': 3116 if p >= 0.5: 3117 item['outcome'] = True 3118 else: 3119 item['outcome'] = False 3120 elif policy == 'fewestEffects': 3121 # Resolve copies so we don't affect original 3122 subSuccess = observeChallengeOutcomes( 3123 context, 3124 item['success'], 3125 location=location, 3126 policy=policy, 3127 knownOutcomes=knownOutcomes[:], 3128 makeCopy=True 3129 ) 3130 subFailure = observeChallengeOutcomes( 3131 context, 3132 item['failure'], 3133 location=location, 3134 policy=policy, 3135 knownOutcomes=knownOutcomes[:], 3136 makeCopy=True 3137 ) 3138 if ( 3139 len(observedEffects(context, subSuccess)) 3140 <= len(observedEffects(context, subFailure)) 3141 ): 3142 item['outcome'] = True 3143 else: 3144 item['outcome'] = False 3145 elif policy == 'success': 3146 item['outcome'] = True 3147 elif policy == 'failure': 3148 item['outcome'] = False 3149 3150 # Figure out outcomes for sub-consequence if we don't 3151 # already have them... 3152 if item['outcome'] not in (True, False): 3153 raise TypeError( 3154 f"Challenge has invalid outcome type" 3155 f" {type(item['outcome'])} after observation." 3156 f"\nOutcome value: {repr(item['outcome'])}" 3157 ) 3158 3159 if item['outcome']: 3160 observeChallengeOutcomes( 3161 context, 3162 item['success'], 3163 location=location, 3164 policy=policy, 3165 knownOutcomes=knownOutcomes, 3166 makeCopy=False 3167 ) 3168 else: 3169 observeChallengeOutcomes( 3170 context, 3171 item['failure'], 3172 location=location, 3173 policy=policy, 3174 knownOutcomes=knownOutcomes, 3175 makeCopy=False 3176 ) 3177 3178 elif 'value' in item: 3179 continue # Effects do not need success/failure assigned 3180 3181 elif 'condition' in item: # a Condition 3182 if item['condition'].satisfied(context): 3183 observeChallengeOutcomes( 3184 context, 3185 item['consequence'], 3186 location=location, 3187 policy=policy, 3188 knownOutcomes=knownOutcomes, 3189 makeCopy=False 3190 ) 3191 else: 3192 observeChallengeOutcomes( 3193 context, 3194 item['alternative'], 3195 location=location, 3196 policy=policy, 3197 knownOutcomes=knownOutcomes, 3198 makeCopy=False 3199 ) 3200 3201 else: # bad dict 3202 raise TypeError( 3203 f"Invalid consequence: items in the list must be" 3204 f" Effects, Challenges, or Conditions (got a dictionary" 3205 f" without 'skills', 'value', or 'condition' keys)." 3206 f"\nGot item: {repr(item)}" 3207 ) 3208 3209 # Return copy or original, now with options selected 3210 return result
Given a RequirementContext
(for Capability
, Token
, and Skill
info as well as equivalences in the DecisionGraph
and a
search-from location for mechanism names) and a Conseqeunce
to be
observed, sets the 'outcome' value for each Challenge
in it to
either True
or False
by determining an outcome for each
Challenge
that's relevant (challenges locked behind unsatisfied
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]
3213class UnassignedOutcomeWarning(Warning): 3214 """ 3215 A warning issued when asking for observed effects of a `Consequence` 3216 whose `Challenge` outcomes have not been fully assigned. 3217 """ 3218 pass
A warning issued when asking for observed effects of a Consequence
whose Challenge
outcomes have not been fully assigned.
Inherited Members
- builtins.Warning
- Warning
- builtins.BaseException
- with_traceback
- add_note
- args
3221def observedEffects( 3222 context: RequirementContext, 3223 observed: Consequence, 3224 skipWarning=False, 3225 baseIndex: int = 0 3226) -> List[int]: 3227 """ 3228 Given a `Situation` and a `Consequence` whose challenges have 3229 outcomes assigned, returns a tuple containing a list of the 3230 depth-first-indices of each effect to apply. You can use 3231 `consequencePart` to extract the actual `Effect` values from the 3232 consequence based on their indices. 3233 3234 Only effects that actually apply are included, based on the observed 3235 outcomes as well as which `Condition`(s) are met, although charges 3236 and delays for the effects are not taken into account. 3237 3238 `baseIndex` can be set to something other than 0 to start indexing 3239 at that value. Issues an `UnassignedOutcomeWarning` if it encounters 3240 a challenge whose outcome has not been observed, unless 3241 `skipWarning` is set to `True`. In that case, no effects are listed 3242 for outcomes of that challenge. 3243 3244 For example: 3245 3246 >>> from . import core 3247 >>> warnings.filterwarnings('error') 3248 >>> e = core.emptySituation() 3249 >>> def skilled(**skills): 3250 ... 'Create a clone of our FocalContext with specific skills.' 3251 ... r = copy.deepcopy(e) 3252 ... r.state['common']['capabilities']['skills'].update(skills) 3253 ... return r 3254 >>> c = challenge( # index 1 in [c] (index 0 is the outer list) 3255 ... skills=BestSkill('charisma'), 3256 ... level=8, 3257 ... success=[ 3258 ... effect(gain='happy'), # index 3 in [c] 3259 ... challenge( 3260 ... skills=BestSkill('strength'), 3261 ... success=[effect(gain='winner')] # index 6 in [c] 3262 ... # failure is index 7 3263 ... ) # level defaults to 0 3264 ... ], 3265 ... failure=[ 3266 ... challenge( 3267 ... skills=BestSkill('strength'), 3268 ... # success is index 10 3269 ... failure=[effect(gain='loser')] # index 12 in [c] 3270 ... ), 3271 ... effect(gain='sad') # index 13 in [c] 3272 ... ] 3273 ... ) 3274 >>> import pytest 3275 >>> with pytest.warns(UnassignedOutcomeWarning): 3276 ... observedEffects(e, [c]) 3277 [] 3278 >>> with pytest.warns(UnassignedOutcomeWarning): 3279 ... observedEffects(e, [c, c]) 3280 [] 3281 >>> observedEffects(e, [c, c], skipWarning=True) 3282 [] 3283 >>> c['outcome'] = 'invalid value' # must be True, False, or None 3284 >>> observedEffects(e, [c]) 3285 Traceback (most recent call last): 3286 ... 3287 TypeError... 3288 >>> yesYes = skilled(charisma=10, strength=5) 3289 >>> yesNo = skilled(charisma=10, strength=-1) 3290 >>> noYes = skilled(charisma=4, strength=5) 3291 >>> noNo = skilled(charisma=4, strength=-1) 3292 >>> resetChallengeOutcomes([c]) 3293 >>> observedEffects( 3294 ... yesYes, 3295 ... observeChallengeOutcomes(yesYes, [c], policy='mostLikely') 3296 ... ) 3297 [3, 6] 3298 >>> resetChallengeOutcomes([c]) 3299 >>> observedEffects( 3300 ... yesNo, 3301 ... observeChallengeOutcomes(yesNo, [c], policy='mostLikely') 3302 ... ) 3303 [3] 3304 >>> resetChallengeOutcomes([c]) 3305 >>> observedEffects( 3306 ... noYes, 3307 ... observeChallengeOutcomes(noYes, [c], policy='mostLikely') 3308 ... ) 3309 [13] 3310 >>> resetChallengeOutcomes([c]) 3311 >>> observedEffects( 3312 ... noNo, 3313 ... observeChallengeOutcomes(noNo, [c], policy='mostLikely') 3314 ... ) 3315 [12, 13] 3316 >>> warnings.filterwarnings('default') 3317 >>> # known outcomes override policy & pre-specified outcomes 3318 >>> observedEffects( 3319 ... noNo, 3320 ... observeChallengeOutcomes( 3321 ... noNo, 3322 ... [c], 3323 ... policy='mostLikely', 3324 ... knownOutcomes=[True, True]) 3325 ... ) 3326 [3, 6] 3327 >>> observedEffects( 3328 ... yesYes, 3329 ... observeChallengeOutcomes( 3330 ... yesYes, 3331 ... [c], 3332 ... policy='mostLikely', 3333 ... knownOutcomes=[False, False]) 3334 ... ) 3335 [12, 13] 3336 >>> resetChallengeOutcomes([c]) 3337 >>> observedEffects( 3338 ... yesYes, 3339 ... observeChallengeOutcomes( 3340 ... yesYes, 3341 ... [c], 3342 ... policy='mostLikely', 3343 ... knownOutcomes=[False, False]) 3344 ... ) 3345 [12, 13] 3346 """ 3347 result: List[int] = [] 3348 totalCount: int = baseIndex + 1 # +1 for the outer list 3349 if not isinstance(observed, list): 3350 raise TypeError( 3351 f"Invalid consequence: must be a list." 3352 f"\nGot: {repr(observed)}" 3353 ) 3354 for item in observed: 3355 if not isinstance(item, dict): 3356 raise TypeError( 3357 f"Invalid consequence: items in the list must be" 3358 f" Effects, Challenges, or Conditions." 3359 f"\nGot item: {repr(item)}" 3360 ) 3361 3362 if 'skills' in item: # must be a Challenge 3363 item = cast(Challenge, item) 3364 succeeded = item['outcome'] 3365 useCh: Optional[Literal['success', 'failure']] 3366 if succeeded is True: 3367 useCh = 'success' 3368 elif succeeded is False: 3369 useCh = 'failure' 3370 else: 3371 useCh = None 3372 level = item["level"] 3373 if succeeded is not None: 3374 raise TypeError( 3375 f"Invalid outcome for level-{level} challenge:" 3376 f" should be True, False, or None, but got:" 3377 f" {repr(succeeded)}" 3378 ) 3379 else: 3380 if not skipWarning: 3381 warnings.warn( 3382 ( 3383 f"A level-{level} challenge in the" 3384 f" consequence being observed has no" 3385 f" observed outcome; no effects from" 3386 f" either success or failure branches" 3387 f" will be included. Use" 3388 f" observeChallengeOutcomes to fill in" 3389 f" unobserved outcomes." 3390 ), 3391 UnassignedOutcomeWarning 3392 ) 3393 3394 if useCh is not None: 3395 skipped = 0 3396 if useCh == 'failure': 3397 skipped = countParts(item['success']) 3398 subEffects = observedEffects( 3399 context, 3400 item[useCh], 3401 skipWarning=skipWarning, 3402 baseIndex=totalCount + skipped + 1 3403 ) 3404 result.extend(subEffects) 3405 3406 # TODO: Go back to returning tuples but fix counts to include 3407 # skipped stuff; this is horribly inefficient :( 3408 totalCount += countParts(item) 3409 3410 elif 'value' in item: # an effect, not a challenge 3411 item = cast(Effect, item) 3412 result.append(totalCount) 3413 totalCount += 1 3414 3415 elif 'condition' in item: # a Condition 3416 item = cast(Condition, item) 3417 useCo: Literal['consequence', 'alternative'] 3418 if item['condition'].satisfied(context): 3419 useCo = 'consequence' 3420 skipped = 0 3421 else: 3422 useCo = 'alternative' 3423 skipped = countParts(item['consequence']) 3424 subEffects = observedEffects( 3425 context, 3426 item[useCo], 3427 skipWarning=skipWarning, 3428 baseIndex=totalCount + skipped + 1 3429 ) 3430 result.extend(subEffects) 3431 totalCount += countParts(item) 3432 3433 else: # bad dict 3434 raise TypeError( 3435 f"Invalid consequence: items in the list must be" 3436 f" Effects, Challenges, or Conditions (got a dictionary" 3437 f" without 'skills', 'value', or 'condition' keys)." 3438 f"\nGot item: {repr(item)}" 3439 ) 3440 3441 return result
Given a Situation
and a Consequence
whose challenges have
outcomes assigned, returns a tuple containing a list of the
depth-first-indices of each effect to apply. You can use
consequencePart
to extract the actual Effect
values from the
consequence based on their indices.
Only effects that actually apply are included, based on the observed
outcomes as well as which Condition
(s) are met, although charges
and delays for the effects are not taken into account.
baseIndex
can be set to something other than 0 to start indexing
at that value. Issues an UnassignedOutcomeWarning
if it encounters
a challenge whose outcome has not been observed, unless
skipWarning
is set to True
. In that case, no effects are listed
for outcomes of that challenge.
For example:
>>> from . import core
>>> warnings.filterwarnings('error')
>>> e = core.emptySituation()
>>> def skilled(**skills):
... 'Create a clone of our FocalContext with specific skills.'
... r = copy.deepcopy(e)
... r.state['common']['capabilities']['skills'].update(skills)
... return r
>>> c = challenge( # index 1 in [c] (index 0 is the outer list)
... skills=BestSkill('charisma'),
... level=8,
... success=[
... effect(gain='happy'), # index 3 in [c]
... challenge(
... skills=BestSkill('strength'),
... success=[effect(gain='winner')] # index 6 in [c]
... # failure is index 7
... ) # level defaults to 0
... ],
... failure=[
... challenge(
... skills=BestSkill('strength'),
... # success is index 10
... failure=[effect(gain='loser')] # index 12 in [c]
... ),
... effect(gain='sad') # index 13 in [c]
... ]
... )
>>> import pytest
>>> with pytest.warns(UnassignedOutcomeWarning):
... observedEffects(e, [c])
[]
>>> with pytest.warns(UnassignedOutcomeWarning):
... observedEffects(e, [c, c])
[]
>>> observedEffects(e, [c, c], skipWarning=True)
[]
>>> c['outcome'] = 'invalid value' # must be True, False, or None
>>> observedEffects(e, [c])
Traceback (most recent call last):
...
TypeError...
>>> yesYes = skilled(charisma=10, strength=5)
>>> yesNo = skilled(charisma=10, strength=-1)
>>> noYes = skilled(charisma=4, strength=5)
>>> noNo = skilled(charisma=4, strength=-1)
>>> resetChallengeOutcomes([c])
>>> observedEffects(
... yesYes,
... observeChallengeOutcomes(yesYes, [c], policy='mostLikely')
... )
[3, 6]
>>> resetChallengeOutcomes([c])
>>> observedEffects(
... yesNo,
... observeChallengeOutcomes(yesNo, [c], policy='mostLikely')
... )
[3]
>>> resetChallengeOutcomes([c])
>>> observedEffects(
... noYes,
... observeChallengeOutcomes(noYes, [c], policy='mostLikely')
... )
[13]
>>> resetChallengeOutcomes([c])
>>> observedEffects(
... noNo,
... observeChallengeOutcomes(noNo, [c], policy='mostLikely')
... )
[12, 13]
>>> warnings.filterwarnings('default')
>>> # known outcomes override policy & pre-specified outcomes
>>> observedEffects(
... noNo,
... observeChallengeOutcomes(
... noNo,
... [c],
... policy='mostLikely',
... knownOutcomes=[True, True])
... )
[3, 6]
>>> observedEffects(
... yesYes,
... observeChallengeOutcomes(
... yesYes,
... [c],
... policy='mostLikely',
... knownOutcomes=[False, False])
... )
[12, 13]
>>> resetChallengeOutcomes([c])
>>> observedEffects(
... yesYes,
... observeChallengeOutcomes(
... yesYes,
... [c],
... policy='mostLikely',
... knownOutcomes=[False, False])
... )
[12, 13]
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.
3456class Requirement: 3457 """ 3458 Represents a precondition for traversing an edge or taking an action. 3459 This can be any boolean expression over `Capability`, mechanism (see 3460 `MechanismName`), and/or `Token` states that must obtain, with 3461 numerical values for the number of tokens required, and specific 3462 mechanism states or active capabilities necessary. For example, if 3463 the player needs either the wall-break capability or the wall-jump 3464 capability plus a balloon token, or for the switch mechanism to be 3465 on, you could represent that using: 3466 3467 ReqAny( 3468 ReqCapability('wall-break'), 3469 ReqAll( 3470 ReqCapability('wall-jump'), 3471 ReqTokens('balloon', 1) 3472 ), 3473 ReqMechanism('switch', 'on') 3474 ) 3475 3476 The subclasses define concrete requirements. 3477 3478 Note that mechanism names are searched for using `lookupMechanism`, 3479 starting from the `DecisionID`s of the decisions on either end of 3480 the transition where a requirement is being checked. You may need to 3481 rename mechanisms to avoid a `MechanismCollisionError`if decisions 3482 on either end of a transition use the same mechanism name. 3483 """ 3484 def satisfied( 3485 self, 3486 context: RequirementContext, 3487 dontRecurse: Optional[ 3488 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3489 ] = None 3490 ) -> bool: 3491 """ 3492 This will return `True` if the requirement is satisfied in the 3493 given `RequirementContext`, resolving mechanisms from the 3494 context's set of decisions and graph, and respecting the 3495 context's equivalences. It returns `False` otherwise. 3496 3497 The `dontRecurse` set should be unspecified to start, and will 3498 be used to avoid infinite recursion in cases of circular 3499 equivalences (requirements are not considered satisfied by 3500 equivalence loops). 3501 3502 TODO: Examples 3503 """ 3504 raise NotImplementedError( 3505 "Requirement is an abstract class and cannot be" 3506 " used directly." 3507 ) 3508 3509 def __eq__(self, other: Any) -> bool: 3510 raise NotImplementedError( 3511 "Requirement is an abstract class and cannot be compared." 3512 ) 3513 3514 def __hash__(self) -> int: 3515 raise NotImplementedError( 3516 "Requirement is an abstract class and cannot be hashed." 3517 ) 3518 3519 def walk(self) -> Generator['Requirement', None, None]: 3520 """ 3521 Yields every part of the requirement in depth-first traversal 3522 order. 3523 """ 3524 raise NotImplementedError( 3525 "Requirement is an abstract class and cannot be walked." 3526 ) 3527 3528 def asEffectList(self) -> List[Effect]: 3529 """ 3530 Transforms this `Requirement` into a list of `Effect` 3531 objects that gain the `Capability`, set the `Token` amounts, and 3532 set the `Mechanism` states mentioned by the requirement. The 3533 requirement must be either a `ReqTokens`, a `ReqCapability`, a 3534 `ReqMechanism`, or a `ReqAll` which includes nothing besides 3535 those types as sub-requirements. The token and capability 3536 requirements at the leaves of the tree will be collected into a 3537 list for the result (note that whether `ReqAny` or `ReqAll` is 3538 used is ignored, all of the tokens/capabilities/mechanisms 3539 mentioned are listed). For each `Capability` requirement a 3540 'gain' effect for that capability will be included. For each 3541 `Mechanism` or `Token` requirement, a 'set' effect for that 3542 mechanism state or token count will be included. Note that if 3543 the requirement has contradictory clauses (e.g., two different 3544 mechanism states) multiple effects which cancel each other out 3545 will be included. Also note that setting token amounts may end 3546 up decreasing them unnecessarily. 3547 3548 Raises a `TypeError` if this requirement is not suitable for 3549 transformation into an effect list. 3550 """ 3551 raise NotImplementedError("Requirement is an abstract class.") 3552 3553 def flatten(self) -> 'Requirement': 3554 """ 3555 Returns a simplified version of this requirement that merges 3556 multiple redundant layers of `ReqAny`/`ReqAll` into single 3557 `ReqAny`/`ReqAll` structures, including recursively. May return 3558 the original requirement if there's no simplification to be done. 3559 3560 Default implementation just returns `self`. 3561 """ 3562 return self 3563 3564 def unparse(self) -> str: 3565 """ 3566 Returns a string which would convert back into this `Requirement` 3567 object if you fed it to `parsing.ParseFormat.parseRequirement`. 3568 3569 TODO: Move this over into `parsing`? 3570 3571 Examples: 3572 3573 >>> r = ReqAny([ 3574 ... ReqCapability('capability'), 3575 ... ReqTokens('token', 3), 3576 ... ReqMechanism('mechanism', 'state') 3577 ... ]) 3578 >>> rep = r.unparse() 3579 >>> rep 3580 '(capability|token*3|mechanism:state)' 3581 >>> from . import parsing 3582 >>> pf = parsing.ParseFormat() 3583 >>> back = pf.parseRequirement(rep) 3584 >>> back == r 3585 True 3586 >>> ReqNot(ReqNothing()).unparse() 3587 '!(O)' 3588 >>> ReqImpossible().unparse() 3589 'X' 3590 >>> r = ReqAny([ReqCapability('A'), ReqCapability('B'), 3591 ... ReqCapability('C')]) 3592 >>> rep = r.unparse() 3593 >>> rep 3594 '(A|B|C)' 3595 >>> back = pf.parseRequirement(rep) 3596 >>> back == r 3597 True 3598 """ 3599 raise NotImplementedError("Requirement is an abstract class.")
Represents a precondition for traversing an edge or taking an action.
This can be any boolean expression over Capability
, mechanism (see
MechanismName
), and/or Token
states that must obtain, with
numerical values for the number of tokens required, and specific
mechanism states or active capabilities necessary. For example, if
the player needs either the wall-break capability or the wall-jump
capability plus a balloon token, or for the switch mechanism to be
on, you could represent that using:
ReqAny(
ReqCapability('wall-break'),
ReqAll(
ReqCapability('wall-jump'),
ReqTokens('balloon', 1)
),
ReqMechanism('switch', 'on')
)
The subclasses define concrete requirements.
Note that mechanism names are searched for using lookupMechanism
,
starting from the 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.
3484 def satisfied( 3485 self, 3486 context: RequirementContext, 3487 dontRecurse: Optional[ 3488 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3489 ] = None 3490 ) -> bool: 3491 """ 3492 This will return `True` if the requirement is satisfied in the 3493 given `RequirementContext`, resolving mechanisms from the 3494 context's set of decisions and graph, and respecting the 3495 context's equivalences. It returns `False` otherwise. 3496 3497 The `dontRecurse` set should be unspecified to start, and will 3498 be used to avoid infinite recursion in cases of circular 3499 equivalences (requirements are not considered satisfied by 3500 equivalence loops). 3501 3502 TODO: Examples 3503 """ 3504 raise NotImplementedError( 3505 "Requirement is an abstract class and cannot be" 3506 " used directly." 3507 )
This will return True
if the requirement is satisfied in the
given RequirementContext
, resolving mechanisms from the
context's set of decisions and graph, and respecting the
context's equivalences. It returns False
otherwise.
The dontRecurse
set should be unspecified to start, and will
be used to avoid infinite recursion in cases of circular
equivalences (requirements are not considered satisfied by
equivalence loops).
TODO: Examples
3519 def walk(self) -> Generator['Requirement', None, None]: 3520 """ 3521 Yields every part of the requirement in depth-first traversal 3522 order. 3523 """ 3524 raise NotImplementedError( 3525 "Requirement is an abstract class and cannot be walked." 3526 )
Yields every part of the requirement in depth-first traversal order.
3528 def asEffectList(self) -> List[Effect]: 3529 """ 3530 Transforms this `Requirement` into a list of `Effect` 3531 objects that gain the `Capability`, set the `Token` amounts, and 3532 set the `Mechanism` states mentioned by the requirement. The 3533 requirement must be either a `ReqTokens`, a `ReqCapability`, a 3534 `ReqMechanism`, or a `ReqAll` which includes nothing besides 3535 those types as sub-requirements. The token and capability 3536 requirements at the leaves of the tree will be collected into a 3537 list for the result (note that whether `ReqAny` or `ReqAll` is 3538 used is ignored, all of the tokens/capabilities/mechanisms 3539 mentioned are listed). For each `Capability` requirement a 3540 'gain' effect for that capability will be included. For each 3541 `Mechanism` or `Token` requirement, a 'set' effect for that 3542 mechanism state or token count will be included. Note that if 3543 the requirement has contradictory clauses (e.g., two different 3544 mechanism states) multiple effects which cancel each other out 3545 will be included. Also note that setting token amounts may end 3546 up decreasing them unnecessarily. 3547 3548 Raises a `TypeError` if this requirement is not suitable for 3549 transformation into an effect list. 3550 """ 3551 raise NotImplementedError("Requirement is an abstract class.")
Transforms this Requirement
into a list of Effect
objects that gain the Capability
, set the Token
amounts, and
set the Mechanism
states mentioned by the requirement. The
requirement must be either a ReqTokens
, a ReqCapability
, a
ReqMechanism
, or a ReqAll
which includes nothing besides
those types as sub-requirements. The token and capability
requirements at the leaves of the tree will be collected into a
list for the result (note that whether ReqAny
or ReqAll
is
used is ignored, all of the tokens/capabilities/mechanisms
mentioned are listed). For each Capability
requirement a
'gain' effect for that capability will be included. For each
Mechanism
or Token
requirement, a 'set' effect for that
mechanism state or token count will be included. Note that if
the requirement has contradictory clauses (e.g., two different
mechanism states) multiple effects which cancel each other out
will be included. Also note that setting token amounts may end
up decreasing them unnecessarily.
Raises a TypeError
if this requirement is not suitable for
transformation into an effect list.
3553 def flatten(self) -> 'Requirement': 3554 """ 3555 Returns a simplified version of this requirement that merges 3556 multiple redundant layers of `ReqAny`/`ReqAll` into single 3557 `ReqAny`/`ReqAll` structures, including recursively. May return 3558 the original requirement if there's no simplification to be done. 3559 3560 Default implementation just returns `self`. 3561 """ 3562 return self
3564 def unparse(self) -> str: 3565 """ 3566 Returns a string which would convert back into this `Requirement` 3567 object if you fed it to `parsing.ParseFormat.parseRequirement`. 3568 3569 TODO: Move this over into `parsing`? 3570 3571 Examples: 3572 3573 >>> r = ReqAny([ 3574 ... ReqCapability('capability'), 3575 ... ReqTokens('token', 3), 3576 ... ReqMechanism('mechanism', 'state') 3577 ... ]) 3578 >>> rep = r.unparse() 3579 >>> rep 3580 '(capability|token*3|mechanism:state)' 3581 >>> from . import parsing 3582 >>> pf = parsing.ParseFormat() 3583 >>> back = pf.parseRequirement(rep) 3584 >>> back == r 3585 True 3586 >>> ReqNot(ReqNothing()).unparse() 3587 '!(O)' 3588 >>> ReqImpossible().unparse() 3589 'X' 3590 >>> r = ReqAny([ReqCapability('A'), ReqCapability('B'), 3591 ... ReqCapability('C')]) 3592 >>> rep = r.unparse() 3593 >>> rep 3594 '(A|B|C)' 3595 >>> back = pf.parseRequirement(rep) 3596 >>> back == r 3597 True 3598 """ 3599 raise NotImplementedError("Requirement is an abstract class.")
Returns a string which would convert back into this Requirement
object if you fed it to parsing.ParseFormat.parseRequirement
.
TODO: Move this over into parsing
?
Examples:
>>> r = ReqAny([
... ReqCapability('capability'),
... ReqTokens('token', 3),
... ReqMechanism('mechanism', 'state')
... ])
>>> rep = r.unparse()
>>> rep
'(capability|token*3|mechanism:state)'
>>> from . import parsing
>>> pf = parsing.ParseFormat()
>>> back = pf.parseRequirement(rep)
>>> back == r
True
>>> ReqNot(ReqNothing()).unparse()
'!(O)'
>>> ReqImpossible().unparse()
'X'
>>> r = ReqAny([ReqCapability('A'), ReqCapability('B'),
... ReqCapability('C')])
>>> rep = r.unparse()
>>> rep
'(A|B|C)'
>>> back = pf.parseRequirement(rep)
>>> back == r
True
3602class ReqAny(Requirement): 3603 """ 3604 A disjunction requirement satisfied when any one of its 3605 sub-requirements is satisfied. 3606 """ 3607 def __init__(self, subs: Iterable[Requirement]) -> None: 3608 self.subs = list(subs) 3609 3610 def __hash__(self) -> int: 3611 result = 179843 3612 for sub in self.subs: 3613 result = 31 * (result + hash(sub)) 3614 return result 3615 3616 def __eq__(self, other: Any) -> bool: 3617 return isinstance(other, ReqAny) and other.subs == self.subs 3618 3619 def __repr__(self): 3620 return "ReqAny(" + repr(self.subs) + ")" 3621 3622 def satisfied( 3623 self, 3624 context: RequirementContext, 3625 dontRecurse: Optional[ 3626 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3627 ] = None 3628 ) -> bool: 3629 """ 3630 True as long as any one of the sub-requirements is satisfied. 3631 """ 3632 return any( 3633 sub.satisfied(context, dontRecurse) 3634 for sub in self.subs 3635 ) 3636 3637 def walk(self) -> Generator[Requirement, None, None]: 3638 yield self 3639 for sub in self.subs: 3640 yield from sub.walk() 3641 3642 def asEffectList(self) -> List[Effect]: 3643 """ 3644 Raises a `TypeError` since disjunctions don't have a translation 3645 into a simple list of effects to satisfy them. 3646 """ 3647 raise TypeError( 3648 "Cannot convert ReqAny into an effect list:" 3649 " contradictory token or mechanism requirements on" 3650 " different branches are not easy to synthesize." 3651 ) 3652 3653 def flatten(self) -> Requirement: 3654 """ 3655 Flattens this requirement by merging any sub-requirements which 3656 are also `ReqAny` instances into this one. 3657 """ 3658 merged = [] 3659 for sub in self.subs: 3660 flat = sub.flatten() 3661 if isinstance(flat, ReqAny): 3662 merged.extend(flat.subs) 3663 else: 3664 merged.append(flat) 3665 3666 return ReqAny(merged) 3667 3668 def unparse(self) -> str: 3669 return '(' + '|'.join(sub.unparse() for sub in self.subs) + ')'
A disjunction requirement satisfied when any one of its sub-requirements is satisfied.
3622 def satisfied( 3623 self, 3624 context: RequirementContext, 3625 dontRecurse: Optional[ 3626 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3627 ] = None 3628 ) -> bool: 3629 """ 3630 True as long as any one of the sub-requirements is satisfied. 3631 """ 3632 return any( 3633 sub.satisfied(context, dontRecurse) 3634 for sub in self.subs 3635 )
True as long as any one of the sub-requirements is satisfied.
3637 def walk(self) -> Generator[Requirement, None, None]: 3638 yield self 3639 for sub in self.subs: 3640 yield from sub.walk()
Yields every part of the requirement in depth-first traversal order.
3642 def asEffectList(self) -> List[Effect]: 3643 """ 3644 Raises a `TypeError` since disjunctions don't have a translation 3645 into a simple list of effects to satisfy them. 3646 """ 3647 raise TypeError( 3648 "Cannot convert ReqAny into an effect list:" 3649 " contradictory token or mechanism requirements on" 3650 " different branches are not easy to synthesize." 3651 )
Raises a TypeError
since disjunctions don't have a translation
into a simple list of effects to satisfy them.
3653 def flatten(self) -> Requirement: 3654 """ 3655 Flattens this requirement by merging any sub-requirements which 3656 are also `ReqAny` instances into this one. 3657 """ 3658 merged = [] 3659 for sub in self.subs: 3660 flat = sub.flatten() 3661 if isinstance(flat, ReqAny): 3662 merged.extend(flat.subs) 3663 else: 3664 merged.append(flat) 3665 3666 return ReqAny(merged)
Flattens this requirement by merging any sub-requirements which
are also ReqAny
instances into this one.
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
3672class ReqAll(Requirement): 3673 """ 3674 A conjunction requirement satisfied when all of its sub-requirements 3675 are satisfied. 3676 """ 3677 def __init__(self, subs: Iterable[Requirement]) -> None: 3678 self.subs = list(subs) 3679 3680 def __hash__(self) -> int: 3681 result = 182971 3682 for sub in self.subs: 3683 result = 17 * (result + hash(sub)) 3684 return result 3685 3686 def __eq__(self, other: Any) -> bool: 3687 return isinstance(other, ReqAll) and other.subs == self.subs 3688 3689 def __repr__(self): 3690 return "ReqAll(" + repr(self.subs) + ")" 3691 3692 def satisfied( 3693 self, 3694 context: RequirementContext, 3695 dontRecurse: Optional[ 3696 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3697 ] = None 3698 ) -> bool: 3699 """ 3700 True as long as all of the sub-requirements are satisfied. 3701 """ 3702 return all( 3703 sub.satisfied(context, dontRecurse) 3704 for sub in self.subs 3705 ) 3706 3707 def walk(self) -> Generator[Requirement, None, None]: 3708 yield self 3709 for sub in self.subs: 3710 yield from sub.walk() 3711 3712 def asEffectList(self) -> List[Effect]: 3713 """ 3714 Returns a gain list composed by adding together the gain lists 3715 for each sub-requirement. Note that some types of requirement 3716 will raise a `TypeError` during this process if they appear as a 3717 sub-requirement. 3718 """ 3719 result = [] 3720 for sub in self.subs: 3721 result += sub.asEffectList() 3722 3723 return result 3724 3725 def flatten(self) -> Requirement: 3726 """ 3727 Flattens this requirement by merging any sub-requirements which 3728 are also `ReqAll` instances into this one. 3729 """ 3730 merged = [] 3731 for sub in self.subs: 3732 flat = sub.flatten() 3733 if isinstance(flat, ReqAll): 3734 merged.extend(flat.subs) 3735 else: 3736 merged.append(flat) 3737 3738 return ReqAll(merged) 3739 3740 def unparse(self) -> str: 3741 return '(' + '&'.join(sub.unparse() for sub in self.subs) + ')'
A conjunction requirement satisfied when all of its sub-requirements are satisfied.
3692 def satisfied( 3693 self, 3694 context: RequirementContext, 3695 dontRecurse: Optional[ 3696 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3697 ] = None 3698 ) -> bool: 3699 """ 3700 True as long as all of the sub-requirements are satisfied. 3701 """ 3702 return all( 3703 sub.satisfied(context, dontRecurse) 3704 for sub in self.subs 3705 )
True as long as all of the sub-requirements are satisfied.
3707 def walk(self) -> Generator[Requirement, None, None]: 3708 yield self 3709 for sub in self.subs: 3710 yield from sub.walk()
Yields every part of the requirement in depth-first traversal order.
3712 def asEffectList(self) -> List[Effect]: 3713 """ 3714 Returns a gain list composed by adding together the gain lists 3715 for each sub-requirement. Note that some types of requirement 3716 will raise a `TypeError` during this process if they appear as a 3717 sub-requirement. 3718 """ 3719 result = [] 3720 for sub in self.subs: 3721 result += sub.asEffectList() 3722 3723 return result
Returns a gain list composed by adding together the gain lists
for each sub-requirement. Note that some types of requirement
will raise a TypeError
during this process if they appear as a
sub-requirement.
3725 def flatten(self) -> Requirement: 3726 """ 3727 Flattens this requirement by merging any sub-requirements which 3728 are also `ReqAll` instances into this one. 3729 """ 3730 merged = [] 3731 for sub in self.subs: 3732 flat = sub.flatten() 3733 if isinstance(flat, ReqAll): 3734 merged.extend(flat.subs) 3735 else: 3736 merged.append(flat) 3737 3738 return ReqAll(merged)
Flattens this requirement by merging any sub-requirements which
are also ReqAll
instances into this one.
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
3744class ReqNot(Requirement): 3745 """ 3746 A negation requirement satisfied when its sub-requirement is NOT 3747 satisfied. 3748 """ 3749 def __init__(self, sub: Requirement) -> None: 3750 self.sub = sub 3751 3752 def __hash__(self) -> int: 3753 return 17293 + hash(self.sub) 3754 3755 def __eq__(self, other: Any) -> bool: 3756 return isinstance(other, ReqNot) and other.sub == self.sub 3757 3758 def __repr__(self): 3759 return "ReqNot(" + repr(self.sub) + ")" 3760 3761 def satisfied( 3762 self, 3763 context: RequirementContext, 3764 dontRecurse: Optional[ 3765 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3766 ] = None 3767 ) -> bool: 3768 """ 3769 True as long as the sub-requirement is not satisfied. 3770 """ 3771 return not self.sub.satisfied(context, dontRecurse) 3772 3773 def walk(self) -> Generator[Requirement, None, None]: 3774 yield self 3775 yield self.sub 3776 3777 def asEffectList(self) -> List[Effect]: 3778 """ 3779 Raises a `TypeError` since understanding a `ReqNot` in terms of 3780 capabilities/tokens to be gained is not straightforward, and would 3781 need to be done relative to a game state in any case. 3782 """ 3783 raise TypeError( 3784 "Cannot convert ReqNot into an effect list:" 3785 " capabilities or tokens would have to be lost, not gained to" 3786 " satisfy this requirement." 3787 ) 3788 3789 def flatten(self) -> Requirement: 3790 return ReqNot(self.sub.flatten()) 3791 3792 def unparse(self) -> str: 3793 return '!(' + self.sub.unparse() + ')'
A negation requirement satisfied when its sub-requirement is NOT satisfied.
3761 def satisfied( 3762 self, 3763 context: RequirementContext, 3764 dontRecurse: Optional[ 3765 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3766 ] = None 3767 ) -> bool: 3768 """ 3769 True as long as the sub-requirement is not satisfied. 3770 """ 3771 return not self.sub.satisfied(context, dontRecurse)
True as long as the sub-requirement is not satisfied.
3777 def asEffectList(self) -> List[Effect]: 3778 """ 3779 Raises a `TypeError` since understanding a `ReqNot` in terms of 3780 capabilities/tokens to be gained is not straightforward, and would 3781 need to be done relative to a game state in any case. 3782 """ 3783 raise TypeError( 3784 "Cannot convert ReqNot into an effect list:" 3785 " capabilities or tokens would have to be lost, not gained to" 3786 " satisfy this requirement." 3787 )
Raises a TypeError
since understanding a ReqNot
in terms of
capabilities/tokens to be gained is not straightforward, and would
need to be done relative to a game state in any case.
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
3796class ReqCapability(Requirement): 3797 """ 3798 A capability requirement is satisfied if the specified capability is 3799 possessed by the player according to the given state. 3800 """ 3801 def __init__(self, capability: Capability) -> None: 3802 self.capability = capability 3803 3804 def __hash__(self) -> int: 3805 return 47923 + hash(self.capability) 3806 3807 def __eq__(self, other: Any) -> bool: 3808 return ( 3809 isinstance(other, ReqCapability) 3810 and other.capability == self.capability 3811 ) 3812 3813 def __repr__(self): 3814 return "ReqCapability(" + repr(self.capability) + ")" 3815 3816 def satisfied( 3817 self, 3818 context: RequirementContext, 3819 dontRecurse: Optional[ 3820 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3821 ] = None 3822 ) -> bool: 3823 return hasCapabilityOrEquivalent( 3824 self.capability, 3825 context, 3826 dontRecurse 3827 ) 3828 3829 def walk(self) -> Generator[Requirement, None, None]: 3830 yield self 3831 3832 def asEffectList(self) -> List[Effect]: 3833 """ 3834 Returns a list containing a single 'gain' effect which grants 3835 the required capability. 3836 """ 3837 return [effect(gain=self.capability)] 3838 3839 def unparse(self) -> str: 3840 return self.capability
A capability requirement is satisfied if the specified capability is possessed by the player according to the given state.
3816 def satisfied( 3817 self, 3818 context: RequirementContext, 3819 dontRecurse: Optional[ 3820 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3821 ] = None 3822 ) -> bool: 3823 return hasCapabilityOrEquivalent( 3824 self.capability, 3825 context, 3826 dontRecurse 3827 )
This will return True
if the requirement is satisfied in the
given RequirementContext
, resolving mechanisms from the
context's set of decisions and graph, and respecting the
context's equivalences. It returns False
otherwise.
The dontRecurse
set should be unspecified to start, and will
be used to avoid infinite recursion in cases of circular
equivalences (requirements are not considered satisfied by
equivalence loops).
TODO: Examples
3832 def asEffectList(self) -> List[Effect]: 3833 """ 3834 Returns a list containing a single 'gain' effect which grants 3835 the required capability. 3836 """ 3837 return [effect(gain=self.capability)]
Returns a list containing a single 'gain' effect which grants the required capability.
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
3843class ReqTokens(Requirement): 3844 """ 3845 A token requirement satisfied if the player possesses at least a 3846 certain number of a given type of token. 3847 3848 Note that checking the satisfaction of individual doors in a specific 3849 state is not enough to guarantee they're jointly traversable, since 3850 if a series of doors requires the same kind of token and they use up 3851 those tokens, further logic is needed to understand that as the 3852 tokens get used up, their requirements may no longer be satisfied. 3853 3854 Also note that a requirement for tokens does NOT mean that tokens 3855 will be subtracted when traversing the door (you can have re-usable 3856 tokens after all). To implement a token cost, use both a requirement 3857 and a 'lose' effect. 3858 """ 3859 def __init__(self, tokenType: Token, cost: TokenCount) -> None: 3860 self.tokenType = tokenType 3861 self.cost = cost 3862 3863 def __hash__(self) -> int: 3864 return (17 * hash(self.tokenType)) + (11 * self.cost) 3865 3866 def __eq__(self, other: Any) -> bool: 3867 return ( 3868 isinstance(other, ReqTokens) 3869 and other.tokenType == self.tokenType 3870 and other.cost == self.cost 3871 ) 3872 3873 def __repr__(self): 3874 return f"ReqTokens({repr(self.tokenType)}, {repr(self.cost)})" 3875 3876 def satisfied( 3877 self, 3878 context: RequirementContext, 3879 dontRecurse: Optional[ 3880 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3881 ] = None 3882 ) -> bool: 3883 return combinedTokenCount(context.state, self.tokenType) >= self.cost 3884 3885 def walk(self) -> Generator[Requirement, None, None]: 3886 yield self 3887 3888 def asEffectList(self) -> List[Effect]: 3889 """ 3890 Returns a list containing a single 'set' effect which sets the 3891 required tokens (note that this may unnecessarily subtract 3892 tokens if the state had more than enough tokens beforehand). 3893 """ 3894 return [effect(set=(self.tokenType, self.cost))] 3895 3896 def unparse(self) -> str: 3897 return f'{self.tokenType}*{self.cost}'
A token requirement satisfied if the player possesses at least a certain number of a given type of token.
Note that checking the satisfaction of individual doors in a specific state is not enough to guarantee they're jointly traversable, since if a series of doors requires the same kind of token and they use up those tokens, further logic is needed to understand that as the tokens get used up, their requirements may no longer be satisfied.
Also note that a requirement for tokens does NOT mean that tokens will be subtracted when traversing the door (you can have re-usable tokens after all). To implement a token cost, use both a requirement and a 'lose' effect.
3876 def satisfied( 3877 self, 3878 context: RequirementContext, 3879 dontRecurse: Optional[ 3880 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3881 ] = None 3882 ) -> bool: 3883 return combinedTokenCount(context.state, self.tokenType) >= self.cost
This will return True
if the requirement is satisfied in the
given RequirementContext
, resolving mechanisms from the
context's set of decisions and graph, and respecting the
context's equivalences. It returns False
otherwise.
The dontRecurse
set should be unspecified to start, and will
be used to avoid infinite recursion in cases of circular
equivalences (requirements are not considered satisfied by
equivalence loops).
TODO: Examples
3888 def asEffectList(self) -> List[Effect]: 3889 """ 3890 Returns a list containing a single 'set' effect which sets the 3891 required tokens (note that this may unnecessarily subtract 3892 tokens if the state had more than enough tokens beforehand). 3893 """ 3894 return [effect(set=(self.tokenType, self.cost))]
Returns a list containing a single 'set' effect which sets the required tokens (note that this may unnecessarily subtract tokens if the state had more than enough tokens beforehand).
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
3900class ReqMechanism(Requirement): 3901 """ 3902 A mechanism requirement satisfied if the specified mechanism is in 3903 the specified state. The mechanism is specified by name and a lookup 3904 on that name will be performed when assessing the requirement, based 3905 on the specific position at which the requirement applies. However, 3906 if a `where` value is supplied, the lookup on the mechanism name will 3907 always start from that decision, regardless of where the requirement 3908 is being evaluated. 3909 """ 3910 def __init__( 3911 self, 3912 mechanism: AnyMechanismSpecifier, 3913 state: MechanismState, 3914 ) -> None: 3915 self.mechanism = mechanism 3916 self.reqState = state 3917 3918 # Normalize mechanism specifiers without any position information 3919 if isinstance(mechanism, tuple): 3920 if len(mechanism) != 4: 3921 raise ValueError( 3922 f"Mechanism specifier must have 4 parts if it's a" 3923 f" tuple. (Got: {mechanism})." 3924 ) 3925 elif all(x is None for x in mechanism[:3]): 3926 self.mechanism = mechanism[3] 3927 3928 def __hash__(self) -> int: 3929 return ( 3930 (11 * hash(self.mechanism)) 3931 + (31 * hash(self.reqState)) 3932 ) 3933 3934 def __eq__(self, other: Any) -> bool: 3935 return ( 3936 isinstance(other, ReqMechanism) 3937 and other.mechanism == self.mechanism 3938 and other.reqState == self.reqState 3939 ) 3940 3941 def __repr__(self): 3942 mRep = repr(self.mechanism) 3943 sRep = repr(self.reqState) 3944 return f"ReqMechanism({mRep}, {sRep})" 3945 3946 def satisfied( 3947 self, 3948 context: RequirementContext, 3949 dontRecurse: Optional[ 3950 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3951 ] = None 3952 ) -> bool: 3953 return mechanismInStateOrEquivalent( 3954 self.mechanism, 3955 self.reqState, 3956 context, 3957 dontRecurse 3958 ) 3959 3960 def walk(self) -> Generator[Requirement, None, None]: 3961 yield self 3962 3963 def asEffectList(self) -> List[Effect]: 3964 """ 3965 Returns a list containing a single 'set' effect which sets the 3966 required mechanism to the required state. 3967 """ 3968 return [effect(set=(self.mechanism, self.reqState))] 3969 3970 def unparse(self) -> str: 3971 if isinstance(self.mechanism, (MechanismID, MechanismName)): 3972 return f'{self.mechanism}:{self.reqState}' 3973 else: # Must be a MechanismSpecifier 3974 # TODO: This elsewhere! 3975 domain, zone, decision, mechanism = self.mechanism 3976 mspec = '' 3977 if domain is not None: 3978 mspec += domain + '//' 3979 if zone is not None: 3980 mspec += zone + '::' 3981 if decision is not None: 3982 mspec += decision + '::' 3983 mspec += mechanism 3984 return f'{mspec}:{self.reqState}'
A mechanism requirement satisfied if the specified mechanism is in
the specified state. The mechanism is specified by name and a lookup
on that name will be performed when assessing the requirement, based
on the specific position at which the requirement applies. However,
if a where
value is supplied, the lookup on the mechanism name will
always start from that decision, regardless of where the requirement
is being evaluated.
3910 def __init__( 3911 self, 3912 mechanism: AnyMechanismSpecifier, 3913 state: MechanismState, 3914 ) -> None: 3915 self.mechanism = mechanism 3916 self.reqState = state 3917 3918 # Normalize mechanism specifiers without any position information 3919 if isinstance(mechanism, tuple): 3920 if len(mechanism) != 4: 3921 raise ValueError( 3922 f"Mechanism specifier must have 4 parts if it's a" 3923 f" tuple. (Got: {mechanism})." 3924 ) 3925 elif all(x is None for x in mechanism[:3]): 3926 self.mechanism = mechanism[3]
3946 def satisfied( 3947 self, 3948 context: RequirementContext, 3949 dontRecurse: Optional[ 3950 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 3951 ] = None 3952 ) -> bool: 3953 return mechanismInStateOrEquivalent( 3954 self.mechanism, 3955 self.reqState, 3956 context, 3957 dontRecurse 3958 )
This will return True
if the requirement is satisfied in the
given RequirementContext
, resolving mechanisms from the
context's set of decisions and graph, and respecting the
context's equivalences. It returns False
otherwise.
The dontRecurse
set should be unspecified to start, and will
be used to avoid infinite recursion in cases of circular
equivalences (requirements are not considered satisfied by
equivalence loops).
TODO: Examples
3963 def asEffectList(self) -> List[Effect]: 3964 """ 3965 Returns a list containing a single 'set' effect which sets the 3966 required mechanism to the required state. 3967 """ 3968 return [effect(set=(self.mechanism, self.reqState))]
Returns a list containing a single 'set' effect which sets the required mechanism to the required state.
3970 def unparse(self) -> str: 3971 if isinstance(self.mechanism, (MechanismID, MechanismName)): 3972 return f'{self.mechanism}:{self.reqState}' 3973 else: # Must be a MechanismSpecifier 3974 # TODO: This elsewhere! 3975 domain, zone, decision, mechanism = self.mechanism 3976 mspec = '' 3977 if domain is not None: 3978 mspec += domain + '//' 3979 if zone is not None: 3980 mspec += zone + '::' 3981 if decision is not None: 3982 mspec += decision + '::' 3983 mspec += mechanism 3984 return f'{mspec}:{self.reqState}'
Returns a string which would convert back into this Requirement
object if you fed it to parsing.ParseFormat.parseRequirement
.
TODO: Move this over into parsing
?
Examples:
>>> r = ReqAny([
... ReqCapability('capability'),
... ReqTokens('token', 3),
... ReqMechanism('mechanism', 'state')
... ])
>>> rep = r.unparse()
>>> rep
'(capability|token*3|mechanism:state)'
>>> from . import parsing
>>> pf = parsing.ParseFormat()
>>> back = pf.parseRequirement(rep)
>>> back == r
True
>>> ReqNot(ReqNothing()).unparse()
'!(O)'
>>> ReqImpossible().unparse()
'X'
>>> r = ReqAny([ReqCapability('A'), ReqCapability('B'),
... ReqCapability('C')])
>>> rep = r.unparse()
>>> rep
'(A|B|C)'
>>> back = pf.parseRequirement(rep)
>>> back == r
True
Inherited Members
3987class ReqLevel(Requirement): 3988 """ 3989 A tag requirement satisfied if a specific skill is at or above the 3990 specified level. 3991 """ 3992 def __init__( 3993 self, 3994 skill: Skill, 3995 minLevel: Level, 3996 ) -> None: 3997 self.skill = skill 3998 self.minLevel = minLevel 3999 4000 def __hash__(self) -> int: 4001 return ( 4002 (79 * hash(self.skill)) 4003 + (55 * hash(self.minLevel)) 4004 ) 4005 4006 def __eq__(self, other: Any) -> bool: 4007 return ( 4008 isinstance(other, ReqLevel) 4009 and other.skill == self.skill 4010 and other.minLevel == self.minLevel 4011 ) 4012 4013 def __repr__(self): 4014 sRep = repr(self.skill) 4015 lRep = repr(self.minLevel) 4016 return f"ReqLevel({sRep}, {lRep})" 4017 4018 def satisfied( 4019 self, 4020 context: RequirementContext, 4021 dontRecurse: Optional[ 4022 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 4023 ] = None 4024 ) -> bool: 4025 return getSkillLevel(context.state, self.skill) >= self.minLevel 4026 4027 def walk(self) -> Generator[Requirement, None, None]: 4028 yield self 4029 4030 def asEffectList(self) -> List[Effect]: 4031 """ 4032 Returns a list containing a single 'set' effect which sets the 4033 required skill to the minimum required level. Note that this may 4034 reduce a skill level that was more than sufficient to meet the 4035 requirement. 4036 """ 4037 return [effect(set=("skill", self.skill, self.minLevel))] 4038 4039 def unparse(self) -> str: 4040 return f'{self.skill}^{self.minLevel}'
A tag requirement satisfied if a specific skill is at or above the specified level.
4018 def satisfied( 4019 self, 4020 context: RequirementContext, 4021 dontRecurse: Optional[ 4022 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 4023 ] = None 4024 ) -> bool: 4025 return getSkillLevel(context.state, self.skill) >= self.minLevel
This will return True
if the requirement is satisfied in the
given RequirementContext
, resolving mechanisms from the
context's set of decisions and graph, and respecting the
context's equivalences. It returns False
otherwise.
The dontRecurse
set should be unspecified to start, and will
be used to avoid infinite recursion in cases of circular
equivalences (requirements are not considered satisfied by
equivalence loops).
TODO: Examples
4030 def asEffectList(self) -> List[Effect]: 4031 """ 4032 Returns a list containing a single 'set' effect which sets the 4033 required skill to the minimum required level. Note that this may 4034 reduce a skill level that was more than sufficient to meet the 4035 requirement. 4036 """ 4037 return [effect(set=("skill", self.skill, self.minLevel))]
Returns a list containing a single 'set' effect which sets the required skill to the minimum required level. Note that this may reduce a skill level that was more than sufficient to meet the requirement.
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
4043class ReqTag(Requirement): 4044 """ 4045 A tag requirement satisfied if there is any active decision that has 4046 the specified value for the given tag (default value is 1 for tags 4047 where a value wasn't specified). Zone tags also satisfy the 4048 requirement if they're applied to zones that include active 4049 decisions. 4050 """ 4051 def __init__( 4052 self, 4053 tag: "Tag", 4054 value: "TagValue", 4055 ) -> None: 4056 self.tag = tag 4057 self.value = value 4058 4059 def __hash__(self) -> int: 4060 return ( 4061 (71 * hash(self.tag)) 4062 + (43 * hash(self.value)) 4063 ) 4064 4065 def __eq__(self, other: Any) -> bool: 4066 return ( 4067 isinstance(other, ReqTag) 4068 and other.tag == self.tag 4069 and other.value == self.value 4070 ) 4071 4072 def __repr__(self): 4073 tRep = repr(self.tag) 4074 vRep = repr(self.value) 4075 return f"ReqTag({tRep}, {vRep})" 4076 4077 def satisfied( 4078 self, 4079 context: RequirementContext, 4080 dontRecurse: Optional[ 4081 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 4082 ] = None 4083 ) -> bool: 4084 active = combinedDecisionSet(context.state) 4085 graph = context.graph 4086 zones = set() 4087 for decision in active: 4088 tags = graph.decisionTags(decision) 4089 if self.tag in tags and tags[self.tag] == self.value: 4090 return True 4091 zones |= graph.zoneAncestors(decision) 4092 for zone in zones: 4093 zTags = graph.zoneTags(zone) 4094 if self.tag in zTags and zTags[self.tag] == self.value: 4095 return True 4096 4097 return False 4098 4099 def walk(self) -> Generator[Requirement, None, None]: 4100 yield self 4101 4102 def asEffectList(self) -> List[Effect]: 4103 """ 4104 Returns a list containing a single 'set' effect which sets the 4105 required mechanism to the required state. 4106 """ 4107 raise TypeError( 4108 "Cannot convert ReqTag into an effect list:" 4109 " effects cannot apply/remove/change tags" 4110 ) 4111 4112 def unparse(self) -> str: 4113 return f'{self.tag}~{self.value!r}'
A tag requirement satisfied if there is any active decision that has the specified value for the given tag (default value is 1 for tags where a value wasn't specified). Zone tags also satisfy the requirement if they're applied to zones that include active decisions.
4077 def satisfied( 4078 self, 4079 context: RequirementContext, 4080 dontRecurse: Optional[ 4081 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 4082 ] = None 4083 ) -> bool: 4084 active = combinedDecisionSet(context.state) 4085 graph = context.graph 4086 zones = set() 4087 for decision in active: 4088 tags = graph.decisionTags(decision) 4089 if self.tag in tags and tags[self.tag] == self.value: 4090 return True 4091 zones |= graph.zoneAncestors(decision) 4092 for zone in zones: 4093 zTags = graph.zoneTags(zone) 4094 if self.tag in zTags and zTags[self.tag] == self.value: 4095 return True 4096 4097 return False
This will return True
if the requirement is satisfied in the
given RequirementContext
, resolving mechanisms from the
context's set of decisions and graph, and respecting the
context's equivalences. It returns False
otherwise.
The dontRecurse
set should be unspecified to start, and will
be used to avoid infinite recursion in cases of circular
equivalences (requirements are not considered satisfied by
equivalence loops).
TODO: Examples
4102 def asEffectList(self) -> List[Effect]: 4103 """ 4104 Returns a list containing a single 'set' effect which sets the 4105 required mechanism to the required state. 4106 """ 4107 raise TypeError( 4108 "Cannot convert ReqTag into an effect list:" 4109 " effects cannot apply/remove/change tags" 4110 )
Returns a list containing a single 'set' effect which sets the required mechanism to the required state.
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
4116class ReqNothing(Requirement): 4117 """ 4118 A requirement representing that something doesn't actually have a 4119 requirement. This requirement is always satisfied. 4120 """ 4121 def __hash__(self) -> int: 4122 return 127942 4123 4124 def __eq__(self, other: Any) -> bool: 4125 return isinstance(other, ReqNothing) 4126 4127 def __repr__(self): 4128 return "ReqNothing()" 4129 4130 def satisfied( 4131 self, 4132 context: RequirementContext, 4133 dontRecurse: Optional[ 4134 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 4135 ] = None 4136 ) -> bool: 4137 return True 4138 4139 def walk(self) -> Generator[Requirement, None, None]: 4140 yield self 4141 4142 def asEffectList(self) -> List[Effect]: 4143 """ 4144 Returns an empty list, since nothing is required. 4145 """ 4146 return [] 4147 4148 def unparse(self) -> str: 4149 return 'O'
A requirement representing that something doesn't actually have a requirement. This requirement is always satisfied.
4130 def satisfied( 4131 self, 4132 context: RequirementContext, 4133 dontRecurse: Optional[ 4134 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 4135 ] = None 4136 ) -> bool: 4137 return True
This will return True
if the requirement is satisfied in the
given RequirementContext
, resolving mechanisms from the
context's set of decisions and graph, and respecting the
context's equivalences. It returns False
otherwise.
The dontRecurse
set should be unspecified to start, and will
be used to avoid infinite recursion in cases of circular
equivalences (requirements are not considered satisfied by
equivalence loops).
TODO: Examples
4142 def asEffectList(self) -> List[Effect]: 4143 """ 4144 Returns an empty list, since nothing is required. 4145 """ 4146 return []
Returns an empty list, since nothing is required.
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
4152class ReqImpossible(Requirement): 4153 """ 4154 A requirement representing that something is impossible. This 4155 requirement is never satisfied. 4156 """ 4157 def __hash__(self) -> int: 4158 return 478743 4159 4160 def __eq__(self, other: Any) -> bool: 4161 return isinstance(other, ReqImpossible) 4162 4163 def __repr__(self): 4164 return "ReqImpossible()" 4165 4166 def satisfied( 4167 self, 4168 context: RequirementContext, 4169 dontRecurse: Optional[ 4170 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 4171 ] = None 4172 ) -> bool: 4173 return False 4174 4175 def walk(self) -> Generator[Requirement, None, None]: 4176 yield self 4177 4178 def asEffectList(self) -> List[Effect]: 4179 """ 4180 Raises a `TypeError` since a `ReqImpossible` cannot be converted 4181 into an effect which would allow the transition to be taken. 4182 """ 4183 raise TypeError( 4184 "Cannot convert ReqImpossible into an effect list:" 4185 " there are no powers or tokens which could be gained to" 4186 " satisfy this requirement." 4187 ) 4188 4189 def unparse(self) -> str: 4190 return 'X'
A requirement representing that something is impossible. This requirement is never satisfied.
4166 def satisfied( 4167 self, 4168 context: RequirementContext, 4169 dontRecurse: Optional[ 4170 Set[Union[Capability, Tuple[MechanismID, MechanismState]]] 4171 ] = None 4172 ) -> bool: 4173 return False
This will return True
if the requirement is satisfied in the
given RequirementContext
, resolving mechanisms from the
context's set of decisions and graph, and respecting the
context's equivalences. It returns False
otherwise.
The dontRecurse
set should be unspecified to start, and will
be used to avoid infinite recursion in cases of circular
equivalences (requirements are not considered satisfied by
equivalence loops).
TODO: Examples
4178 def asEffectList(self) -> List[Effect]: 4179 """ 4180 Raises a `TypeError` since a `ReqImpossible` cannot be converted 4181 into an effect which would allow the transition to be taken. 4182 """ 4183 raise TypeError( 4184 "Cannot convert ReqImpossible into an effect list:" 4185 " there are no powers or tokens which could be gained to" 4186 " satisfy this requirement." 4187 )
Raises a TypeError
since a ReqImpossible
cannot be converted
into an effect which would allow the transition to be taken.
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...
4302class NoTagValue: 4303 """ 4304 Class used to indicate no tag value for things that return tag values 4305 since `None` is a valid tag value. 4306 """ 4307 pass
Class used to indicate no tag value for things that return tag values
since None
is a valid tag value.
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.
4329class ZoneInfo(NamedTuple): 4330 """ 4331 Zone info holds a level integer (starting from 0 as the level directly 4332 above decisions), a set of parent zones, a set of child decisions 4333 and/or zones, and zone tags and annotations. Zones at a particular 4334 level may only contain zones in lower levels, although zones at any 4335 level may also contain decisions directly. The norm is for zones at 4336 level 0 to contain decisions, while zones at higher levels contain 4337 zones from the level directly below them. 4338 4339 Note that zones may have multiple parents, because one sub-zone may be 4340 contained within multiple super-zones. 4341 """ 4342 level: int 4343 parents: Set[Zone] 4344 contents: Set[Union[DecisionID, Zone]] 4345 tags: Dict[Tag, TagValue] 4346 annotations: List[Annotation]
Zone info holds a level integer (starting from 0 as the level directly above decisions), a set of parent zones, a set of child decisions and/or zones, and zone tags and annotations. Zones at a particular level may only contain zones in lower levels, although zones at any level may also contain decisions directly. The norm is for zones at level 0 to contain decisions, while zones at higher levels contain zones from the level directly below them.
Note that zones may have multiple parents, because one sub-zone may be contained within multiple super-zones.
Create new instance of ZoneInfo(level, parents, contents, tags, annotations)
Inherited Members
- builtins.tuple
- index
- count
4349class DefaultZone: 4350 """ 4351 Default argument for a `Zone` when `None` is used to mean "Do not add 4352 to the zone you normally would." 4353 """ 4354 pass
Default argument for a Zone
when None
is used to mean "Do not add
to the zone you normally would."
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, type[DefaultZone], None] = action[-1] 4642 if newZone in (None, DefaultZone): 4643 deDesc = f"{destID} ({nowWord}{newName})" 4644 else: 4645 deDesc = f"{destID} ({nowWord}{newZone}::{newName})" 4646 # TODO: Don't hardcode '::' here? 4647 4648 oDesc = "" 4649 if len(specified) > 0: 4650 oDesc = " with outcomes: " 4651 first = True 4652 for o in specified: 4653 if first: 4654 first = False 4655 else: 4656 oDesc += ", " 4657 if o: 4658 oDesc += "success" 4659 else: 4660 oDesc += "failure" 4661 4662 return ( 4663 f"explore {transitionName} from decision {frDesc} to" 4664 f" {deDesc}{oDesc}" 4665 ) 4666 4667 elif aType == 'take': 4668 if len(action) == 4: 4669 assert action[1] in get_args(ContextSpecifier) 4670 assert isinstance(action[2], DecisionID) 4671 assert isinstance(action[3], tuple) 4672 assert len(action[3]) == 2 4673 assert isinstance(action[3][0], Transition) 4674 assert isinstance(action[3][1], list) 4675 context = action[1] 4676 fromID = action[2] 4677 transitionName, specified = action[3] 4678 destID = graph.getDestination(fromID, transitionName) 4679 oDesc = "" 4680 if len(specified) > 0: 4681 oDesc = " with outcomes: " 4682 first = True 4683 for o in specified: 4684 if first: 4685 first = False 4686 else: 4687 oDesc += ", " 4688 if o: 4689 oDesc += "success" 4690 else: 4691 oDesc += "failure" 4692 if fromID == destID: # an action 4693 return f"do action {transitionName}" 4694 else: # normal transition 4695 frDesc = graph.identityOf(fromID) 4696 deDesc = graph.identityOf(destID) 4697 4698 return ( 4699 f"take {transitionName} from decision {frDesc} to" 4700 f" {deDesc}{oDesc}" 4701 ) 4702 elif len(action) == 3: 4703 assert isinstance(action[1], tuple) 4704 assert len(action[1]) == 3 4705 assert isinstance(action[2], tuple) 4706 assert len(action[2]) == 2 4707 assert isinstance(action[2][0], Transition) 4708 assert isinstance(action[2][1], list) 4709 _, focalPoint, transition = action 4710 context, domain, name = focalPoint 4711 frID = resolvePosition(situation, focalPoint) 4712 4713 transitionName, specified = action[2] 4714 oDesc = "" 4715 if len(specified) > 0: 4716 oDesc = " with outcomes: " 4717 first = True 4718 for o in specified: 4719 if first: 4720 first = False 4721 else: 4722 oDesc += ", " 4723 if o: 4724 oDesc += "success" 4725 else: 4726 oDesc += "failure" 4727 4728 if frID is None: 4729 return ( 4730 f"invalid action (moves {focalPoint} which doesn't" 4731 f" exist)" 4732 ) 4733 else: 4734 destID = graph.getDestination(frID, transitionName) 4735 4736 if frID == destID: 4737 return "do action {transition}{oDesc}" 4738 else: 4739 frDesc = graph.identityOf(frID) 4740 deDesc = graph.identityOf(destID) 4741 return ( 4742 f"{name} takes {transition} from {frDesc} to" 4743 f" {deDesc}{oDesc}" 4744 ) 4745 else: 4746 raise ValueError( 4747 f"Wrong number of parts for 'take' action: {action!r}" 4748 ) 4749 4750 elif aType == 'warp': 4751 if len(action) != 3: 4752 raise ValueError( 4753 f"Wrong number of parts for 'warp' action: {action!r}" 4754 ) 4755 if action[1] in get_args(ContextSpecifier): 4756 assert isinstance(action[1], str) 4757 assert isinstance(action[2], DecisionID) 4758 _, context, destination = action 4759 deDesc = graph.identityOf(destination) 4760 return f"warp to {deDesc!r}" 4761 elif isinstance(action[1], tuple) and len(action[1]) == 3: 4762 assert isinstance(action[2], DecisionID) 4763 _, focalPoint, destination = action 4764 context, domain, name = focalPoint 4765 deDesc = graph.identityOf(destination) 4766 frID = resolvePosition(situation, focalPoint) 4767 frDesc = graph.identityOf(frID) 4768 return f"{name} warps to {deDesc!r}" 4769 else: 4770 raise TypeError( 4771 f"Invalid second part for 'warp' action: {action!r}" 4772 ) 4773 4774 elif aType == 'focus': 4775 if len(action) != 4: 4776 raise ValueError( 4777 "Wrong number of parts for 'focus' action: {action!r}" 4778 ) 4779 _, context, deactivate, activate = action 4780 assert isinstance(deactivate, set) 4781 assert isinstance(activate, set) 4782 result = "change in active domains: " 4783 clauses = [] 4784 if len(deactivate) > 0: 4785 clauses.append("deactivate domain(s) {', '.join(deactivate)}") 4786 if len(activate) > 0: 4787 clauses.append("activate domain(s) {', '.join(activate)}") 4788 result += '; '.join(clauses) 4789 return result 4790 4791 elif aType == 'swap': 4792 if len(action) != 2: 4793 raise ValueError( 4794 "Wrong number of parts for 'swap' action: {action!r}" 4795 ) 4796 _, fcName = action 4797 return f"swap to focal context {fcName!r}" 4798 4799 elif aType == 'focalize': 4800 if len(action) != 2: 4801 raise ValueError( 4802 "Wrong number of parts for 'focalize' action: {action!r}" 4803 ) 4804 _, fcName = action 4805 return f"create new focal context {fcName!r}" 4806 4807 else: 4808 raise RuntimeError( 4809 "Missing case for exploration action type: {action[0]!r}" 4810 )
Returns a string description of the action represented by an
ExplorationAction
object (or the string '(no action)' for the value
None
). Uses the provided situation to look up things like decision
names, focal point positions, and destinations where relevant. Does
not know details of which graph it is applied to or the outcomes of
the action, so just describes what is being attempted.
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
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.
5765class FeatureSpecifier(NamedTuple): 5766 """ 5767 There are several ways to specify a feature within a `FeatureGraph`: 5768 Simplest is to just include the `FeatureID` directly (in that case 5769 the domain must be `None` and the 'within' sequence must be empty). 5770 A specific domain and/or a sequence of containing features (starting 5771 from most-external to most-internal) may also be specified when a 5772 string is used as the feature itself, to help disambiguate (when an 5773 ambiguous `FeatureSpecifier` is used, 5774 `AmbiguousFeatureSpecifierError` may arise in some cases). For any 5775 feature, a part may also be specified indicating which part of the 5776 feature is being referred to; this can be `None` when not referring 5777 to any specific sub-part. 5778 """ 5779 domain: Optional[Domain] 5780 within: Sequence[Feature] 5781 feature: Union[Feature, FeatureID] 5782 part: Optional[Part]
There are several ways to specify a feature within a FeatureGraph
:
Simplest is to just include the FeatureID
directly (in that case
the domain must be None
and the 'within' sequence must be empty).
A specific domain and/or a sequence of containing features (starting
from most-external to most-internal) may also be specified when a
string is used as the feature itself, to help disambiguate (when an
ambiguous FeatureSpecifier
is used,
AmbiguousFeatureSpecifierError
may arise in some cases). For any
feature, a part may also be specified indicating which part of the
feature is being referred to; this can be None
when not referring
to any specific sub-part.
Create new instance of FeatureSpecifier(domain, within, feature, part)
Inherited Members
- builtins.tuple
- index
- count
5785def feature( 5786 name: Feature, 5787 part: Optional[Part] = None, 5788 domain: Optional[Domain] = None, 5789 within: Optional[Sequence[Feature]] = None 5790) -> FeatureSpecifier: 5791 """ 5792 Builds a `FeatureSpecifier` with some defaults. The default domain 5793 is `None`, and by default the feature has an empty 'within' field and 5794 its part field is `None`. 5795 """ 5796 if within is None: 5797 within = [] 5798 return FeatureSpecifier( 5799 domain=domain, 5800 within=within, 5801 feature=name, 5802 part=part 5803 )
Builds a FeatureSpecifier
with some defaults. The default domain
is None
, and by default the feature has an empty 'within' field and
its part field is None
.
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
.
5819def normalizeFeatureSpecifier(spec: AnyFeatureSpecifier) -> FeatureSpecifier: 5820 """ 5821 Turns an `AnyFeatureSpecifier` into a `FeatureSpecifier`. Note that 5822 it does not do parsing from a complex string. Use 5823 `parsing.ParseFormat.parseFeatureSpecifier` for that. 5824 5825 It will turn a feature specifier with an int-convertible feature name 5826 into a feature-ID-based specifier, discarding any domain and/or zone 5827 parts. 5828 5829 TODO: Issue a warning if parts are discarded? 5830 """ 5831 if isinstance(spec, (FeatureID, Feature)): 5832 return FeatureSpecifier( 5833 domain=None, 5834 within=[], 5835 feature=spec, 5836 part=None 5837 ) 5838 elif isinstance(spec, FeatureSpecifier): 5839 try: 5840 fID = int(spec.feature) 5841 return FeatureSpecifier(None, [], fID, spec.part) 5842 except ValueError: 5843 return spec 5844 else: 5845 raise TypeError( 5846 f"Invalid feature specifier type: {type(spec)}" 5847 )
Turns an AnyFeatureSpecifier
into a FeatureSpecifier
. Note that
it does not do parsing from a complex string. Use
parsing.ParseFormat.parseFeatureSpecifier
for that.
It will turn a feature specifier with an int-convertible feature name into a feature-ID-based specifier, discarding any domain and/or zone parts.
TODO: Issue a warning if parts are discarded?
5850class MetricSpace: 5851 """ 5852 TODO 5853 Represents a variable-dimensional coordinate system within which 5854 locations can be identified by coordinates. May (or may not) include 5855 a reference to one or more images which are visual representation(s) 5856 of the space. 5857 """ 5858 def __init__(self, name: str): 5859 self.name = name 5860 5861 self.points: Dict[PointID, Coords] = {} 5862 # Holds all IDs and their corresponding coordinates as key/value 5863 # pairs 5864 5865 self.nextID: PointID = 0 5866 # ID numbers should not be repeated or reused 5867 5868 def addPoint(self, coords: Coords) -> PointID: 5869 """ 5870 Given a sequence (list/array/etc) of int coordinates, creates a 5871 point and adds it to the metric space object 5872 5873 >>> ms = MetricSpace("test") 5874 >>> ms.addPoint([2, 3]) 5875 0 5876 >>> #expected result 5877 >>> ms.addPoint([2, 7, 0]) 5878 1 5879 """ 5880 thisID = self.nextID 5881 5882 self.nextID += 1 5883 5884 self.points[thisID] = coords # creates key value pair 5885 5886 return thisID 5887 5888 # How do we "add" things to the metric space? What data structure 5889 # is it? dictionary 5890 5891 def removePoint(self, thisID: PointID) -> None: 5892 """ 5893 Given the ID of a point/coord, checks the dictionary 5894 (points) for that key and removes the key/value pair from 5895 it. 5896 5897 >>> ms = MetricSpace("test") 5898 >>> ms.addPoint([2, 3]) 5899 0 5900 >>> ms.removePoint(0) 5901 >>> ms.removePoint(0) 5902 Traceback (most recent call last): 5903 ... 5904 KeyError... 5905 >>> #expected result should be a caught KeyNotFound exception 5906 """ 5907 self.points.pop(thisID) 5908 5909 def distance(self, origin: AnyPoint, dest: AnyPoint) -> float: 5910 """ 5911 Given an orgin point and destination point, returns the 5912 distance between the two points as a float. 5913 5914 >>> ms = MetricSpace("test") 5915 >>> ms.addPoint([4, 0]) 5916 0 5917 >>> ms.addPoint([1, 0]) 5918 1 5919 >>> ms.distance(0, 1) 5920 3.0 5921 >>> p1 = ms.addPoint([4, 3]) 5922 >>> p2 = ms.addPoint([4, 9]) 5923 >>> ms.distance(p1, p2) 5924 6.0 5925 >>> ms.distance([8, 6], [4, 6]) 5926 4.0 5927 >>> ms.distance([1, 1], [1, 1]) 5928 0.0 5929 >>> ms.distance([-2, -3], [-5, -7]) 5930 5.0 5931 >>> ms.distance([2.5, 3.7], [4.9, 6.1]) 5932 3.394112549695428 5933 """ 5934 if isinstance(origin, PointID): 5935 coord1 = self.points[origin] 5936 else: 5937 coord1 = origin 5938 5939 if isinstance(dest, PointID): 5940 coord2 = self.points[dest] 5941 else: 5942 coord2 = dest 5943 5944 inside = 0.0 5945 5946 for dim in range(max(len(coord1), len(coord2))): 5947 if dim < len(coord1): 5948 val1 = coord1[dim] 5949 else: 5950 val1 = 0 5951 if dim < len(coord2): 5952 val2 = coord2[dim] 5953 else: 5954 val2 = 0 5955 5956 inside += (val2 - val1)**2 5957 5958 result = math.sqrt(inside) 5959 return result 5960 5961 def NDCoords( 5962 self, 5963 point: AnyPoint, 5964 numDimension: int 5965 ) -> Coords: 5966 """ 5967 Given a 2D set of coordinates (x, y), converts them to the desired 5968 dimension 5969 5970 >>> ms = MetricSpace("test") 5971 >>> ms.NDCoords([5, 9], 3) 5972 [5, 9, 0] 5973 >>> ms.NDCoords([3, 1], 1) 5974 [3] 5975 """ 5976 if isinstance(point, PointID): 5977 coords = self.points[point] 5978 else: 5979 coords = point 5980 5981 seqLength = len(coords) 5982 5983 if seqLength != numDimension: 5984 5985 newCoords: Coords 5986 5987 if seqLength < numDimension: 5988 5989 newCoords = [item for item in coords] 5990 5991 for i in range(numDimension - seqLength): 5992 newCoords.append(0) 5993 5994 else: 5995 newCoords = coords[:numDimension] 5996 5997 return newCoords 5998 5999 def lastID(self) -> PointID: 6000 """ 6001 Returns the most updated ID of the metricSpace instance. The nextID 6002 field is always 1 more than the last assigned ID. Assumes that there 6003 has at least been one ID assigned to a point as a key value pair 6004 in the dictionary. Returns 0 if that is not the case. Does not 6005 consider possible counting errors if a point has been removed from 6006 the dictionary. The last ID does not neccessarily equal the number 6007 of points in the metricSpace (or in the dictionary). 6008 6009 >>> ms = MetricSpace("test") 6010 >>> ms.lastID() 6011 0 6012 >>> ms.addPoint([2, 3]) 6013 0 6014 >>> ms.addPoint([2, 7, 0]) 6015 1 6016 >>> ms.addPoint([2, 7]) 6017 2 6018 >>> ms.lastID() 6019 2 6020 >>> ms.removePoint(2) 6021 >>> ms.lastID() 6022 2 6023 """ 6024 if self.nextID < 1: 6025 return self.nextID 6026 return self.nextID - 1
TODO Represents a variable-dimensional coordinate system within which locations can be identified by coordinates. May (or may not) include a reference to one or more images which are visual representation(s) of the space.
5868 def addPoint(self, coords: Coords) -> PointID: 5869 """ 5870 Given a sequence (list/array/etc) of int coordinates, creates a 5871 point and adds it to the metric space object 5872 5873 >>> ms = MetricSpace("test") 5874 >>> ms.addPoint([2, 3]) 5875 0 5876 >>> #expected result 5877 >>> ms.addPoint([2, 7, 0]) 5878 1 5879 """ 5880 thisID = self.nextID 5881 5882 self.nextID += 1 5883 5884 self.points[thisID] = coords # creates key value pair 5885 5886 return thisID 5887 5888 # How do we "add" things to the metric space? What data structure 5889 # is it? dictionary
Given a sequence (list/array/etc) of int coordinates, creates a point and adds it to the metric space object
>>> ms = MetricSpace("test")
>>> ms.addPoint([2, 3])
0
>>> #expected result
>>> ms.addPoint([2, 7, 0])
1
5891 def removePoint(self, thisID: PointID) -> None: 5892 """ 5893 Given the ID of a point/coord, checks the dictionary 5894 (points) for that key and removes the key/value pair from 5895 it. 5896 5897 >>> ms = MetricSpace("test") 5898 >>> ms.addPoint([2, 3]) 5899 0 5900 >>> ms.removePoint(0) 5901 >>> ms.removePoint(0) 5902 Traceback (most recent call last): 5903 ... 5904 KeyError... 5905 >>> #expected result should be a caught KeyNotFound exception 5906 """ 5907 self.points.pop(thisID)
Given the ID of a point/coord, checks the dictionary (points) for that key and removes the key/value pair from it.
>>> ms = MetricSpace("test")
>>> ms.addPoint([2, 3])
0
>>> ms.removePoint(0)
>>> ms.removePoint(0)
Traceback (most recent call last):
...
KeyError...
>>> #expected result should be a caught KeyNotFound exception
5909 def distance(self, origin: AnyPoint, dest: AnyPoint) -> float: 5910 """ 5911 Given an orgin point and destination point, returns the 5912 distance between the two points as a float. 5913 5914 >>> ms = MetricSpace("test") 5915 >>> ms.addPoint([4, 0]) 5916 0 5917 >>> ms.addPoint([1, 0]) 5918 1 5919 >>> ms.distance(0, 1) 5920 3.0 5921 >>> p1 = ms.addPoint([4, 3]) 5922 >>> p2 = ms.addPoint([4, 9]) 5923 >>> ms.distance(p1, p2) 5924 6.0 5925 >>> ms.distance([8, 6], [4, 6]) 5926 4.0 5927 >>> ms.distance([1, 1], [1, 1]) 5928 0.0 5929 >>> ms.distance([-2, -3], [-5, -7]) 5930 5.0 5931 >>> ms.distance([2.5, 3.7], [4.9, 6.1]) 5932 3.394112549695428 5933 """ 5934 if isinstance(origin, PointID): 5935 coord1 = self.points[origin] 5936 else: 5937 coord1 = origin 5938 5939 if isinstance(dest, PointID): 5940 coord2 = self.points[dest] 5941 else: 5942 coord2 = dest 5943 5944 inside = 0.0 5945 5946 for dim in range(max(len(coord1), len(coord2))): 5947 if dim < len(coord1): 5948 val1 = coord1[dim] 5949 else: 5950 val1 = 0 5951 if dim < len(coord2): 5952 val2 = coord2[dim] 5953 else: 5954 val2 = 0 5955 5956 inside += (val2 - val1)**2 5957 5958 result = math.sqrt(inside) 5959 return result
Given an orgin point and destination point, returns the distance between the two points as a float.
>>> ms = MetricSpace("test")
>>> ms.addPoint([4, 0])
0
>>> ms.addPoint([1, 0])
1
>>> ms.distance(0, 1)
3.0
>>> p1 = ms.addPoint([4, 3])
>>> p2 = ms.addPoint([4, 9])
>>> ms.distance(p1, p2)
6.0
>>> ms.distance([8, 6], [4, 6])
4.0
>>> ms.distance([1, 1], [1, 1])
0.0
>>> ms.distance([-2, -3], [-5, -7])
5.0
>>> ms.distance([2.5, 3.7], [4.9, 6.1])
3.394112549695428
5961 def NDCoords( 5962 self, 5963 point: AnyPoint, 5964 numDimension: int 5965 ) -> Coords: 5966 """ 5967 Given a 2D set of coordinates (x, y), converts them to the desired 5968 dimension 5969 5970 >>> ms = MetricSpace("test") 5971 >>> ms.NDCoords([5, 9], 3) 5972 [5, 9, 0] 5973 >>> ms.NDCoords([3, 1], 1) 5974 [3] 5975 """ 5976 if isinstance(point, PointID): 5977 coords = self.points[point] 5978 else: 5979 coords = point 5980 5981 seqLength = len(coords) 5982 5983 if seqLength != numDimension: 5984 5985 newCoords: Coords 5986 5987 if seqLength < numDimension: 5988 5989 newCoords = [item for item in coords] 5990 5991 for i in range(numDimension - seqLength): 5992 newCoords.append(0) 5993 5994 else: 5995 newCoords = coords[:numDimension] 5996 5997 return newCoords
Given a 2D set of coordinates (x, y), converts them to the desired dimension
>>> ms = MetricSpace("test")
>>> ms.NDCoords([5, 9], 3)
[5, 9, 0]
>>> ms.NDCoords([3, 1], 1)
[3]
5999 def lastID(self) -> PointID: 6000 """ 6001 Returns the most updated ID of the metricSpace instance. The nextID 6002 field is always 1 more than the last assigned ID. Assumes that there 6003 has at least been one ID assigned to a point as a key value pair 6004 in the dictionary. Returns 0 if that is not the case. Does not 6005 consider possible counting errors if a point has been removed from 6006 the dictionary. The last ID does not neccessarily equal the number 6007 of points in the metricSpace (or in the dictionary). 6008 6009 >>> ms = MetricSpace("test") 6010 >>> ms.lastID() 6011 0 6012 >>> ms.addPoint([2, 3]) 6013 0 6014 >>> ms.addPoint([2, 7, 0]) 6015 1 6016 >>> ms.addPoint([2, 7]) 6017 2 6018 >>> ms.lastID() 6019 2 6020 >>> ms.removePoint(2) 6021 >>> ms.lastID() 6022 2 6023 """ 6024 if self.nextID < 1: 6025 return self.nextID 6026 return self.nextID - 1
Returns the most updated ID of the metricSpace instance. The nextID field is always 1 more than the last assigned ID. Assumes that there has at least been one ID assigned to a point as a key value pair in the dictionary. Returns 0 if that is not the case. Does not consider possible counting errors if a point has been removed from the dictionary. The last ID does not neccessarily equal the number of points in the metricSpace (or in the dictionary).
>>> ms = MetricSpace("test")
>>> ms.lastID()
0
>>> ms.addPoint([2, 3])
0
>>> ms.addPoint([2, 7, 0])
1
>>> ms.addPoint([2, 7])
2
>>> ms.lastID()
2
>>> ms.removePoint(2)
>>> ms.lastID()
2
6029def featurePart(spec: AnyFeatureSpecifier, part: Part) -> FeatureSpecifier: 6030 """ 6031 Returns a new feature specifier (and/or normalizes to one) that 6032 contains the specified part in the 'part' slot. If the provided 6033 feature specifier already contains a 'part', that will be replaced. 6034 6035 For example: 6036 6037 >>> featurePart('town', 'north') 6038 FeatureSpecifier(domain=None, within=[], feature='town', part='north') 6039 >>> featurePart(5, 'top') 6040 FeatureSpecifier(domain=None, within=[], feature=5, part='top') 6041 >>> featurePart( 6042 ... FeatureSpecifier('dom', ['one', 'two'], 'three', 'middle'), 6043 ... 'top' 6044 ... ) 6045 FeatureSpecifier(domain='dom', within=['one', 'two'], feature='three',\ 6046 part='top') 6047 >>> featurePart(FeatureSpecifier(None, ['region'], 'place', None), 'top') 6048 FeatureSpecifier(domain=None, within=['region'], feature='place',\ 6049 part='top') 6050 """ 6051 spec = normalizeFeatureSpecifier(spec) 6052 return FeatureSpecifier(spec.domain, spec.within, spec.feature, part)
Returns a new feature specifier (and/or normalizes to one) that contains the specified part in the 'part' slot. If the provided feature specifier already contains a 'part', that will be replaced.
For example:
>>> featurePart('town', 'north')
FeatureSpecifier(domain=None, within=[], feature='town', part='north')
>>> featurePart(5, 'top')
FeatureSpecifier(domain=None, within=[], feature=5, part='top')
>>> featurePart(
... FeatureSpecifier('dom', ['one', 'two'], 'three', 'middle'),
... 'top'
... )
FeatureSpecifier(domain='dom', within=['one', 'two'], feature='three', part='top')
>>> featurePart(FeatureSpecifier(None, ['region'], 'place', None), 'top')
FeatureSpecifier(domain=None, within=['region'], feature='place', part='top')
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.
6213class FeatureDecision(TypedDict): 6214 """ 6215 Represents a decision made during exploration, including the 6216 position(s) at which the explorer made the decision, which 6217 feature(s) were most relevant to the decision and what course of 6218 action was decided upon (see `FeatureAction`). Has the following 6219 slots: 6220 6221 - 'type': The type of decision (see `exploration.core.DecisionType`). 6222 - 'domains': A set of domains which are active during the decision, 6223 as opposed to domains which may be unfocused or otherwise 6224 inactive. 6225 - 'focus': An optional single `FeatureSpecifier` which represents the 6226 focal character or object for a decision. May be `None` e.g. in 6227 cases where a menu is in focus. Note that the 'positions' slot 6228 determines which positions are relevant to the decision, 6229 potentially separately from the focus but usually overlapping it. 6230 - 'positions': A dictionary mapping `core.Domain`s to sets of 6231 `FeatureSpecifier`s representing the player's position(s) in 6232 each domain. Some domains may function like tech trees, where 6233 the set of positions only expands over time. Others may function 6234 like a single avatar in a virtual world, where there is only one 6235 position. Still others might function like a group of virtual 6236 avatars, with multiple positions that can be updated 6237 independently. 6238 - 'intention': A `FeatureAction` indicating the action taken or 6239 attempted next as a result of the decision. 6240 """ 6241 # TODO: HERE 6242 pass
Represents a decision made during exploration, including the
position(s) at which the explorer made the decision, which
feature(s) were most relevant to the decision and what course of
action was decided upon (see FeatureAction
). Has the following
slots:
- 'type': The type of decision (see
exploration.core.DecisionType
). - 'domains': A set of domains which are active during the decision, as opposed to domains which may be unfocused or otherwise inactive.
- 'focus': An optional single
FeatureSpecifier
which represents the focal character or object for a decision. May 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.
6322class FeatureEffect(TypedDict): 6323 """ 6324 Similar to `Effect` but with more options for how to manipulate the 6325 game state. This represents a single concrete change to either 6326 internal game state, or to the feature graph. Multiple changes 6327 (possibly with random factors involved) can be represented by a 6328 `Consequence`; a `FeatureEffect` is used as a leaf in a `Consequence` 6329 tree. 6330 """ 6331 type: Literal[ 6332 'gain', 6333 'lose', 6334 'toggle', 6335 'deactivate', 6336 'move', 6337 'focus', 6338 'initiate' 6339 'foreground', 6340 'background', 6341 ] 6342 value: Union[ 6343 Capability, 6344 Tuple[Token, int], 6345 List[Capability], 6346 None 6347 ] 6348 charges: Optional[int] 6349 delay: Optional[int]
Similar to Effect
but with more options for how to manipulate the
game state. This represents a single concrete change to either
internal game state, or to the feature graph. Multiple changes
(possibly with random factors involved) can be represented by a
Consequence
; a FeatureEffect
is used as a leaf in a Consequence
tree.
6352def featureEffect( 6353 #applyTo: ContextSpecifier = 'active', 6354 #gain: Optional[Union[ 6355 # Capability, 6356 # Tuple[Token, TokenCount], 6357 # Tuple[Literal['skill'], Skill, Level] 6358 #]] = None, 6359 #lose: Optional[Union[ 6360 # Capability, 6361 # Tuple[Token, TokenCount], 6362 # Tuple[Literal['skill'], Skill, Level] 6363 #]] = None, 6364 #set: Optional[Union[ 6365 # Tuple[Token, TokenCount], 6366 # Tuple[AnyMechanismSpecifier, MechanismState], 6367 # Tuple[Literal['skill'], Skill, Level] 6368 #]] = None, 6369 #toggle: Optional[Union[ 6370 # Tuple[AnyMechanismSpecifier, List[MechanismState]], 6371 # List[Capability] 6372 #]] = None, 6373 #deactivate: Optional[bool] = None, 6374 #edit: Optional[List[List[commands.Command]]] = None, 6375 #goto: Optional[Union[ 6376 # AnyDecisionSpecifier, 6377 # Tuple[AnyDecisionSpecifier, FocalPointName] 6378 #]] = None, 6379 #bounce: Optional[bool] = None, 6380 #delay: Optional[int] = None, 6381 #charges: Optional[int] = None, 6382 **kwargs 6383): 6384 # TODO: HERE 6385 return effect(**kwargs)
6390class FeatureAction(TypedDict): 6391 """ 6392 Indicates an action decided on by a `FeatureDecision`. Has the 6393 following slots: 6394 6395 - 'subject': the main feature (an `AnyFeatureSpecifier`) that 6396 performs the action (usually an 'entity'). 6397 - 'object': the main feature (an `AnyFeatureSpecifier`) with which 6398 the affordance is performed. 6399 - 'affordance': the specific `FeatureAffordance` indicating the type 6400 of action. 6401 - 'direction': The general direction of movement (especially when 6402 the affordance is `follow`). This can be either a direction in 6403 an associated `MetricSpace`, or it can be defined towards or 6404 away from the destination specified. If a destination but no 6405 direction is provided, the direction is assumed to be towards 6406 that destination. 6407 - 'part': The part within/along a feature for movement (e.g., which 6408 side of an edge are you on, or which part of a region are you 6409 traveling through). 6410 - 'destination': The destination of the action (when known ahead of 6411 time). For example, moving along a path towards a particular 6412 feature touching that path, or entering a node into a particular 6413 feature within that node. Note that entering of regions can be 6414 left implicit: if you enter a region to get to a landmark within 6415 it, noting that as approaching the landmark is more appropriate 6416 than noting that as entering the region with the landmark as the 6417 destination. The system can infer what regions you're in by 6418 which feature you're at. 6419 - 'outcome': A `Consequence` list/tree indicating one or more 6420 outcomes, possibly involving challenges. Note that the actual 6421 outcomes of an action may be altered by triggers; the outcomes 6422 listed here are the default outcomes if no triggers are tripped. 6423 6424 The 'direction', 'part', and/or 'destination' may each be None, 6425 depending on the type of affordance and/or amount of detail desired. 6426 """ 6427 subject: AnyFeatureSpecifier 6428 object: AnyFeatureSpecifier 6429 affordance: FeatureAffordance 6430 direction: Optional[Part] 6431 part: Optional[Part] 6432 destination: Optional[AnyFeatureSpecifier] 6433 outcome: Consequence
Indicates an action decided on by a FeatureDecision
. Has the
following slots:
- 'subject': the main feature (an
AnyFeatureSpecifier
) that performs the action (usually an 'entity'). - 'object': the main feature (an
AnyFeatureSpecifier
) with which the affordance is performed. - 'affordance': the specific
FeatureAffordance
indicating the type of action. - 'direction': The general direction of movement (especially when
the affordance is
follow
). This can be either a direction in an 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.