exploration.tests.test_journal

Authors: Peter Mawhorter Consulted: Date: 2022-9-30 Purpose: Tests for journal functionality.

   1"""
   2Authors: Peter Mawhorter
   3Consulted:
   4Date: 2022-9-30
   5Purpose: Tests for journal functionality.
   6"""
   7
   8import json
   9import warnings
  10
  11import pytest
  12
  13from .. import core, journal, base
  14
  15
  16def listingAtStep(expl: core.DiscreteExploration, step: int = -1) -> str:
  17    """
  18    Calls `namesListing` for all decisions in the graph at the specified
  19    step of the given exploration.
  20    """
  21    graph = expl.getSituation(step).graph
  22    return graph.namesListing(graph)
  23
  24
  25def test_simpleConversion() -> None:
  26    """
  27    A simple test of journal conversion.
  28    """
  29    simple = journal.convertJournal("""\
  30S First_Room
  31x Exit Second_Room Entrance
  32gt tag
  33
  34o Onwards
  35E END
  36""")
  37    assert len(simple) == 4
  38
  39    finalGraph = simple.getSituation().graph
  40
  41    assert len(finalGraph) == 4
  42    assert finalGraph.namesListing(finalGraph) == """\
  43  0 (First_Room)
  44  1 (Second_Room)
  45  2 (_u.1)
  46  3 (endings//END)
  47"""
  48
  49    assert finalGraph.destinationsFrom("First_Room") == {'Exit': 1}
  50    assert finalGraph.destinationsFrom("Second_Room") == {
  51        'Entrance': 0,
  52        'Onwards': 2
  53    }
  54    assert finalGraph.destinationsFrom("END") == {}
  55    assert json.dumps(
  56        finalGraph.textMapObj(),
  57        indent=4
  58    ) == """\
  59{
  60    "0::Exit": {
  61        "1::Entrance": "0",
  62        "1::Onwards": {
  63            "2::return": "1"
  64        }
  65    }
  66}"""
  67    # TODO: We'd like to include the ending here!
  68
  69# TODO: Specific case coverage tests...
  70
  71
  72def test_exploring_zones() -> None:
  73    """
  74    A test for exploration with zones.
  75    """
  76    expl = journal.convertJournal("""\
  77S zone::room  # decision 0
  78x ahead room2 back  # decision 1
  79o hatch basement ladder  # decision 2
  80x ahead zone2::room3 back  # decision 3
  81x ahead room4 back  # decision 4
  82t back
  83t back
  84x hatch
  85""")
  86    # TODO: Add test here for returning to unvisited decision not placed
  87    # in zone
  88    assert expl.getActiveDecisions() == {2}
  89    now = expl.getSituation()
  90    assert now.graph.getDecision('basement') == 2
  91    assert now.graph.getDecision(
  92        base.DecisionSpecifier(domain=None, zone='zone', name='basement')
  93    ) == 2
  94    assert now.graph.getDecision(
  95        base.DecisionSpecifier(domain=None, zone='zone2', name='basement')
  96    ) is None
  97    assert now.graph.getDecision(
  98        base.DecisionSpecifier(domain=None, zone='zone2', name='room3')
  99    ) == 3
 100    assert now.graph.namesListing(now.graph) == """\
 101  0 (zone::room)
 102  1 (zone::room2)
 103  2 (zone::basement)
 104  3 (zone2::room3)
 105  4 (zone2::room4)
 106"""
 107    assert now.graph.zoneParents(0) == {'zone'}
 108    assert now.graph.zoneParents(2) == {'zone'}
 109    assert now.graph.zoneParents(3) == {'zone2'}
 110    assert now.graph.zoneParents(4) == {'zone2'}
 111    assert now.graph.zoneParents(1) == {'zone'}
 112
 113
 114def test_zoneNaming() -> None:
 115    """
 116    Tests some situations with zone propagation in journals.
 117    """
 118    expl = journal.convertJournal("""\
 119S West::start
 120x right second left
 121  o down third up
 122x right East::fourth left
 123t left
 124x down  # third should be in zone 'West'
 125  o left New::fifth right
 126x left  # fifth should remain in zone 'New'
 127""")
 128    g1 = expl.getSituation(1).graph
 129    assert g1.namesListing(g1) == """\
 130  0 (West::start)
 131  1 (_u.0)
 132"""
 133
 134    g2 = expl.getSituation(2).graph
 135    assert g2.namesListing(g2) == """\
 136  0 (West::start)
 137  1 (West::second)
 138  2 (West::third)
 139  3 (_u.2)
 140"""
 141    assert g2.zoneParents(0) == {'West'}
 142    assert g2.zoneParents(1) == {'West'}
 143    assert g2.zoneParents(2) == {'West'}
 144    assert g2.zoneParents(3) == set()
 145
 146    g3 = expl.getSituation(3).graph
 147    assert g3.namesListing(g3) == """\
 148  0 (West::start)
 149  1 (West::second)
 150  2 (West::third)
 151  3 (East::fourth)
 152"""
 153    assert g3.zoneParents(0) == {'West'}
 154    assert g3.zoneParents(1) == {'West'}
 155    assert g3.zoneParents(2) == {'West'}
 156    assert g3.zoneParents(3) == {'East'}
 157
 158    g4 = expl.getSituation(4).graph
 159    assert g4.namesListing(g4) == """\
 160  0 (West::start)
 161  1 (West::second)
 162  2 (West::third)
 163  3 (East::fourth)
 164"""
 165    assert g4.zoneParents(0) == {'West'}
 166    assert g4.zoneParents(1) == {'West'}
 167    assert g3.zoneParents(2) == {'West'}
 168    assert g4.zoneParents(3) == {'East'}
 169
 170    g5 = expl.getSituation(5).graph
 171    assert g5.namesListing(g5) == """\
 172  0 (West::start)
 173  1 (West::second)
 174  2 (West::third)
 175  3 (East::fourth)
 176  4 (New::fifth)
 177"""
 178    assert g5.zoneParents(0) == {'West'}
 179    assert g5.zoneParents(1) == {'West'}
 180    assert g5.zoneParents(2) == {'West'}
 181    assert g5.zoneParents(3) == {'East'}
 182    assert g5.zoneParents(4) == {'New'}
 183
 184    g6 = expl.getSituation(6).graph
 185    assert g6.namesListing(g6) == """\
 186  0 (West::start)
 187  1 (West::second)
 188  2 (West::third)
 189  3 (East::fourth)
 190  4 (New::fifth)
 191"""
 192    assert g6.zoneParents(0) == {'West'}
 193    assert g6.zoneParents(1) == {'West'}
 194    assert g6.zoneParents(2) == {'West'}
 195    assert g6.zoneParents(3) == {'East'}
 196    assert g6.zoneParents(4) == {'New'}
 197
 198
 199def test_startOfCotM() -> None:
 200    """
 201    A test for converting part of a journal from Castlevania: Circle of
 202    the Moon. This journal covers just the first few rooms of the game.
 203    """
 204    # Note that we're expecting a `TransitionBlockedWarning` because the
 205    # requirement 'q distance' does not match the power gained 'At gain
 206    # dash_boots'...
 207    # TODO: Add an equivalence now that we support those!
 208    with pytest.warns(core.TransitionBlockedWarning):
 209        expl = journal.convertJournal("""\
 210# Setup
 211# -----
 212# A save-point alias that creates, explores and tags a new decision but
 213# then returns to where we just were.
 214= savePoint [
 215    x {_1} save{_2} return
 216    g save
 217    t return
 218]
 219= dagger [
 220    a candle{_}
 221    At gain dagger
 222    At lose axe
 223    At lose holy_water
 224    At lose cross
 225]
 226= axe [
 227    a candle{_}
 228    At lose dagger
 229    At gain axe
 230    At lose holy_water
 231    At lose cross
 232]
 233= holy_water [
 234    a candle{_}
 235    At lose dagger
 236    At lose axe
 237    At gain holy_water
 238    At lose cross
 239]
 240= cross [
 241    a candle{_}
 242    At lose dagger
 243    At lose axe
 244    At lose holy_water
 245    At gain cross
 246]
 247
 248# Journal
 249# -------
 250S Confrontation_Site::main  # Stars the exploration, and applies a zone
 251  zz Catacombs  # Creates and applies a level-1 zone above the existing zone
 252  o Top_Left  # Observes a transition at the current decision
 253x Bottom Rubble_Tower_Base::top Top  # Explores a transition (newly created)
 254    # The 'x' entry above notes the transition taken, the room name on
 255    # the other end, the transition name for the reciprocal, and the
 256    # new level-0 zone for the destination (could be omitted if it's in
 257    # the same level-0 zone).
 258    gt forced  # Tags the transition we just explored
 259
 260  o Crack  # We see a crack in the wall as we go down
 261    q ?short_and_high  # But we can't get through it right now
 262    # Spoilers: we'll never be able to get through it from this side
 263  n Fought -Skeleton_Bomber-  # A note, applied to the exploration step
 264  A gain Salamander  # An applied effect: gaining a power
 265  # Note: We're not modelling random drops but could with a challenge...
 266  n Literally first enemy I touched! # Another note
 267x Bottom_Right Catacombs_Entrance::top Top_Left  # Another exploration
 268  o up
 269    n Can't jump up here yet
 270    q height
 271  o top_stairs mid top_stairs  # The stairs to the middle level
 272    # Here we define the room name on the other side and the reciprocal
 273    # transition name.
 274x First_Right Catacombs_Treasury::left Left  # But first a detour
 275
 276  o box  # Can't get through this box yet
 277    qb heavy  # Requirement applies in both directions
 278t Left  # This 't' retraces a known transition at the current position
 279
 280x top_stairs  # Here the room and reciprocal names have already been
 281    # defined, and the zone is the same
 282
 283  o bottom_stairs bottom bottom_stairs
 284  A gain Mercury
 285  n Bone head along the way
 286
 287> savePoint
 288x bottom_stairs
 289  > dagger
 290  o Bot_Left
 291  o across_bottom
 292x Bot_Left Blockwall_Cave::bottom Right
 293
 294  o ledge
 295    q height
 296t Right
 297x across_bottom bottom_right back_across_bottom
 298  o Bot_Right
 299  # TODO: Is the zone necessary here? Feels like it shouldn't be
 300  u Catacombs_Entrance::bottom
 301  # Nothing super interesting across the bottom of the room, so we might
 302  # as well treat the entire bottom as one decision.
 303x Bot_Right Green_Gates::entrance Left
 304
 305  n Fought -Earth_Demon-
 306  o jump
 307    q distance
 308  a get_dash_boots
 309  At gain dash_boots
 310x jump upper left
 311  o right_stairs_down right_side
 312  o climb
 313x climb attic fall
 314  v fall_left entrance fly_up
 315    qr flight
 316  n at: Green_Gates::attic
 317  o fall_right right_side fly_up
 318    qr flight
 319
 320x Upper_Right  Catacombs_Treasury_2::entrance Left
 321  o block
 322    qb crumble
 323
 324t Left
 325  n at: Green_Gates::attic
 326x fall_right
 327  n at: Green_Gates::right_side
 328x Lower_Right Zombie_Corridor::main Left
 329
 330  n [
 331    Fought -Poison_Worm- combo'd with -Zombie- and -Skeleton-. This
 332    makes for an interesting room.
 333  ]
 334  # This annotation spans multiple lines of text
 335x Right Pinkstone_Tower::bottom_left Left_Mid
 336
 337  # And that's the end of this fragment for testing purposes!
 338""")
 339    assert len(expl) == 23
 340
 341    firstSituation = expl.getSituation(0)
 342    firstDecisions = expl.getActiveDecisions(0)
 343    secondSituation = expl.getSituation(1)
 344    secondDecisions = expl.getActiveDecisions(1)
 345    sixthSituation = expl.getSituation(5)
 346    sixthDecisions = expl.getActiveDecisions(5)
 347    finalSituation = expl.getSituation()
 348    finalDecisions = expl.getActiveDecisions()
 349
 350    assert len(firstSituation.graph) == 1
 351    assert firstDecisions == set()
 352
 353    assert len(secondSituation.graph) == 3  # room + 2 unknowns
 354    assert listingAtStep(expl, 1) == """\
 355  0 (Confrontation_Site::main)
 356  1 (_u.0)
 357  2 (_u.1)
 358"""
 359    assert secondDecisions == {0}
 360
 361    g6 = sixthSituation.graph
 362    assert len(g6) == 9
 363    assert listingAtStep(expl, 5) == """\
 364  0 (Confrontation_Site::main)
 365  1 (_u.0)
 366  2 (Rubble_Tower_Base::top)
 367  3 (_u.2)
 368  4 (Catacombs_Entrance::top)
 369  5 (_u.4)
 370  6 (Catacombs_Entrance::mid)
 371  7 (Catacombs_Treasury::left)
 372  8 (_u.7)
 373"""  # 'mid' has not been explored yet so isn't in a zone
 374    gZones = [g6.zoneParents(d) for d in sorted(g6)]
 375    assert gZones == [
 376        {'Confrontation_Site'},
 377        set(),
 378        {'Rubble_Tower_Base'},
 379        set(),
 380        {'Catacombs_Entrance'},
 381        set(),
 382        {'Catacombs_Entrance'},
 383        {'Catacombs_Treasury'},
 384        set()
 385    ]
 386    assert g6.destination('_u.0', 'return') == 0
 387    assert g6.destination('_u.2', 'return') == 2
 388    assert g6.destination('_u.4', 'return') == 4
 389    assert g6.destination('_u.7', 'return') == 7
 390    assert sixthDecisions == {4}
 391
 392    gL = finalSituation.graph
 393    assert len(gL) == 21
 394    assert listingAtStep(expl, -1) == """\
 395  0 (Confrontation_Site::main)
 396  1 (_u.0)
 397  2 (Rubble_Tower_Base::top)
 398  3 (_u.2)
 399  4 (Catacombs_Entrance::top)
 400  5 (_u.4)
 401  6 (Catacombs_Entrance::mid)
 402  7 (Catacombs_Treasury::left)
 403  8 (_u.7)
 404  9 (Catacombs_Entrance::bottom)
 405  10 (Catacombs_Entrance::save_1)
 406  11 (Blockwall_Cave::bottom)
 407  13 (_u.12)
 408  14 (Green_Gates::entrance)
 409  15 (Green_Gates::upper)
 410  16 (Green_Gates::right_side)
 411  17 (Green_Gates::attic)
 412  20 (Catacombs_Treasury_2::entrance)
 413  21 (_u.20)
 414  22 (Zombie_Corridor::main)
 415  23 (Pinkstone_Tower::bottom_left)
 416"""
 417    assert finalDecisions == {23}
 418
 419
 420def _test_CotMToCerberus() -> None:
 421    """
 422    A test for converting part of a journal from Castlevania: Circle of
 423    the Moon. This journal covers an experienced re-playthrough up to
 424    the first boss (Cerberus) and boss reward (double-jump).
 425
 426    TODO: This test is not finished yet!
 427    """
 428    expl = journal.convertJournal("""\
 429# Setup
 430# -----
 431P on  # Turns on zone-prefixing (we're using level-0 zones for rooms)
 432# A save-point alias that creates, explores and tags a new decision but
 433# then returns to where we just were.
 434= savePoint [
 435    x {_1} save{_2} return
 436    g save
 437    t return
 438]
 439= dagger [
 440    a candle{_}
 441    At gain dagger
 442    At lose axe
 443    At lose holy_water
 444    At lose cross
 445]
 446= axe [
 447    a candle{_}
 448    At lose dagger
 449    At gain axe
 450    At lose holy_water
 451    At lose cross
 452]
 453= holy_water [
 454    a candle{_}
 455    At lose dagger
 456    At lose axe
 457    At gain holy_water
 458    At lose cross
 459]
 460= cross [
 461    a candle{_}
 462    At lose dagger
 463    At lose axe
 464    At lose holy_water
 465    At gain cross
 466]
 467
 468# Journal
 469# -------
 470S main Confrontation_Site  # Stars the exploration, and applies a zone
 471zz Catacombs  # Creates and applies a level-1 zone above the existing zone
 472o Top_Left  # Observes a transition at the current decision
 473x Bottom top Top Rubble_Tower_Base  # Explores a transition (newly created)
 474    # The 'x' entry above notes the transition taken, the room name on
 475    # the other end, the transition name for the reciprocal, and the
 476    # new level-0 zone for the destination (could be omitted if it's in
 477    # the same level-0 zone).
 478    gt forced  # Tags the transition we just explored
 479
 480o Crack  # We see a crack in the wall as we go down
 481    q short_and_high  # But we can't get through it right now
 482    # Spoilers: we'll never be able to get through it from this side
 483  n Fought -Skeleton_Bomber-  # A note, applied to the exploration step
 484  A gain Salamander  # An applied effect: gaining a power
 485  # Note we're not modelling random drops here but we could with
 486  # challenges
 487  n Literally first enemy I touched! # Another note
 488x Bottom_Right Catacombs_Entrance::top Top_Left  # Another exploration
 489
 490o up
 491    n Can't jump up here yet
 492    q height
 493o top_stairs mid top_stairs  # The stairs to the middle level
 494    # Here we define the room name on the other side and the reciprocal
 495    # transition name.
 496x First_Right Catacombs_Treasury::left Left  # But first a detour
 497
 498o box  # Can't get through this box yet
 499    qb heavy  # Requirement applies in both directions
 500t Left  # This 't' retraces a known transition at the current position
 501
 502x top_stairs  # Here the room and reciprocal names have already been
 503    # defined, and the zone is the same
 504  o bottom_stairs bottom bottom_stairs
 505  A gain Mercury
 506  n Bone head along the way
 507
 508> savePoint
 509x bottom_stairs
 510  > dagger
 511  o Bot_Left
 512  o across_bottom
 513x Bot_Left Blockwall_Cave::bottom Right
 514
 515  o ledge
 516    q height
 517t Right
 518x across_bottom bottom_right back_across_bottom
 519  o Bot_Right
 520  u Catacombs_Entrance::bottom
 521  # Nothing super interesting across the bottom of the room, so we might
 522  # as well treat the entire bottom as one decision.
 523x Bot_Right entrance Left Green_Gates
 524  n Fought -Earth_Demon-
 525  o jump
 526    q distance
 527  a get_dash_boots
 528  At gain dash_boots
 529x jump upper left
 530  o right_stairs_down right_side
 531  o climb
 532x climb attic fall
 533  v fall_left entrance fly_up
 534    qr flight
 535  o fall_right right_side fly_up
 536    qr flight
 537
 538x Upper_Right Catacombs_Treasury_2::entrance Left
 539  o block
 540    qb crumble
 541
 542t Left
 543x fall_right
 544x Lower_Right main Left Zombie_Corridor
 545
 546  n [
 547    Fought -Poison_Worm- combo'd with -Zombie- and -Skeleton-. This
 548    makes for an interesting room.
 549  ]
 550  # This annotation spans multiple lines of text
 551x Right bottom_left Left_Mid Pinkstone_Tower
 552
 553# TODO: HERE
 554
 555[Pinkstone_Tower]
 556< Left_Mid
 557? down
 558? up
 559? across
 560>< Left_Crumble [. <mp>]
 561@ down
 562> Bottom_Right
 563
 564[Green_Shaft_Cave]
 565< Left
 566# -Earth_Demon-
 567. <ht>
 568x< up (tall_and_narrow)
 569> Left
 570
 571[Pinkstone_Tower]
 572< Bottom_Right
 573- across
 574> Mid_Right
 575
 576[Squeeze_Under]
 577< Right
 578. <hp>
 579> Right
 580
 581[Pinkstone_Tower]
 582< Mid_Right
 583- up
 584? up2
 585> Upper_Left
 586
 587[Pinkstone_Connector_Crack]
 588< Right
 589x< crack (short_and_high)
 590> Right
 591
 592[Pinkstone_Tower]
 593< Upper_Left
 594- up2
 595? across2
 596? up3
 597>< Top_Left [. <mp>]
 598- up3
 599? Top
 600- across2
 601>< Top_Right [save]
 602> Top
 603
 604[FlameMouth_Chamber]
 605< Bottom
 606? right
 607> Left
 608
 609[Pinkstone_Tower_2]
 610< Entrance_Crack
 611. <axe>
 612? Right
 613>< Left [. <hp>]
 614> Right
 615
 616[Pinkstone_Connector_Crack]
 617< Left
 618-> crack
 619> Right
 620
 621[Pinkstone_Tower]
 622< Upper_Left
 623> Top
 624
 625[FlameMouth_Chamber]
 626< Bottom
 627- right
 628? up
 629- right2
 630. <mp>
 631x< ledge (height)
 632- up
 633> Top_Left
 634
 635[Muddy_Pinkstone]
 636< Right
 637? down
 638>< Top_Left [save]
 639- down
 640> Bottom_Left
 641
 642[Greenstone_Double_Shaft]
 643< Top_Right
 644# -Gremlin-
 645>< Mid_Right [. <ht>]
 646? up
 647> Bottom_Left
 648
 649[Mummies_Hallway]
 650< Right
 651# Mummy
 652. <holy water>
 653x< ledge (height)
 654> Right
 655
 656[Greenstone_Double_Shaft]
 657< Bottom_Left
 658- up
 659?b Top_Left
 660> Mid_Left
 661
 662[Hopper_Treasury]
 663< Right
 664# -Hopper-
 665. <mp>
 666> Right
 667
 668[Greenstone_Double_Shaft]
 669< Mid_Left
 670> Top_Left {boss}
 671
 672[Cerberus]
 673< Right {boss}
 674# -Cerberus-
 675>< Left [. <double> (/height|distance)]
 676> Right
 677""")
 678    # TODO: Fix this up? Or swap for another example?
 679    assert len(expl) == 3
 680
 681    firstSituation = expl.getSituation(0)
 682    firstDecisions = expl.getActiveDecisions(0)
 683    sixthSituation = expl.getSituation(5)
 684    sixthDecisions = expl.getActiveDecisions(5)
 685    finalSituation = expl.getSituation()
 686    finalDecisions = expl.getActiveDecisions()
 687
 688    assert len(firstSituation.graph) == 1
 689    assert firstDecisions == {0}
 690
 691    assert len(sixthSituation.graph) == 10
 692    assert sixthDecisions == {3}
 693
 694    assert len(finalSituation.graph) == 100
 695    assert finalDecisions == {41}
 696
 697
 698def test_entry_types() -> None:
 699    pf = journal.JournalParseFormat()
 700    for ebits, expect in [
 701        (["o", "action"], ("observe", 'active', None, ["action"])),
 702        (["observe", "action"], ("observe", 'active', None, ["action"])),
 703        (["oa", "action"], ("observe", 'active', "actionPart", ["action"])),
 704        (["ot", "action"],
 705         ("observe", 'active', "transitionPart", ["action"])),
 706        (["observe@a", "action"],
 707         ("observe", 'active', "actionPart", ["action"])),
 708        (["observe@action", "action"],
 709         ("observe", 'active', "actionPart", ["action"])),
 710        (["observe@actionPart", "action"],
 711         ("observe", 'active', "actionPart", ["action"])),
 712        (["imposed/observe@actionPart", "action"],
 713         ("observe", 'imposed', "actionPart", ["action"])),
 714        (["!oa", "action"],
 715         ("observe", 'unintended', "actionPart", ["action"])),
 716    ]:
 717        assert pf.determineEntryType(ebits) == expect, ebits
 718
 719    for broken in (
 720        ["obse"],
 721        ["obse", "action"],
 722        ["observe@madeUp"],
 723        ["oq"],
 724    ):
 725        with pytest.raises(journal.JournalParseError):
 726            assert pf.determineEntryType(broken) is None, broken
 727
 728
 729def test_long_entry_types() -> None:
 730    """
 731    Tests the use of long entry types.
 732    """
 733    expl = journal.convertJournal("""\
 734START start
 735zone Start
 736zone@zone Region
 737observe@action action
 738observe door
 739explore door Room1::room1 door
 740tag@both blue
 741""")
 742    expl2 = journal.convertJournal("""\
 743S start
 744z Start
 745zz Region
 746oa action
 747o door
 748x door Room1::room1 door
 749gb blue
 750""")
 751    assert expl == expl2
 752
 753
 754def test_revisions() -> None:
 755    """
 756    Tests revision-type entries.
 757    """
 758    expl = journal.convertJournal("""\
 759S A
 760  o left
 761  o right
 762  extinguish left
 763x right B left
 764  unify A
 765x down C up
 766  complicate up D down2 up2
 767t up
 768x right E left
 769""")
 770    assert len(expl) == 6
 771
 772    # Ignoring empty starting graph
 773    first = expl.getSituation(1).graph
 774    assert expl.getActiveDecisions(1) == {0}
 775    assert listingAtStep(expl, 1) == """\
 776  0 (A)
 777  2 (_u.1)
 778"""
 779    assert first.getDestination('A', 'left') is None
 780    assert first.getDestination('A', 'right') is not None
 781    assert not first.isConfirmed(first.destination('A', 'right'))
 782
 783    second = expl.getSituation(2).graph
 784    assert listingAtStep(expl, 2) == """\
 785  0 (A)
 786  3 (_u.2)
 787"""
 788    assert expl.getActiveDecisions(2) == {0}
 789    assert 'B' not in [second.nameFor(d) for d in second]
 790    assert second.getDestination('A', 'right') == 0
 791    assert second.getDestination('A', 'left') == 0
 792    assert not second.isConfirmed(second.destination('A', 'down'))
 793
 794    third = expl.getSituation(3).graph
 795    assert listingAtStep(expl, 3) == """\
 796  0 (A)
 797  3 (C)
 798  4 (D)
 799"""
 800    assert expl.getActiveDecisions(3) == {3}
 801    assert third.getDestination('A', 'down') == 4
 802    assert third.getDestination('D', 'up2') == 0
 803    assert third.getDestination('C', 'up') == 4
 804    assert third.getDestination('D', 'down2') == 3
 805
 806    fourth = expl.getSituation(4).graph
 807    assert listingAtStep(expl, 4) == """\
 808  0 (A)
 809  3 (C)
 810  4 (D)
 811  5 (_u.3)
 812"""
 813    assert expl.getActiveDecisions(4) == {4}
 814    assert fourth.getDestination('A', 'down') == 4
 815    assert fourth.getDestination('D', 'up2') == 0
 816    assert fourth.getDestination('C', 'up') == 4
 817    assert fourth.getDestination('D', 'down2') == 3
 818    assert not fourth.isConfirmed(fourth.destination('D', 'right'))
 819
 820    fifth = expl.getSituation(5).graph
 821    assert listingAtStep(expl, 5) == """\
 822  0 (A)
 823  3 (C)
 824  4 (D)
 825  5 (E)
 826"""
 827    assert expl.getActiveDecisions(5) == {5}
 828    assert fifth.getDestination('A', 'down') == 4
 829    assert fifth.getDestination('D', 'up2') == 0
 830    assert fifth.getDestination('C', 'up') == 4
 831    assert fifth.getDestination('D', 'down2') == 3
 832    assert fifth.getDestination('D', 'right') == 5
 833
 834
 835def test_journal_outcomes() -> None:
 836    """
 837    Tests challenge outcome mechanisms.
 838    """
 839    warnings.filterwarnings("error")
 840    expl = journal.convertJournal("""\
 841S A
 842  A gain coin*5
 843  o right
 844    e [
 845       {<0>luck>{gain coin*1}{lose coin*1}}
 846     ]  # gain outcome is selected
 847x right B left
 848  n has: coin*6  # gain outcome triggers by default
 849  er [
 850     {<0>luck{gain coin*2}>{lose coin*2}}
 851  ]  # lose outcome is selected
 852t left
 853  n has: coin*4  # lose outcome triggered by default
 854t right%f  # explicit outcome overrules
 855  n has: coin*3
 856t left%s   # explicit again
 857  n has: coin*5
 858t right  # remembers most-recent outcomes
 859  n has: coin*4
 860t left%f  # specified again
 861  n has: coin*2
 862t right%s
 863  n has: coin*3
 864t left  # remembers most-recent
 865  n has: coin*1
 866""")
 867    # (should not warn about mismatched 'has:' notes)
 868
 869    assert len(expl) == 10
 870
 871    def tokensAtStep(e, t, n):
 872        return base.combinedTokenCount(e.getSituation(n).state, t)
 873
 874    assert tokensAtStep(expl, 'coin', 0) == 0
 875    assert tokensAtStep(expl, 'coin', 1) == 5
 876    assert tokensAtStep(expl, 'coin', 2) == 6
 877    assert tokensAtStep(expl, 'coin', 3) == 4
 878    assert tokensAtStep(expl, 'coin', 4) == 3
 879    assert tokensAtStep(expl, 'coin', 5) == 5
 880    assert tokensAtStep(expl, 'coin', 6) == 4
 881    assert tokensAtStep(expl, 'coin', 7) == 2
 882    assert tokensAtStep(expl, 'coin', 8) == 3
 883    assert tokensAtStep(expl, 'coin', 9) == 1
 884
 885    expl2 = journal.convertJournal("""\
 886S A
 887  A gain coin*5
 888  n has: coin*5
 889  n has: flower*0
 890  o right B left
 891    e [
 892       {
 893           <0>luck{<0>luck{gain coin*1}{lose coin*1}; bounce}
 894                  {<0>luck{gain coin*2}{lose coin*2}; follow left};
 895           ??(coin*5){gain flower*1}{<0>luck{gain flower*1}{}}
 896       }
 897     ]  # no pre-selected outcomes
 898x right%fs
 899  # effectively bounces each time so we can just keep taking it
 900  n has: coin*7
 901  n has: flower*1
 902  n at: A
 903t right%sf
 904  n has: coin*6
 905  n has: flower*2
 906  n at: A
 907t right%ff  # coins are checked in original state
 908  n has: coin*4
 909  n has: flower*3
 910  n at: A
 911t right%ssf
 912  n has: coin*5
 913  n has: flower*3
 914  n at: A
 915""")
 916    assert len(expl2) == 6
 917
 918    assert tokensAtStep(expl2, 'coin', 0) == 0
 919    assert tokensAtStep(expl2, 'flower', 0) == 0
 920    assert base.combinedDecisionSet(expl2.getSituation(0).state) == set()
 921    assert tokensAtStep(expl2, 'coin', 1) == 5
 922    assert tokensAtStep(expl2, 'flower', 1) == 0
 923    assert base.combinedDecisionSet(expl2.getSituation(1).state) == {0}
 924    assert tokensAtStep(expl2, 'coin', 2) == 7
 925    assert tokensAtStep(expl2, 'flower', 2) == 1
 926    assert base.combinedDecisionSet(expl2.getSituation(2).state) == {0}
 927    assert tokensAtStep(expl2, 'coin', 3) == 6
 928    assert tokensAtStep(expl2, 'flower', 3) == 2
 929    assert base.combinedDecisionSet(expl2.getSituation(3).state) == {0}
 930    assert tokensAtStep(expl2, 'coin', 4) == 4
 931    assert tokensAtStep(expl2, 'flower', 4) == 3
 932    assert base.combinedDecisionSet(expl2.getSituation(4).state) == {0}
 933    assert tokensAtStep(expl2, 'coin', 5) == 5
 934    assert tokensAtStep(expl2, 'flower', 5) == 3
 935    assert base.combinedDecisionSet(expl2.getSituation(5).state) == {0}
 936
 937    warnings.filterwarnings("default")
 938
 939
 940def test_journal_reversions() -> None:
 941    """
 942    Some tests of the state-reversion mechanisms.
 943    """
 944    warnings.filterwarnings("error")
 945    expl = journal.convertJournal("""\
 946S A
 947  A save slot0
 948x right B left
 949  A save slot1
 950x right C left
 951  At gain coin*1
 952revert slot0
 953  n at: A
 954  n has: coin*0
 955w
 956revert slot1
 957  n at: B
 958  n has: coin*0
 959w
 960? destinations
 961t right  # graph still unreverted
 962  n at: C
 963  n has:coin*1
 964revert slot0 all-positions primary
 965  n at: A
 966  n has: coin*1
 967w
 968revert slot1 c-main-tokens
 969  n at: A
 970  n has: coin*0
 971x up aboveA down
 972  n at: aboveA
 973revert slot0 all-positions primary graph
 974  n at: A
 975x up aboveA2 down
 976  n at: aboveA2
 977""")
 978    warnings.filterwarnings("default")
 979    assert len(expl) == 15
 980    preRevertGraph = expl.getSituation(-3).graph
 981    assert len(preRevertGraph) == 4
 982    assert preRevertGraph.destinationsFrom('A') == {"right": 1, "up": 3}
 983    revertedGraph = expl.getSituation(-2).graph
 984    assert len(revertedGraph) == 2
 985    assert revertedGraph.destinationsFrom('A') == {"up": 4}
 986
 987
 988def test_journal_macros() -> None:
 989    """
 990    Some tests of macros.
 991    """
 992    warnings.filterwarnings("error")
 993    expl = journal.convertJournal("""\
 994= boss who level [
 995  oa fight{who}
 996    gt bossFight
 997    e [
 998      {
 999        <{level}>sum(combat, {who}){
1000          gain defeated{who};
1001          deactivate
1002        }{
1003          goto endings//death
1004        }
1005      }
1006    ]
1007]
1008
1009
1010= failBossRevert who [
1011  ta fight{who}%f
1012  R
1013]
1014
1015= beatBoss who [
1016  ta fight{who}%s
1017]
1018
1019S start  # step 1
1020@ endings//death
1021@ @
1022A save
1023
1024x right bossRoom left  # step 2
1025> boss Boss -1
1026  e gain orb*1
1027  n has: orb*0
1028  n has: coin*0
1029
1030> failBossRevert Boss  # steps 3+4
1031  n at: start
1032  n has: orb*0
1033  n has: coin*0
1034
1035t right  # step 5
1036  n at: bossRoom
1037
1038> failBossRevert Boss  # step 6+7
1039  n at: start
1040  n has: orb*0
1041
1042t right  # step 8
1043  n has: orb*0
1044  n has: coin*0
1045  > beatBoss Boss  # step 9
1046    At gain coin*1
1047  n has: orb*1
1048  n has: coin*1
1049
1050? steps
1051
1052p endings//death  # step 10
1053R  # step 11
1054  n at: start
1055  n has: orb*0
1056  n has: coin*0
1057
1058? steps
1059
1060t right  # step 12
1061  n has: orb*0
1062  n has: coin*0
1063  > beatBoss Boss  # step 13
1064  n has: orb*1
1065  n has: coin*1
1066  n can: defeatedBoss
1067""")
1068    warnings.filterwarnings("default")
1069    assert len(expl) == 14
1070    # Most assertions are in the journal itself here
1071
1072
1073def test_applyAfterwards() -> None:
1074    """
1075    Tests using 'At' to note non-obvious transition effects.
1076    """
1077    warnings.filterwarnings("error")
1078    expl = journal.convertJournal("""\
1079= get what [
1080  a get
1081    At gain {what}
1082]
1083
1084S start
1085  n has: coin*0
1086  oa grab
1087    e gain coin*1
1088  n has: coin*0
1089  n has: chip*0
1090  ta grab
1091  n has: coin*1
1092  n has: chip*0
1093    At gain chip*1
1094  n has: coin*1
1095  n has: chip*1
1096  ta grab
1097  n has: coin*2
1098  n has: chip*2
1099  > get coin*2
1100  n has: coin*4
1101  ta get
1102  n has: coin*6
1103""")
1104    warnings.filterwarnings("default")
1105    assert expl.tokenCountNow('coin') == 6
1106    assert expl.tokenCountNow('chip') == 2
1107
1108# TODO: More specific case coverage tests...
def listingAtStep(expl: exploration.core.DiscreteExploration, step: int = -1) -> str:
17def listingAtStep(expl: core.DiscreteExploration, step: int = -1) -> str:
18    """
19    Calls `namesListing` for all decisions in the graph at the specified
20    step of the given exploration.
21    """
22    graph = expl.getSituation(step).graph
23    return graph.namesListing(graph)

