Monday, April 8, 2019

Zupplements

For my last post, I wanted to analyze some of the more data-oriented aspects of Zork.

Zork is a heavily data-driven game, programmed in a language derivative of LISP, and with a lot of conventions reminiscent of object oriented programming.

The MDL source code is therefore very rich in information, but trying to extract all of the data from the game would not be an interesting exercise. Almost the entire game is defined in the in-line data tables – they define the rooms, their descriptions, the objects, where they can be used, and what commands can be used where. The tables would be overwhelming to browse, and would mostly reveal what I already figured out by solving the game.

But there were a number of mysteries of Zork that I wanted to solve, by way of examining the source code.

First, here’s a list of things that score points:

Enter Cellar 25
Enter East-West passage 5
Enter Kitchen 10
Enter Land of Dead 30
Enter Lower Shaft 10
Enter Strange Passage 10
Enter Treasure Room 25
Enter Top of Well 10
Get bauble 1
Get bills 10
Get blue sphere 10
Get bracelet 5
Get canary 6
Get chalice 10
Get coffin 3
Get coins 10
Get crown 15
Get diamond 10
Get egg 5
Get emerald 5
Get gold card 10
Get grail 2
Get jade figurine 5
Get necklace 9
Get painting 4
Get platinum bar 12
Get portrait 10
Get pot of gold 10
Get red sphere 10
Get ruby 15
Get spices 5
Get stamp 4
Get statue 10
Get torch 14
Get trident 4
Get trunk 15
Get violin 10
Get white sphere 6
Get zorkmid 10
Deposit bauble 1
Deposit bills 15
Deposit blue sphere 5
Deposit bracelet 3
Deposit canary 2
Deposit chalice 10
Deposit coffin 7
Deposit coins 5
Deposit crown 10
Deposit diamond 6
Deposit egg 5
Deposit emerald 10
Deposit gold card 15
Deposit grail 5
Deposit jade figurine 5
Deposit necklace 5
Deposit painting 7
Deposit platinum bar 10
Deposit portrait 5
Deposit pot of gold 10
Deposit red sphere 5
Deposit ruby 8
Deposit spices 5
Deposit stamp 10
Deposit statue 13
Deposit torch 6
Deposit trident 11
Deposit trunk 8
Deposit violin 10
Deposit white sphere 6
Deposit Woods stamp 1
Deposit zorkmid 12

And here are point-scoring events in the endgame:

Enter Crypt 5
Enter Top of Stairs 10
Enter Inside Mirror 15
Enter Dungeon Entrance 15
Enter Carrow Corridor 20
Enter Treasury of Zork 35

One of the mysteries of Zork that I pondered was, how does the combat actually work? There’s clearly a rather in-depth combat engine, offering much more complexity than what seems warranted. There are only three enemies who can be fought in the whole game – not enemy types, three enemies total, and only two of them can be beaten through combat. There’s the troll, a pushover, and the thief, who is very tough, but becomes easier late in the game, as your strength correlates to your score. And the cyclops, who is impossible to beat in combat.

I studied the code, but I don’t know MDL, and so a lot of my conclusions are guesses.

First of all, each fighting-capable entity – you, the troll, the thief, and the cyclops, have a base strength rating.

Your base strength is determined by this formula:
2 + int(5 * ([score]/616) + 0.5)

It translates to this, table-wise:
Score Strength
0-61 2
62-184 3
185-307 4
308-431 5
432-554 6
555-616 7

The troll has a base strength of 2.

The thief has a base strength of 5.

The cyclops has a base strength of 10,000. That’s just a formality, possibly a leftover from an early build. Attacking him doesn’t even invoke any combat functions that would take his strength into account.

HP is determined by base strength, subtracted by 1 for every light wound sustained, and subtracted by 2 for every serious wound sustained. This also affects carrying capacity. Wounds heal over time.

Fighting strength is equivalent to HP remaining. The thief has a special case; if “engrossed,” fighting strength will be lowered to 2 (unless it was already 2 or lower).

Attack and defense are both equal to fighting strength, though the troll gets a -1 defense penalty if attacked with the sword, and the thief gets a -1 defense penalty if attacked with the knife.

When one entity attacks another, a lookup table for a result is selected depending on the attacked target's defense level.

A big thanks to Viila for helping me better understand how the tables work!

If the defense is 1, this table is used:
Attack = 1 Attack = 2 Attack >= 3
Miss Miss Miss
Miss Miss Miss
Miss Miss Stagger
Miss Stagger Stagger
Stagger Stagger Knockout
Stagger Knockout Knockout
Knockout Knockout Kill
Knockout Kill Kill
Kill Kill Kill

