klembot / chapbook

A simple, readable story format for Twine 2.
https://klembot.github.io/chapbook
MIT License
80 stars 25 forks source link

Allow access of undefined variables. #11

Open klicktock opened 5 years ago

klicktock commented 5 years ago

Super excited to see the release of Chapbook. I've jumped straight into developing an interactive book using Twee and Chapbook.

The biggest stand-out issue for me is that currently the use of undefined variables (booleans in particular) is not allowed. We had a lively debate on this issue today on the Twine discord.

Personally, I would like to see [if playerHasMagicMirror] return false even if the variable has not been defined. I have been planning to have a lot of variables tracking unique objects (playerHasWhiteKey) unique states (playerHasKilledTheKing), and also things like player traits (playerIsCannibal, playerIsIntelligent). These could be referenced many passages apart from one another.

Not nearly as important, but if possible I would also like to see integers return 0 and strings return '' if interrogated before they have been defined.

The design goal I am targeting is "Forgiving of Mistakes". If I must declare variables at the top of the file each time I use them (I'm writing a larger book with a lot of variables) there'll be a large block of variables all setting "false false false". Additionally, at the moment, accessing an undefined variable crashes out the story entirely.

klembot commented 5 years ago

This is an interesting issue to raise, and I'm hoping we can have a good discussion here. I right now do not think this is the right thing to do in Chapbook, but I understand why you'd want it.

There is an general design constraint here that Chapbook is a pretty thin layer over JavaScript. Unlike Harlowe which is more of a full-fledged programming environment, Chapbook variable assignments and conditionals are mostly using the browser's native JavaScript interpreter to evaluate them. (I don't know SugarCube's internals well enough to say how it works in this regard.) The current best practice in JS is to not allow variables to be used before they are defined, and so that is what you are currently bumping up against.

It would also be very difficult in Chapbook's current state to be able to analyze how you are using variables and guess whether you would like them to be initialized to 0, the empty string, or false.

That said... I would argue that there is a tradeoff here. It is true that it's a hassle to have to initialize a lot of variables to false, but it avoids a very annoying kind of error, where you write:

PlayerHasMagicMirror: true
--
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur
pharetra est et leo vehicula, in ullamcorper nisi tincidunt. Morbi
porttitor ut dolor eget finibus. Fusce dignissim, ex id consectetur
dignissim, ante massa semper neque, sed aliquam felis enim sed felis.
Suspendisse placerat convallis euismod. Duis posuere libero
dignissim interdum aliquam. Sed id arcu et magna sollicitudin feugiat.
Sed pellentesque ut magna venenatis ornare. Fusce pretium libero
in arcu lobortis congue. Pellentesque rutrum, est vestibulum mollis
dapibus, purus est consequat leo, non elementum felis libero interdum
felis. Donec commodo sit amet lectus id mollis. Pellentesque habitant
morbi tristique senectus et netus et malesuada fames ac turpis egestas.
Morbi ut efficitur neque.

[if playerHasMagicMirror]
The mirror begins to speak...

e.g. PlayerHasMagicMirror and playerHasMagicMirror are two different variables, and the passage displays incorrectly without any warning or explanation. I know there's an argument here that variable names should be case-insensitive... but that is a whole other kettle of fish. You can also imagine a typo like playerhasMagcMirror and so on. At least right now, you get an error message.

So I have a couple thoughts, none of which are what you are asking for :).

klicktock commented 5 years ago

Having to define every variable at the top of a twee file is quite debilitating to crafting a story, particularly a non-linear one. Let's say a passage can be accessed from multiple locations. The first time you visit it, I want to have verbose text. The second and subsequent times I want to have an abbreviation. It makes the most sense to self-contain this variable to the passage in which it's been used. "haveBeenHereBefore" let's call it. That's something that I would use quite frequently.

Or a passage has a list of 10 locations. Each of these locations I want the player to only access once. Again this would require defining those variables at the top of the twee file, but really - the passage is where they logically belong.

Being able to define those variables in the most logical place - the passage that will use it - makes the most sense to me.

klicktock commented 5 years ago

A solution occurred to me. What if the variable initialization can be performed in any passage, but is "run once" at the start of a compiled story as opposed to the first time you visit the passage?

On Sun, 1 Sep 2019 at 15:46, Matthew Hall matt@klicktock.com wrote:

Having to define every variable at the top of a twee file is quite debilitating to crafting a story, particularly a non-linear one. Let's say a passage can be accessed from multiple locations. The first time you visit it, I want to have verbose text. The second and subsequent times I want to have an abbreviation. It makes the most sense to self-contain this variable to the passage in which it's been used. "haveBeenHereBefore" let's call it. That's something that I would use quite frequently.

Or a passage has a list of 10 locations. Each of these locations I want the player to only access once. Again this would require defining those variables at the top of the twee file, but really - the passage is where they logically belong.

Being able to define those variables in the most logical place - the passage that will use it - makes the most sense to me.

-- -Matt

klembot commented 5 years ago

It's an interesting idea, but how would Chapbook know which initialization to run? Or the idea you're saying is that there would be a separate initialization syntax that could be anywhere in a story?

klicktock commented 5 years ago

Just going back over this, even your own example here is incorrect: https://klembot.github.io/chapbook/guide/state/conditional-display.html

Currently your example would crash the game if they haven't encountered the key previously. I'll quote you:

Only if the player had found the key earlier do they see “You could try unlocking it with the key you found,” but in all cases they see “You consider turning back.” Which is not the case.

On the forums it was suggested by Luna City Hobo:

In JS, to check falsy-ness, you can go: if (!!questionableVar) doSomething() ! will coerce the variable to Boolean, negating it, then the second ! will negate that. So you don’t have to do multiple checks for undefined, null, etc. If truthy, it will be true, if falsey it will return false

klembot commented 5 years ago

Fair enough. I should have written in terms of variables being set, not events in the story. Did you have any thoughts on my other question, though?

klicktock commented 5 years ago

Sorry for the delay. With your current format you have a section at the start for initialization of variables.. before the "--" My first thought was perhaps to flagging these in some way: [declare] hasKey=false Any variables flagged this way would be initialised at on story restart. All others would be set at the moment the passage is entered. But I wasn't sure it would make for easy reading for beginners and is not a great solution to the heart of the problem. Things like health, etc variables that aren't booleans, it totally makes sense to crash if you encounter them when they haven't been declared. But crashing out on an undeclared boolean feels ... wrong. I almost wondered if, the moment a variable is created anywhere, in any passage, that they should be initialised to false, zero, '' on story restart. You may pick up false errors with misspelled variables but those should be easy to diagnose in backstage.

klicktock commented 5 years ago

Another idea would be to have a ::Declare special case passage. You could have more than one of these, if you're building a story from multiple files.

greyelf commented 5 years ago

One of the potential downsides of the "Forgiving of Mistakes" design methodology is that it can result in the existence of those mistakes being hidden from the Author / Developer. eg. a. they misspell an existing Boolean variable (currently set to false). b. the code referencing the misspent variable behaves like it should when basic testing is applied. c. real variable is later changed to true. d. code that worked correctly before now behaves in an unexpected way.

Personally I don't see what benefit is gained by not teaching a novice developer to initialise variables (sometime) before they are used, which is the common practice in the majority of programming languages.

everythingability commented 4 years ago

I've just hit this myself... and I take your point about wanting it to a thin a layer as possible over JS.

What I'd hoped for was to me able to interrogate for a variables existence and then use that, so something like:

money: money + 1 | 0

so if money hasn't been defined yet, it uses the or, sort of like a try.. catch scenario.

aleclarson commented 6 months ago

How about a special try function that gets compiled?

In this example, if the money variable was never set, it will remain unset.

money: try(money + 1)

You can provide a fallback value:

money: try(money, 0) + 1

It works anywhere there's an expression:

[if try(playerHasMagicMirror)]
You have a magic mirror of level {try(magicMirrorLevel)}.

If playerHasMagicMirror isn't set, the try expression returns undefined.

The examples above compile to the following:

// money: try(money + 1)
try {
  money = money + 1;
} catch {}

// money: try(money, 0) + 1
money = (typeof money !== 'undefined' ? money : 0) + 1;

// [if try(playerHasMagicMirror)]
// You have a magic mirror of level {try(magicMirrorLevel, 1)}.
if ((typeof playerHasMagicMirror !== 'undefined' ? playerHasMagicMirror : undefined)) {
  write(`You have a magic mirror of level ${typeof magicMirrorLevel !== 'undefined' ? magicMirrorLevel : 1}.`);
}

Note how a try-catch block is used when try() is called with an expression instead of a single variable name. This might require a utility function to be added to the runtime:

function chapbookTry(expr: () => any) {
  try { return expr() } catch {}
}

// [if try(playerHasMagicMirror || magicMirrorIsNearby)]
// The ornate mirror's surface shimmered and swirled, the glass rippling like water.
if (chapbookTry(() => playerHasMagicMirror || magicMirrorIsNearby)) {
  write(`The ornate mirror's surface shimmered and swirled, the glass rippling like water.`);
}
aleclarson commented 6 months ago

Alternatively, maybe compiling the ?? operator would be better.

money: (money ?? 0) + 1

…this becomes…

money = (typeof money !== 'undefined' ? money : 0) + 1

Whether money is undeclared or is set to undefined, the money defaults to zero here.

klembot commented 6 months ago

@aleclarson I'm against the idea of redefining what ?? does because, in my opinion, it'll confuse people who already have knowledge of JS. Less sure about the try() function you propose, but right now using Chapbook doesn't require people to learn about functions--though they are there if you want to use them. I haven't thought about it a ton, but my opinion leans more towards making initializing lots of variables faster, something like this (but I haven't really thought through the ramifications/warts where this syntax could be problematic):

money, wounds, lostCats: 0
aleclarson commented 6 months ago

I'm against the idea of redefining what ?? does because, in my opinion, it'll confuse people who already have knowledge of JS.

Agreed in principle, but I don't think this is a disagreeable change to ?? behavior. Allow me to explain!

For example, Babel compiles money ?? 0 to this:

money !== null && money !== void 0 ? money : 0

All we need to do is insert a typeof money !== 'undefined' check before that condition, and the behavior of ?? is effectively the same, except undeclared variable access isn't unsafe.

typeof money !== 'undefined' && money !== null ? money : 0

(Note that I removed the money !== void 0 because the typeof check covers that case already).

greyelf commented 6 months ago

While the recent suggestions may help with the issue of assigning a default value to a variable when it is referenced before it has been initialised, they don't help with identifying when an Author has misspelt the name of a previously initialised variable.

eg. take the previous supplied money: (money ?? 0) + 1 example for instance.

That syntax won't help the Author work out what's wrong if they accidently type money: (mnoey ?? 0) + 1 instead, because no error will occur.

And as there is no variable-name auto-completion feature, misspelling such is a more likely occurrence, especially in a project with a lot of variables.