Calls namesListing for all decisions in the graph at the specified step of the given exploration.

def test_simpleConversion() -> None:
26def test_simpleConversion() -> None:
27    """
28    A simple test of journal conversion.
29    """
30    simple = journal.convertJournal("""\
31S First_Room
32x Exit Second_Room Entrance
33gt tag
34
35o Onwards
36E END
37""")
38    assert len(simple) == 4
39
40    finalGraph = simple.getSituation().graph
41
42    assert len(finalGraph) == 4
43    assert finalGraph.namesListing(finalGraph) == """\
44  0 (First_Room)
45  1 (Second_Room)
46  2 (_u.1)
47  3 (endings//END)
48"""
49
50    assert finalGraph.destinationsFrom("First_Room") == {'Exit': 1}
51    assert finalGraph.destinationsFrom("Second_Room") == {
52        'Entrance': 0,
53        'Onwards': 2
54    }
55    assert finalGraph.destinationsFrom("END") == {}
56    assert json.dumps(
57        finalGraph.textMapObj(),
58        indent=4
59    ) == """\
60{
61    "0::Exit": {
62        "1::Entrance": "0",
63        "1::Onwards": {
64            "2::return": "1"
65        }
66    }
67}"""
68    # TODO: We'd like to include the ending here!

A simple test of journal conversion.