Then, one of these columns of possible results is selected, based on the attacker’s attack value. From the nine cells in the column, a result is randomly selected.

So, if your opponent’s defense is 1, and your attack is 1, then these will be the possible results when you attack:


Miss
Miss
Miss
Miss
Stagger
Stagger
Knockout
Knockout
Kill

And if your attack is 3 or higher, then these will be the possible results:

Miss
Miss
Stagger
Stagger
Knockout
Knockout
Kill
Kill
Kill

Also, if the result is “stagger,” then there will be a 25% chance of the staggered victim being disarmed. Unless you broke a mirror! In that case, there’s a 50% chance of disarming if you’re the staggered victim, and a 10% chance of disarming if you’re the attacker. I love little touches like that. That said, I’m not sure if there’s a meaningful difference between staggering and disarming. Either one, in practice, seems to just cost the victim their turn without doing damage.

If the target’s defense is 2, then the table looks like this:

Attack = 1 Attack = 2 Attack = 3 Attack >= 4
Miss Miss Miss Miss
Miss Miss Miss Stagger
Miss Miss Stagger Stagger
Miss Stagger Stagger Light wound
Miss Stagger Light wound Light wound
Stagger Light wound Light wound Light wound
Stagger Light wound Light wound Knockout
Light wound Light wound Knockout Kill
Light wound Knockout Kill Kill

And if the target’s defense is 3 or higher, then this is the result table. You must subtract the target’s defense from the attacker’s attack to get the correct result column. E.g. - if you attack with strength 2 against the thief's 4, the difference is -2, and you select the leftmost column.

A – D <= -2 A – D = -1 A – D = 0 A – D = 1 A – D >= 2
Miss Miss Miss Miss Miss
Miss Miss Miss Miss Stagger
Miss Miss Miss Stagger Stagger
Miss Miss Stagger Stagger Light wound
Miss Stagger Stagger Light wound Light wound
Stagger Stagger Light wound Light wound Light wound
Stagger Light wound Light wound Light wound Light wound
Light wound Light wound Light wound Serious wound Serious wound
Light wound Serious wound Serious wound Serious wound Serious wound

So let’s say you are starting out, and attack the troll with your sword. Your attack value will be 2, since you are at perfect health but have no experience. His base defense is 2, and will be reduced to 1 because of his defense penalty. This lookup table is used:

Attack = 2, Defense 1
Miss
Miss
Miss
Stagger
Stagger
Knockout
Knockout
Kill
Kill

That’s essentially a 3/7 chance to do nothing and a 4/7 chance to win the fight, since staggering is pretty much just a free turn for you, and a knockout gives you a guaranteed kill on the next round. This is pretty good odds.

If you fail, then the troll attacks with 2 strength against your defense of 2.

Attack = 2, Defense 2
Miss
Miss
Miss
Stagger
Stagger
Light wound
Light wound
Light wound
Knockout

Odds are 3/7 he misses and the ball is in your court again, everything back as the combat started, 3/7 that he lightly wounds you, and 1/7 that he knocks you out (and will probably kill you next round).

Assuming he lightly wounds you, your strength becomes 1, and now this becomes your new lookup table when you attack:


Attack = 1, Defense 1
Miss
Miss
Miss
Miss
Stagger
Stagger
Knockout
Knockout
Kill

4/7 odds of missing and reverting control to the troll, and 3/7 odds of winning. Miss, and the troll attacks with 2 against your defense of 1, the same sub-table that you started with.

I did some math, and estimate you have about an 83% chance of beating the troll at the start of the game.

Then I did more math to estimate the odds of beating the thief based on your experience level. This assumes that he gets first strike, that you use the knife, and that he will kill you if you get knocked out. Take this with a grain of salt, because I got really bogged down in the algebra, and I don’t feel like checking it again.


Your level Approximate odds
2 0.25%
3 (62 points) 16%
4 (185 points) 23%
5 (308 points) 46%
6 (432 points) 67%
7 (555 points) 89%

I also did an experiment to see how early in the game I could kill the thief. I got a few early treasures and also robbed the Bank of Zork, giving me a score of 64, and a combat strength of 3. I went to visit the thief, and this happened:

Treasure Room
This is a large room, whose north wall is solid granite.  A number
of discarded bags, which crumble at your touch, are scattered about
on the floor.  There is an exit down and what appears to be a newly
created passage to the east.
There is a silver chalice, intricately engraved, here.
You hear a scream of anguish as you violate the robber's hideaway.
Using passages unknown to you, he rushes to its defense.
The stiletto flashes faster than you can follow, and blood wells
from your leg.


