(Release 1.1.1. The PlotEx script is in the public domain.)
PlotEx allows you to build a web of puzzle and event constraints (what needs to happen before what, what requires what tools, and so on). Then it computes all the consequences of the scenario; it shows you what states can and cannot be reached.
(See my long and discursive explanation of this idea.)
To run PlotEx with the included sample scenario, just type
python plotex.py
(If you are a Python 3 user, you'll have to use the alternate version, plotex3.py
. It's easiest to just copy it over plotex.py
, so that the example scripts like enchanter.py
can import it properly.)
To run another scenario, just invoke it the same way:
python enchanter.py
Either way, invoking it without arguments gives the default output. PlotEx will start at the Start
state, run as far as possible, and display all the final states (states from which no more actions are possible). For each final state it will display the actions that got there (and how many actions it took). An asterisk marks states which no other (displayed) state is better than.
Note that if an argument accepts multiple values, you can use either multiple arguments ("--start S1 --start S2") or comma-separated arguments ("--start S1,S2").
Start
. You may give multiple states; PlotEx will assume they are all available to begin with.
genlimit
value in your scenario file.)
The easiest way to do this is to copy the blank scenario example file, and start filling it in. A minimal scenario looks like this:
from plotex import * class Scenario(ScenarioClass): Start = State() # Actions go here # This parses and carries out the command-line options, using the Scenario. shell(Scenario)
A scenario must be in the same directory as plotex.py
. To run it, type
python yourscenario.py
The simplest state looks like State()
. This is a state with no qualities at all. Your Start
state will often look like this, although it doesn't have to.
A more complex state is defined with any number of keyword arguments. Each defines a quality. For example:
State(food=True, coin=3) State(name='fred', spells=['light', 'unlock'])
This defines a boolean quality food
, and a numeric quality coin
.
Qualities can only have a few types:
A quality must have the same type everywhere in your scenario. You can't define one state with State(food=True)
, and another with State(food='cheese')
.
An empty quality (False, 0, '', or []) is equivalent to leaving out the quality entirely. (Except that the type is checked. You could use this to type-define qualities, but there's really no need to.)
Quality names are normally lower-case. (This isn't required, but in my examples I stick to the rule that qualities are lower-case, and states and actions are capitalized. I recommend you do to.)
If a quality names begins with an underscore, it is considered to have an inverted scale. That is, State(coin=2)
is better than State(coin=1)
, which is better than State()
. However, State(_pain=1)
is better than State(_pain=2)
; State()
is better than either of them.
(But you still can't use negative integers. Sorry.)
An action turns a state into another state, by changing its qualities. Some actions check for qualities first. If the check fails, the action does nothing to that state.
Actions can be chained together. In fact, this is usually the way you construct conditional actions. You take a "check for condition" action and a "change some stuff" action, and chain them together. If one step in the chain fails, the whole chained action fails (and does nothing).
These are the standard actions. You can also define your own, but that's Python work, and if you're that sort of person you can pick it up from the source code.
Set(qual=VALUE, qual=VALUE, ...)
Set some qualities. This always succeeds (even if the state already had those qualities at a different level, or the same level). You can set any number of qualities this way.
Note that Set(food=False)
will remove the (boolean) food
quality entirely.
Reset(qual=VALUE, qual=VALUE, ...)
Clear the state out, and then set some qualities. This wipes the state completely; the output is the same no matter what the input state was.
Has(qual=VALUE, qual=VALUE, ...)
Check whether the state has all of the given qualities, or better. Has(coin=1)
will check whether the state has at least one coin. On the other hand, Has(_pain=2)
checks whether the state's pain is at most two. (Because _pain
starts with an underscore, it has an inverted scale.)
If the check succeeds, the state is returned unchanged. (This is only useful because of the Chain
action. See below.) If the state doesn't have all of the qualities, the action fails.
Note that checking Has(flag=False)
is useless, because every state has a flag
value of False or better. However, Has(_flag=False)
does make sense, because _flag
is inverted.
HasAny(qual=VALUE, qual=VALUE, ...)
Like Has()
, but the state only needs to have one of the given qualities.
HasDifferent('qual', 'STR', 'STR', ...)
Checks whether the state's quality has a value different from all of the given strings. If it doesn't, or if the state lacks the quality entirely (or is ''), then the action fails.
The quality must be a string quality. Because of the way Python works, the quality name (and all the strings) must be quoted.
Lose('qual', 'qual', ...)
If the state does not have all of the given qualities, the action fails. Otherwise, it loses them all. (Boolean qualities become False, numeric ones become 0, strings become '', sets become the empty set.)
Here, again, the quality names must be quoted.
Lose(flag)
is equivalent to Chain(Has(flag=True), Set(flag=False))
. But it's shorter.
Once()
Checks whether this action has ever succeeded before. If it never has, it succeeds now. All subsequent times, it fails. (This works by setting an inverted boolean quality.)
You might say, for example, Chain(Once(), Set(blessing=True))
. This action will set the blessing
quality, but only once per game.
Once('_qual')
Same thing, but you specify the quality name. It must start with an underscore, and it must be quoted.
Once(ACTION)
Same thing, except you include the action to once-ify inside the Once()
action. Usually this is simpler than chaining it. E.g., Once(Set(blessing=True))
Increment('qual', LIMIT)
If the given quality is LIMIT or more, this fails. Otherwise, it increases the quality by one. (It must be a numeric quality, of course.) The quality name must be quoted.
If you write Increment('coin', 10)
with no chained conditions, PlotEx will promptly take the action ten times in a row. (Players naturally want all the coins.) If that's your intent, it's easier to do Set(coin=10)
. Otherwise, think about your preconditions.
Decrement('qual', LIMIT)
Same thing, except the quality decreases by one. You can omit the LIMIT, in which case it defaults to zero.
Include('qual', 'STR', 'STR', ...)
The given quality must be a set. This adds the given strings to the set, if they're not there already. This action always succeeds.
The quality name (and all the strings) must be quoted.
Exclude('qual', 'STR', 'STR', ...)
If the set quality does not have all of the given strings, the action fails. If it does, it loses them.
Count('qual', NUMBER)
The given quality must be a set. This succeeds if the set has at least NUMBER strings in it.
Chain(ACTION, ACTION, ACTION...)
This is the one that ties them all together. It tries to perform each of the listed actions, in order, on the state. If any of them fail, the whole chain fails. If they all succeed, the final product is returned.
Some examples from Enchanter:
Chain(Has(rezrov=True), Set(incourtyard=True))
-- If the player has the rezrov spell, set incourtyard
, indicating that the player has access to the courtyard.
Chain(Lose('kulcad'), Set(melbor=True))
-- If the player has the kulcad spell, remove it, and then gain the melbor spell. (You can kulcad the jewelled box to open it and access the contents. But kulcad is a one-shot spell.) If the player doesn't have the kulcad
quality, this action does nothing.
Chain(Has(intower=True), Has(gondar=True), HasAny(vaxum=True, cleesh=True), Set(krill=True))
-- Check that the player is in the tower, that the player has gondar, and that the player has either cleesh or vaxum. If all of those are true, set krill
, indicating that the player has access to Krill.
Choice(ACTION, ACTION, ACTION...)
Try to perform each of the listed actions, in order. As soon as one of them succeeds, return the result. If they all fail, the whole action fails.
A test is essentially a complete invocation of PlotEx, wrapped up with some outcome conditions. When you invoke the test, it runs and verifies the outcome is what you expected.
You can include some tests in your scenario; they're effectively unit tests of your story. If you alter one of your actions, you can re-test to see if the outcomes all still work (or don't work) correctly.
Test(can=ACTION)
Run through the scenario, starting with the Start
state. Then see if the given action succeeds on any of the reachable states. (It doesn't have to succeed on all of them, just at least one.)
For example, you might define Test(can=Has(win=True))
to check whether some winning state is reachable.
The actions have no effect on the scenario states (even actions like Lose
). They're just checked to see whether they succeed or fail.
Test(cannot=ACTION)
Run through the scenario, and check that the action succeeds on no reachable states.
Test(gets='qual')
Run through the scenario, and see if any reachable state has the given quality. (At better than False/0/etc.)
Test(getsnot='qual')
Run through the scenario, and check that no reachable state has the given quality. (All states must lack it; or, equivelantly, have it at False/0/etc.)
Test(includes=ACTION)
Run through the scenario, and check whether any reachable state includes the action in its history. (That is, whether that action was taken in the process of reaching that state.)
Note that if there are several ways to reach a state, PlotEx only records one of them. (It tries to minimize the number of actions.) So this test will be most useful for critical actions.
Test(excludes=ACTION)
Run through the scenario, and check that no reachable state has the action in its history.
Test(start=STATE, ...)
Test(start=[STATE, STATE, ...], ...)
By including start=STATE
in any test, you can control where the test begins its run. The default is Start
. You can also have it start with a bunch of states.
(These may be named states, or ones created on the spot. start=State(food=True)
would start the test in a state with only the food
quality.)
Test(block=ACTION, ...)
Test(block=[ACTION, ACTION, ...], ...)
By including block=ACTION
in any test, you can mark the given action (or list of actions) as out-of-bounds for that test's run.
Note that you can include multiple positive or negative criteria in the test. For example:
Test(gets='coin', includes=KillDragon)
-- Looks for a state that has the coin
quality (greater than 0) and passed through the KillDragon
action.
Test(gets=['food', 'lamp'])
-- Looks for a state that has both of those qualities.
Test(can=[Has('food'=True), Has('lamp'=True)])
-- Another way of doing the same thing.
Test(can=Has(win=True), excludes=AteMushroom)
-- Look at the list of states which have win
; make sure none of them passed through the AteMushroom
action.
Some things I've run into which tripped me up:
Don't create self-cancelling actions. Consider this action:
MakeMoney = Chain(Lose('coin'), Set(coin=True, jewel=True))
This ought to mean that if you have a coin, you also get a jewel. However, writing it this way -- removing and then setting the coin
property in the same action -- confuses the optimizer. PlotEx won't recognize this as a strict improvement action, so you'll get the wrong answer. I think. It'll be less efficient, anyhow. Write it this way instead:
MakeMoney = Chain(Has(coin=True), Set(jewel=True))
Be careful about "recharge" actions. Say you have this scenario:
Start = State(coin=True) EnterShop = Once(Set(inshop=True)) BuyInShop = Chain( Lose('inshop', 'coin'), Set(fireball=True))
This is fine; you start with a coin, you can enter the shop (just once), you buy a fireball with your coin. (Buying and leaving the shop are stuffed into the same action, for simplicity.) Run this, and PlotEx will correctly declare that the final state looks like:
*<fireball _did_entershop> (2): EnterShop, BuyInShopLater, you decide you want the player to be able to buy two fireballs, perhaps after locating a Fez of Awesome Charisma. But you don't want to mess with the existing
fireball
property. So you decide to patch around it, giving the player a sort of coupon that can be redeemed for a new fireball at any time:
Start = State(coin=True) FindFez = Once(Set(fez=True)) EnterShop = Once(Set(inshop=True)) BuyInShop = Chain( Lose('inshop', 'coin'), Set(fireball=True)) BuyInShopExtra = Chain( Has(fez=True), Lose('inshop', 'coin'), Set(fireball=True, coupon=True)) MoreFire = Chain(Lose('coupon'), Set(fireball=True))
Now the final state looks like:
*<fez fireball _did_entershop _did_findfez> (3): EnterShop, BuyInShop, FindFez
There's no trace of the extra fireball! Or the coupon, or the BuyInShopExtra
action. What just happened?
This setup falls prey to PlotEx's desire to only show us final states. If you run with the -a
switch, you'll see that the BuyInShopExtra
action is tried. But the notional player then gets impatient and turns in the coupon (MoreFire
) because there's nothing else to do. This sets fireball
true, but fireball
is already true. So we're back in the state listed above -- but by a less efficient route. PlotEx doesn't bother showing that path, because it's already shown the shorter one.
Note that this is a display problem, not a failure to find the state. If you add an action sequence that requires two fireballs, PlotEx will correctly steer through it, using MoreFire
to reload at the right point. However, when I use PlotEx, I spend a lot of time looking at intermediate states and partial win conditions. So it's worth avoiding these situations, where the player stops halfway and appears to be missing a possibility.
How to avoid? Use a numeric quality, instead of a "got it" flag and a "reload" flag.
Start = State(coin=True) FindFez = Once(Set(fez=True)) EnterShop = Once(Set(inshop=True)) BuyInShop = Chain( Lose('inshop', 'coin'), Increment('fireball')) BuyInShopExtra = Chain( Has(fez=True), Lose('inshop', 'coin'), Increment('fireball'), Increment('fireball'))
(Yeah, I need to add a step-by argument to the Increment
action class.) This produces two final outcomes, a much clearer rendering of the situation:
<fez fireball=1 _did_entershop _did_findfez> (3): EnterShop, BuyInShop, FindFez *<fez fireball=2 _did_entershop _did_findfez> (3): EnterShop, FindFez, BuyInShopExtra
Last updated June 3, 2012.