def test_exploring_zones() -> None:
 73def test_exploring_zones() -> None:
 74    """
 75    A test for exploration with zones.
 76    """
 77    expl = journal.convertJournal("""\
 78S zone::room  # decision 0
 79x ahead room2 back  # decision 1
 80o hatch basement ladder  # decision 2
 81x ahead zone2::room3 back  # decision 3
 82x ahead room4 back  # decision 4
 83t back
 84t back
 85x hatch
 86""")
 87    # TODO: Add test here for returning to unvisited decision not placed
 88    # in zone
 89    assert expl.getActiveDecisions() == {2}
 90    now = expl.getSituation()
 91    assert now.graph.getDecision('basement') == 2
 92    assert now.graph.getDecision(
 93        base.DecisionSpecifier(domain=None, zone='zone', name='basement')
 94    ) == 2
 95    assert now.graph.getDecision(
 96        base.DecisionSpecifier(domain=None, zone='zone2', name='basement')
 97    ) is None
 98    assert now.graph.getDecision(
 99        base.DecisionSpecifier(domain=None, zone='zone2', name='room3')
100    ) == 3
101    assert now.graph.namesListing(now.graph) == """\
102  0 (zone::room)
103  1 (zone::room2)
104  2 (zone::basement)
105  3 (zone2::room3)
106  4 (zone2::room4)
107"""
108    assert now.graph.zoneParents(0) == {'zone'}
109    assert now.graph.zoneParents(2) == {'zone'}
110    assert now.graph.zoneParents(3) == {'zone2'}
111    assert now.graph.zoneParents(4) == {'zone2'}
112    assert now.graph.zoneParents(1) == {'zone'}