So, the thief gets first strike when you intrude on his lair. He had a combat strength of 5, I had a combat strength of 3, so he was going to get the most favorable combat table possible, and “light wound” was the result. That left me with a combat strength of 2, but I figured if I gave him treasure, his defense would be only 1 – a base value of 2 for being engrossed, and a -1 penalty for being attacked with the knife.

> give egg to thief
The thief is taken aback by your unexpected generosity, but accepts
the jewel-encrusted egg and stops to admire its beauty.
> kill thief with knife
The quickness of your thrust knocks the thief back, stunned.
The thief slowly regains his feet.


Would he accept more treasure?

> give portrait to thief
The thief is taken aback by your unexpected generosity, but accepts
the portrait of J. Pierpont Flathead and stops to admire its beauty.
> kill thief with knife
The thief is disarmed by a subtle feint past his guard.
The robber, somewhat surprised at this turn of events, nimbly
retrieves his stilletto.


Yep! Continuing:

> give bills to thief
The thief is taken aback by your unexpected generosity, but accepts
the stack of zorkmid bills and stops to admire its beauty.
> kill thief with knife
The thief drops to his knees, staggered.
The thief slowly regains his feet.
> give painting to thief
The thief is taken aback by your unexpected generosity, but accepts
the painting and stops to admire its beauty.
> kill thief with knife
The fatal thrust strikes the thief square in the heart:  He dies.
Almost as soon as the thief breathes his last breath, a cloud
of sinister black fog envelops him, and when the fog lifts, the
carcass has disappeared.  His booty remains.

As the thief dies, the power of his magic decreases, and his
treasures reappear:
  A jewel-encrusted egg
  A portrait of J. Pierpont Flathead
  A stack of zorkmid bills
  A painting
>


And so, I killed the thief pretty early in the game. Of course I made it unwinnable in the process, since you need him alive to open the egg for you, but it shows that if you’re willing to exploit his behavior, you don’t need to put this off for very long, and it your life will be easier without him wandering around the dungeon messing things up.

I had also wondered how the thief behaves when wandering, but couldn’t reach as many conclusions as I wanted to. The thief traverses most of the rooms in Zork, even outdoor rooms like the climbable tree in the forest. He ignores the normal passageways, and instead just moves from room to room in a completely predetermined pattern, based on the room’s internal ID numbers. A large number of rooms are marked “sacred,” including the living room, and I believe this flag prevents the thief from entering.

One last mystery was determining how the inventory limit works, and the data tables proved invaluable for this.

Almost all objects, whether takeable or not, have a “size” parameter. Those that don’t effectively have a “size” of zero. You can hold up to 100 units in your inventory.

Some objects also have a “capacity” parameter, and can hold other objects, with a combined size of up to the container’s capacity. Sometimes they need to be “opened” first, such as the grail, which apparently is more like a stein.

Here is a list of container objects:
Size Capacity
Brochure 30 1
Woods Stamp 1 1
Blue book 10 2
Brick 9 2
Green book 10 2
Purple book 10 2
White book 10 2
Glass bottle 0 4
Silver chalice 10 5
Flask 10 5
Grail 10 5
Egg 0 6
Sack 3 15
Red buoy 10 20
Steel box 40 20
Nest 0 20
Coffin 55 35
Wooden barrel 70 100
Balloon basket 70 100
Boat 20 100

And here is a list of non-container objects and their sizes. Not all can be taken.

Bag of coins 15
Bloody axe 25
Blue label 1
Black book 10
Broken balloon 40
Stick 3
Timber 50
Coke bottles 15
Useless lantern 20
Card 1
Crown 10
White sphere 10
Trident 20
Violin 10
Green paper 3
Pump 0
Diamond 0
Guano 20
Jade figurine 10
Lamp 15
Emerald 0
Leaflet 2
Cage 60
Matchbook 2
Newspaper 2
Painting 15
Candles 10
Necklace 10
Eatme cake 10
Blue cake 4
Orange cake 4
Red cake 4
Vitreous slag 10
Leaves 25
Plastic 20
Platinum bar 20
Pot of gold 15
Zorkmid 10
Water 4
Rope 10
Ruby 0
Rusty knife 20
Bracelet 10
Skeleton Keys 10
Shovel 15
Coal 20
Printer paper 70
Flathead stamp 1
Statue 8
Stiletto 10
Sword 30
Tan label 2
Rare spices 8
Torch 20
Trunk of jewels 35
Tube 10
Gunk 6
Wire 1
Wrench 10
Zorkmid bills 10
Portrait 25
Bauble 0
Clockwork canary 0
Gold card 4
Blue sphere 0
Mat 12
Red sphere 0