A test for exploration with zones.

def test_zoneNaming() -> None:
115def test_zoneNaming() -> None:
116    """
117    Tests some situations with zone propagation in journals.
118    """
119    expl = journal.convertJournal("""\
120S West::start
121x right second left
122  o down third up
123x right East::fourth left
124t left
125x down  # third should be in zone 'West'
126  o left New::fifth right
127x left  # fifth should remain in zone 'New'
128""")
129    g1 = expl.getSituation(1).graph
130    assert g1.namesListing(g1) == """\
131  0 (West::start)
132  1 (_u.0)
133"""
134
135    g2 = expl.getSituation(2).graph
136    assert g2.namesListing(g2) == """\
137  0 (West::start)
138  1 (West::second)
139  2 (West::third)
140  3 (_u.2)
141"""
142    assert g2.zoneParents(0) == {'West'}
143    assert g2.zoneParents(1) == {'West'}
144    assert g2.zoneParents(2) == {'West'}
145    assert g2.zoneParents(3) == set()
146
147    g3 = expl.getSituation(3).graph
148    assert g3.namesListing(g3) == """\
149  0 (West::start)
150  1 (West::second)
151  2 (West::third)
152  3 (East::fourth)
153"""
154    assert g3.zoneParents(0) == {'West'}
155    assert g3.zoneParents(1) == {'West'}
156    assert g3.zoneParents(2) == {'West'}
157    assert g3.zoneParents(3) == {'East'}
158
159    g4 = expl.getSituation(4).graph
160    assert g4.namesListing(g4) == """\
161  0 (West::start)
162  1 (West::second)
163  2 (West::third)
164  3 (East::fourth)
165"""
166    assert g4.zoneParents(0) == {'West'}
167    assert g4.zoneParents(1) == {'West'}
168    assert g3.zoneParents(2) == {'West'}
169    assert g4.zoneParents(3) == {'East'}
170
171    g5 = expl.getSituation(5).graph
172    assert g5.namesListing(g5) == """\
173  0 (West::start)
174  1 (West::second)
175  2 (West::third)
176  3 (East::fourth)
177  4 (New::fifth)
178"""
179    assert g5.zoneParents(0) == {'West'}
180    assert g5.zoneParents(1) == {'West'}
181    assert g5.zoneParents(2) == {'West'}
182    assert g5.zoneParents(3) == {'East'}
183    assert g5.zoneParents(4) == {'New'}
184
185    g6 = expl.getSituation(6).graph
186    assert g6.namesListing(g6) == """\
187  0 (West::start)
188  1 (West::second)
189  2 (West::third)
190  3 (East::fourth)
191  4 (New::fifth)
192"""
193    assert g6.zoneParents(0) == {'West'}
194    assert g6.zoneParents(1) == {'West'}
195    assert g6.zoneParents(2) == {'West'}
196    assert g6.zoneParents(3) == {'East'}
197    assert g6.zoneParents(4) == {'New'}

Tests some situations with zone propagation in journals.

def test_startOfCotM() -> None:
200def test_startOfCotM() -> None:
201    """
202    A test for converting part of a journal from Castlevania: Circle of
203    the Moon. This journal covers just the first few rooms of the game.
204    """
205    # Note that we're expecting a `TransitionBlockedWarning` because the
206    # requirement 'q distance' does not match the power gained 'At gain
207    # dash_boots'...
208    # TODO: Add an equivalence now that we support those!
209    with pytest.warns(core.TransitionBlockedWarning):
210        expl = journal.convertJournal("""\
211# Setup
212# -----
213# A save-point alias that creates, explores and tags a new decision but
214# then returns to where we just were.
215= savePoint [
216    x {_1} save{_2} return
217    g save
218    t return
219]
220= dagger [
221    a candle{_}
222    At gain dagger
223    At lose axe
224    At lose holy_water
225    At lose cross
226]
227= axe [
228    a candle{_}
229    At lose dagger
230    At gain axe
231    At lose holy_water
232    At lose cross
233]
234= holy_water [
235    a candle{_}
236    At lose dagger
237    At lose axe
238    At gain holy_water
239    At lose cross
240]
241= cross [
242    a candle{_}
243    At lose dagger
244    At lose axe
245    At lose holy_water
246    At gain cross
247]
248
249# Journal
250# -------
251S Confrontation_Site::main  # Stars the exploration, and applies a zone
252  zz Catacombs  # Creates and applies a level-1 zone above the existing zone
253  o Top_Left  # Observes a transition at the current decision
254x Bottom Rubble_Tower_Base::top Top  # Explores a transition (newly created)
255    # The 'x' entry above notes the transition taken, the room name on
256    # the other end, the transition name for the reciprocal, and the
257    # new level-0 zone for the destination (could be omitted if it's in
258    # the same level-0 zone).
259    gt forced  # Tags the transition we just explored
260
261  o Crack  # We see a crack in the wall as we go down
262    q ?short_and_high  # But we can't get through it right now
263    # Spoilers: we'll never be able to get through it from this side
264  n Fought -Skeleton_Bomber-  # A note, applied to the exploration step
265  A gain Salamander  # An applied effect: gaining a power
266  # Note: We're not modelling random drops but could with a challenge...
267  n Literally first enemy I touched! # Another note
268x Bottom_Right Catacombs_Entrance::top Top_Left  # Another exploration
269  o up
270    n Can't jump up here yet
271    q height
272  o top_stairs mid top_stairs  # The stairs to the middle level
273    # Here we define the room name on the other side and the reciprocal
274    # transition name.
275x First_Right Catacombs_Treasury::left Left  # But first a detour
276
277  o box  # Can't get through this box yet
278    qb heavy  # Requirement applies in both directions
279t Left  # This 't' retraces a known transition at the current position
280
281x top_stairs  # Here the room and reciprocal names have already been
282    # defined, and the zone is the same
283
284  o bottom_stairs bottom bottom_stairs
285  A gain Mercury
286  n Bone head along the way
287
288> savePoint
289x bottom_stairs
290  > dagger
291  o Bot_Left
292  o across_bottom
293x Bot_Left Blockwall_Cave::bottom Right
294
295  o ledge
296    q height
297t Right
298x across_bottom bottom_right back_across_bottom
299  o Bot_Right
300  # TODO: Is the zone necessary here? Feels like it shouldn't be
301  u Catacombs_Entrance::bottom
302  # Nothing super interesting across the bottom of the room, so we might
303  # as well treat the entire bottom as one decision.
304x Bot_Right Green_Gates::entrance Left
305
306  n Fought -Earth_Demon-
307  o jump
308    q distance
309  a get_dash_boots
310  At gain dash_boots
311x jump upper left
312  o right_stairs_down right_side
313  o climb
314x climb attic fall
315  v fall_left entrance fly_up
316    qr flight
317  n at: Green_Gates::attic
318  o fall_right right_side fly_up
319    qr flight
320
321x Upper_Right  Catacombs_Treasury_2::entrance Left
322  o block
323    qb crumble
324
325t Left
326  n at: Green_Gates::attic
327x fall_right
328  n at: Green_Gates::right_side
329x Lower_Right Zombie_Corridor::main Left
330
331  n [
332    Fought -Poison_Worm- combo'd with -Zombie- and -Skeleton-. This
333    makes for an interesting room.
334  ]
335  # This annotation spans multiple lines of text
336x Right Pinkstone_Tower::bottom_left Left_Mid
337
338  # And that's the end of this fragment for testing purposes!
339""")
340    assert len(expl) == 23
341
342    firstSituation = expl.getSituation(0)
343    firstDecisions = expl.getActiveDecisions(0)
344    secondSituation = expl.getSituation(1)
345    secondDecisions = expl.getActiveDecisions(1)
346    sixthSituation = expl.getSituation(5)
347    sixthDecisions = expl.getActiveDecisions(5)
348    finalSituation = expl.getSituation()
349    finalDecisions = expl.getActiveDecisions()
350
351    assert len(firstSituation.graph) == 1
352    assert firstDecisions == set()
353
354    assert len(secondSituation.graph) == 3  # room + 2 unknowns
355    assert listingAtStep(expl, 1) == """\
356  0 (Confrontation_Site::main)
357  1 (_u.0)
358  2 (_u.1)
359"""
360    assert secondDecisions == {0}
361
362    g6 = sixthSituation.graph
363    assert len(g6) == 9
364    assert listingAtStep(expl, 5) == """\
365  0 (Confrontation_Site::main)
366  1 (_u.0)
367  2 (Rubble_Tower_Base::top)
368  3 (_u.2)
369  4 (Catacombs_Entrance::top)
370  5 (_u.4)
371  6 (Catacombs_Entrance::mid)
372  7 (Catacombs_Treasury::left)
373  8 (_u.7)
374"""  # 'mid' has not been explored yet so isn't in a zone
375    gZones = [g6.zoneParents(d) for d in sorted(g6)]
376    assert gZones == [
377        {'Confrontation_Site'},
378        set(),
379        {'Rubble_Tower_Base'},
380        set(),
381        {'Catacombs_Entrance'},
382        set(),
383        {'Catacombs_Entrance'},
384        {'Catacombs_Treasury'},
385        set()
386    ]
387    assert g6.destination('_u.0', 'return') == 0
388    assert g6.destination('_u.2', 'return') == 2
389    assert g6.destination('_u.4', 'return') == 4
390    assert g6.destination('_u.7', 'return') == 7
391    assert sixthDecisions == {4}
392
393    gL = finalSituation.graph
394    assert len(gL) == 21
395    assert listingAtStep(expl, -1) == """\
396  0 (Confrontation_Site::main)
397  1 (_u.0)
398  2 (Rubble_Tower_Base::top)
399  3 (_u.2)
400  4 (Catacombs_Entrance::top)
401  5 (_u.4)
402  6 (Catacombs_Entrance::mid)
403  7 (Catacombs_Treasury::left)
404  8 (_u.7)
405  9 (Catacombs_Entrance::bottom)
406  10 (Catacombs_Entrance::save_1)
407  11 (Blockwall_Cave::bottom)
408  13 (_u.12)
409  14 (Green_Gates::entrance)
410  15 (Green_Gates::upper)
411  16 (Green_Gates::right_side)
412  17 (Green_Gates::attic)
413  20 (Catacombs_Treasury_2::entrance)
414  21 (_u.20)
415  22 (Zombie_Corridor::main)
416  23 (Pinkstone_Tower::bottom_left)
417"""
418    assert finalDecisions == {23}