4 comments:

  1. Interesting. Since one of the Steel Boxes has size 0, does it mean that you could put it inside the Brochure (capacity 1)? :P (Or the egg, or the glass bottle, etc.)

    As for the mystery code ,DEF3B ,DEF3C>>

    The brackets group statements. Eg: <+ 1 1> would return 2. MDL uses prefix notation, so the operator is first element and operands are the following elements.

    is some kind of Zork's own wrapper around , it sets value of a global atom (variable) and adds that atom to global PURE-LIST. What that is I have no idea.

    ,atom is equivalent to , it retrieves the global value of the atom. So, I would expect following code:


    ,foo

    would return 1

    makes an UVECTOR (which I presume is a list datastructure of some kind)

    I presume returns list, sans the first element. "rest of the list"


    So, unpacking the line of code:

    Set the global atom DEF3-RES to value of: ,DEF3B ,DEF3C>

    Unpacking that, the value is an UVECTOR made out of elements: ,DEF3A (global value of DEF3A); the rest of the list ,DEF3A; global value of ,DEF3B; rest of the list DEF3B; global value of ,DEF3C

    So, as far as I can tell, that bit of code duplicates the lists DEF3A and DEF3B, except for the first element and concatenates the original, duplicated lists, and list DEF3C. Why? I have no idea. I presume something then takes the DEF3-RES list and returns an element from it by some logic.

    ReplyDelete
  2. Bummer. Looks like Blogger ate all the brackets. So here's the code analysis with the angle brackets replaced with ()s:


    As for the mystery code (PSETG DEF3-RES (UVECTOR ,DEF3A (REST ,DEF3A) ,DEF3B (REST ,DEF3B) ,DEF3C))

    The brackets group statements. Eg: (+ 1 1) would return 2. MDL uses prefix notation, so the operator is first element and operands are the following elements.

    (PSETG) is some kind of Zork's own wrapper around (SETG), it sets value of a global atom (variable) and adds that atom to global PURE-LIST. What that is I have no idea.

    ,atom is equivalent to (GVAL atom), it retrieves the global value of the atom. So, I would expect following code:

    (PSETG foo 1)
    ,foo

    would return 1

    (UVECTOR [elements]) makes an UVECTOR (which I presume is a list datastructure of some kind)

    (REST [list]) I presume returns list, sans the first element. "rest of the list"


    So, unpacking the line of code:

    Set the global atom DEF3-RES to value of: (UVECTOR ,DEF3A (REST ,DEF3A) ,DEF3B (REST ,DEF3B) ,DEF3C)

    Unpacking that, the value is an UVECTOR made out of elements: ,DEF3A (global value of DEF3A); the rest of the list ,DEF3A; global value of ,DEF3B; rest of the list DEF3B; global value of ,DEF3C

    So, as far as I can tell, that bit of code duplicates the lists DEF3A and DEF3B, except for the first element and concatenates the original, duplicated lists, and list DEF3C. Why? I have no idea. I presume something then takes the DEF3-RES list and returns an element from it by some logic.

    ReplyDelete
    Replies
    1. I goofed a bit on the steel box. There are two steel boxes in the game; the one in the volcano with the crown and the one in the loud room with the violin. The crown box is weightless, but you can't take it with you, so this isn't very useful. I've taken this one out of the list.

      I'm still a bit confused by your explanation of DEF3-RES. Are you saying there are five elements in the list? That would make plenty of sense if there are exactly five, but what does this mean?
      the rest of the list ,DEF3A

      DEF3_RES gets used here:
      (SET TBL (NTH ,DEF3-RES(+ ATT 3)))

      There is a precondition that someone with a defense value of 3 or higher is being attacked. ATT must be a value in between -2 and 2, which means (+ ATT 3) must be a value in between 1 and 5. Therefore, it would make complete sense if DEF3-RES had exactly five elements. It would mean that TBL is set to one of the five elements, based on the ATT value.

      Delete
    2. I think I get it! There are five elements in DEF3-RES, and each element is itself a UVECTOR. This has some implications for how the other lookup tables work as well. For instance, the DEF1_RES table looks like this:
      (UVECTOR ,DEF1 (REST ,DEF1) (REST ,DEF1 2))

      I now understand this to mean a three element UVECTOR, each element itself a UVECTOR, and it would be equivalent to this:
      * DEF1
      * DEF1 minus the first element
      * DEF1 minus the first two elements

      I've updated the combat section of this post with this new information. Thanks!

      Delete