A test for converting part of a journal from Castlevania: Circle of the Moon. This journal covers just the first few rooms of the game.

def test_entry_types() -> None:
699def test_entry_types() -> None:
700    pf = journal.JournalParseFormat()
701    for ebits, expect in [
702        (["o", "action"], ("observe", 'active', None, ["action"])),
703        (["observe", "action"], ("observe", 'active', None, ["action"])),
704        (["oa", "action"], ("observe", 'active', "actionPart", ["action"])),
705        (["ot", "action"],
706         ("observe", 'active', "transitionPart", ["action"])),
707        (["observe@a", "action"],
708         ("observe", 'active', "actionPart", ["action"])),
709        (["observe@action", "action"],
710         ("observe", 'active', "actionPart", ["action"])),
711        (["observe@actionPart", "action"],
712         ("observe", 'active', "actionPart", ["action"])),
713        (["imposed/observe@actionPart", "action"],
714         ("observe", 'imposed', "actionPart", ["action"])),
715        (["!oa", "action"],
716         ("observe", 'unintended', "actionPart", ["action"])),
717    ]:
718        assert pf.determineEntryType(ebits) == expect, ebits
719
720    for broken in (
721        ["obse"],
722        ["obse", "action"],
723        ["observe@madeUp"],
724        ["oq"],
725    ):
726        with pytest.raises(journal.JournalParseError):
727            assert pf.determineEntryType(broken) is None, broken
def test_long_entry_types() -> None:
730def test_long_entry_types() -> None:
731    """
732    Tests the use of long entry types.
733    """
734    expl = journal.convertJournal("""\
735START start
736zone Start
737zone@zone Region
738observe@action action
739observe door
740explore door Room1::room1 door
741tag@both blue
742""")
743    expl2 = journal.convertJournal("""\
744S start
745z Start
746zz Region
747oa action
748o door
749x door Room1::room1 door
750gb blue
751""")
752    assert expl == expl2

Tests the use of long entry types.

def test_revisions() -> None:
755def test_revisions() -> None:
756    """
757    Tests revision-type entries.
758    """
759    expl = journal.convertJournal("""\
760S A
761  o left
762  o right
763  extinguish left
764x right B left
765  unify A
766x down C up
767  complicate up D down2 up2
768t up
769x right E left
770""")
771    assert len(expl) == 6
772
773    # Ignoring empty starting graph
774    first = expl.getSituation(1).graph
775    assert expl.getActiveDecisions(1) == {0}
776    assert listingAtStep(expl, 1) == """\
777  0 (A)
778  2 (_u.1)
779"""
780    assert first.getDestination('A', 'left') is None
781    assert first.getDestination('A', 'right') is not None
782    assert not first.isConfirmed(first.destination('A', 'right'))
783
784    second = expl.getSituation(2).graph
785    assert listingAtStep(expl, 2) == """\
786  0 (A)
787  3 (_u.2)
788"""
789    assert expl.getActiveDecisions(2) == {0}
790    assert 'B' not in [second.nameFor(d) for d in second]
791    assert second.getDestination('A', 'right') == 0
792    assert second.getDestination('A', 'left') == 0
793    assert not second.isConfirmed(second.destination('A', 'down'))
794
795    third = expl.getSituation(3).graph
796    assert listingAtStep(expl, 3) == """\
797  0 (A)
798  3 (C)
799  4 (D)
800"""
801    assert expl.getActiveDecisions(3) == {3}
802    assert third.getDestination('A', 'down') == 4
803    assert third.getDestination('D', 'up2') == 0
804    assert third.getDestination('C', 'up') == 4
805    assert third.getDestination('D', 'down2') == 3
806
807    fourth = expl.getSituation(4).graph
808    assert listingAtStep(expl, 4) == """\
809  0 (A)
810  3 (C)
811  4 (D)
812  5 (_u.3)
813"""
814    assert expl.getActiveDecisions(4) == {4}
815    assert fourth.getDestination('A', 'down') == 4
816    assert fourth.getDestination('D', 'up2') == 0
817    assert fourth.getDestination('C', 'up') == 4
818    assert fourth.getDestination('D', 'down2') == 3
819    assert not fourth.isConfirmed(fourth.destination('D', 'right'))
820
821    fifth = expl.getSituation(5).graph
822    assert listingAtStep(expl, 5) == """\
823  0 (A)
824  3 (C)
825  4 (D)
826  5 (E)
827"""
828    assert expl.getActiveDecisions(5) == {5}
829    assert fifth.getDestination('A', 'down') == 4
830    assert fifth.getDestination('D', 'up2') == 0
831    assert fifth.getDestination('C', 'up') == 4
832    assert fifth.getDestination('D', 'down2') == 3
833    assert fifth.getDestination('D', 'right') == 5

Tests revision-type entries.

def test_journal_outcomes() -> None:
836def test_journal_outcomes() -> None:
837    """
838    Tests challenge outcome mechanisms.
839    """
840    warnings.filterwarnings("error")
841    expl = journal.convertJournal("""\
842S A
843  A gain coin*5
844  o right
845    e [
846       {<0>luck>{gain coin*1}{lose coin*1}}
847     ]  # gain outcome is selected
848x right B left
849  n has: coin*6  # gain outcome triggers by default
850  er [
851     {<0>luck{gain coin*2}>{lose coin*2}}
852  ]  # lose outcome is selected
853t left
854  n has: coin*4  # lose outcome triggered by default
855t right%f  # explicit outcome overrules
856  n has: coin*3
857t left%s   # explicit again
858  n has: coin*5
859t right  # remembers most-recent outcomes
860  n has: coin*4
861t left%f  # specified again
862  n has: coin*2
863t right%s
864  n has: coin*3
865t left  # remembers most-recent
866  n has: coin*1
867""")
868    # (should not warn about mismatched 'has:' notes)
869
870    assert len(expl) == 10
871
872    def tokensAtStep(e, t, n):
873        return base.combinedTokenCount(e.getSituation(n).state, t)
874
875    assert tokensAtStep(expl, 'coin', 0) == 0
876    assert tokensAtStep(expl, 'coin', 1) == 5
877    assert tokensAtStep(expl, 'coin', 2) == 6
878    assert tokensAtStep(expl, 'coin', 3) == 4
879    assert tokensAtStep(expl, 'coin', 4) == 3
880    assert tokensAtStep(expl, 'coin', 5) == 5
881    assert tokensAtStep(expl, 'coin', 6) == 4
882    assert tokensAtStep(expl, 'coin', 7) == 2
883    assert tokensAtStep(expl, 'coin', 8) == 3
884    assert tokensAtStep(expl, 'coin', 9) == 1
885
886    expl2 = journal.convertJournal("""\
887S A
888  A gain coin*5
889  n has: coin*5
890  n has: flower*0
891  o right B left
892    e [
893       {
894           <0>luck{<0>luck{gain coin*1}{lose coin*1}; bounce}
895                  {<0>luck{gain coin*2}{lose coin*2}; follow left};
896           ??(coin*5){gain flower*1}{<0>luck{gain flower*1}{}}
897       }
898     ]  # no pre-selected outcomes
899x right%fs
900  # effectively bounces each time so we can just keep taking it
901  n has: coin*7
902  n has: flower*1
903  n at: A
904t right%sf
905  n has: coin*6
906  n has: flower*2
907  n at: A
908t right%ff  # coins are checked in original state
909  n has: coin*4
910  n has: flower*3
911  n at: A
912t right%ssf
913  n has: coin*5
914  n has: flower*3
915  n at: A
916""")
917    assert len(expl2) == 6
918
919    assert tokensAtStep(expl2, 'coin', 0) == 0
920    assert tokensAtStep(expl2, 'flower', 0) == 0
921    assert base.combinedDecisionSet(expl2.getSituation(0).state) == set()
922    assert tokensAtStep(expl2, 'coin', 1) == 5
923    assert tokensAtStep(expl2, 'flower', 1) == 0
924    assert base.combinedDecisionSet(expl2.getSituation(1).state) == {0}
925    assert tokensAtStep(expl2, 'coin', 2) == 7
926    assert tokensAtStep(expl2, 'flower', 2) == 1
927    assert base.combinedDecisionSet(expl2.getSituation(2).state) == {0}
928    assert tokensAtStep(expl2, 'coin', 3) == 6
929    assert tokensAtStep(expl2, 'flower', 3) == 2
930    assert base.combinedDecisionSet(expl2.getSituation(3).state) == {0}
931    assert tokensAtStep(expl2, 'coin', 4) == 4
932    assert tokensAtStep(expl2, 'flower', 4) == 3
933    assert base.combinedDecisionSet(expl2.getSituation(4).state) == {0}
934    assert tokensAtStep(expl2, 'coin', 5) == 5
935    assert tokensAtStep(expl2, 'flower', 5) == 3
936    assert base.combinedDecisionSet(expl2.getSituation(5).state) == {0}
937
938    warnings.filterwarnings("default")

Tests challenge outcome mechanisms.

def test_journal_reversions() -> None:
941def test_journal_reversions() -> None:
942    """
943    Some tests of the state-reversion mechanisms.
944    """
945    warnings.filterwarnings("error")
946    expl = journal.convertJournal("""\
947S A
948  A save slot0
949x right B left
950  A save slot1
951x right C left
952  At gain coin*1
953revert slot0
954  n at: A
955  n has: coin*0
956w
957revert slot1
958  n at: B
959  n has: coin*0
960w
961? destinations
962t right  # graph still unreverted
963  n at: C
964  n has:coin*1
965revert slot0 all-positions primary
966  n at: A
967  n has: coin*1
968w
969revert slot1 c-main-tokens
970  n at: A
971  n has: coin*0
972x up aboveA down
973  n at: aboveA
974revert slot0 all-positions primary graph
975  n at: A
976x up aboveA2 down
977  n at: aboveA2
978""")
979    warnings.filterwarnings("default")
980    assert len(expl) == 15
981    preRevertGraph = expl.getSituation(-3).graph
982    assert len(preRevertGraph) == 4
983    assert preRevertGraph.destinationsFrom('A') == {"right": 1, "up": 3}
984    revertedGraph = expl.getSituation(-2).graph
985    assert len(revertedGraph) == 2
986    assert revertedGraph.destinationsFrom('A') == {"up": 4}

Some tests of the state-reversion mechanisms.

def test_journal_macros() -> None:
 989def test_journal_macros() -> None:
 990    """
 991    Some tests of macros.
 992    """
 993    warnings.filterwarnings("error")
 994    expl = journal.convertJournal("""\
 995= boss who level [
 996  oa fight{who}
 997    gt bossFight
 998    e [
 999      {
1000        <{level}>sum(combat, {who}){
1001          gain defeated{who};
1002          deactivate
1003        }{
1004          goto endings//death
1005        }
1006      }
1007    ]
1008]
1009
1010
1011= failBossRevert who [
1012  ta fight{who}%f
1013  R
1014]
1015
1016= beatBoss who [
1017  ta fight{who}%s
1018]
1019
1020S start  # step 1
1021@ endings//death
1022@ @
1023A save
1024
1025x right bossRoom left  # step 2
1026> boss Boss -1
1027  e gain orb*1
1028  n has: orb*0
1029  n has: coin*0
1030
1031> failBossRevert Boss  # steps 3+4
1032  n at: start
1033  n has: orb*0
1034  n has: coin*0
1035
1036t right  # step 5
1037  n at: bossRoom
1038
1039> failBossRevert Boss  # step 6+7
1040  n at: start
1041  n has: orb*0
1042
1043t right  # step 8
1044  n has: orb*0
1045  n has: coin*0
1046  > beatBoss Boss  # step 9
1047    At gain coin*1
1048  n has: orb*1
1049  n has: coin*1
1050
1051? steps
1052
1053p endings//death  # step 10
1054R  # step 11
1055  n at: start
1056  n has: orb*0
1057  n has: coin*0
1058
1059? steps
1060
1061t right  # step 12
1062  n has: orb*0
1063  n has: coin*0
1064  > beatBoss Boss  # step 13
1065  n has: orb*1
1066  n has: coin*1
1067  n can: defeatedBoss
1068""")
1069    warnings.filterwarnings("default")
1070    assert len(expl) == 14
1071    # Most assertions are in the journal itself here

Some tests of macros.

def test_applyAfterwards() -> None:
1074def test_applyAfterwards() -> None:
1075    """
1076    Tests using 'At' to note non-obvious transition effects.
1077    """
1078    warnings.filterwarnings("error")
1079    expl = journal.convertJournal("""\
1080= get what [
1081  a get
1082    At gain {what}
1083]
1084
1085S start
1086  n has: coin*0
1087  oa grab
1088    e gain coin*1
1089  n has: coin*0
1090  n has: chip*0
1091  ta grab
1092  n has: coin*1
1093  n has: chip*0
1094    At gain chip*1
1095  n has: coin*1
1096  n has: chip*1
1097  ta grab
1098  n has: coin*2
1099  n has: chip*2
1100  > get coin*2
1101  n has: coin*4
1102  ta get
1103  n has: coin*6
1104""")
1105    warnings.filterwarnings("default")
1106    assert expl.tokenCountNow('coin') == 6
1107    assert expl.tokenCountNow('chip') == 2

Tests using 'At' to note non-obvious transition effects.