Wordsmyth's Corner

Large-Scale System Design

by Linda Naughton ("Faraday")

Part III - Code

MAIN ARTICLE

So as you may have noticed, it's taken me awhile to write this one. Partly it's because I've been busy, but mostly it's because it's hard to write about code without getting bogged down by the code itself. This article isn't supposed to be about how to do an @switch, or how to use nested iter() calls. It's about the bigger picture - getting your commands and functions to work together in a coherent system. But at the end of the day, the system is really just a collection of individual commands with @switches and iters(). It's a tough balance, but hopefully I walked the edge well enough for someone to get something out of it.

A couple general notes about the code snippets contained here.


  • They're just snippets. If I included all the code, this article would be huge. I'll include the complete decompiles in one of the later articles.
  • I try to use the "pretty" style of MUSH formatting so you can better see what they're doing, but it's a little tough with HTML formatting.
  • They're all for PennMUSH, because that's what I do 99% of my coding in. If you use some other codebase, don't despair. I'm going to try to focus less on syntax and more on what the code is trying to do and why. You should be able to use the same the principles for any codebase.

Getting Started
Sitting down to code a large-scale system can be pretty intimidating. I usually like to start off by making a task list. It helps you organize things in your mind so you don't forget anything. It also gives you a sense of accomplishment as you complete each task. And - as an added bonus - it gives you a starting outline if you're going to write an article about coding a big system. :)

So here's my task list for the skills/chargen system:

Chargen - Player setup
Chargen - Dig rooms
Chargen - Physical profile commands
Chargen - Demographic commands
Skills - Utility functions
Chargen - Skill/Attribute commands
Skills - Unopposed rolls
Skills - Opposed rolls

There's no particular rhyme or reason to the order, except that the skills utility functions are necessary for the Chargen skill/attribute commands (which we know from our Design work). I could just as easily have done all the skill stuff first.

Chargen - Player Setup
It's important to lock character attributes to prevent players from fiddling with them. You don't want Saywer to changing his stats to make himself into the World's Most Handsome Redneck, and you also don't want Cousin Mary messing up your +finger code by putting an extra comma where it doesn't belong.

There are a number of ways to protect your attributes. I believe Tiny (and maybe even Penn, now) lets you specify locked attributes in the server config, or in a global @-command. But I've always just locked them using the atrlock() function and set them Ômortal_dark' so that normal players can't see them.

You're going to need to do this for a lot of attributes, not just the skills/chargen-related ones. So one of the first things I always code on my game is a Player Setup object. It sits in the welcome room (usually #0) and listens for new players to connect. When they do, it sets up the necessary attributes for all the coded systems. You could have each coded system try to set up its own attributes, but this can be tough. I prefer to centralize it and make sure it's done the instant the character is created. Here's what the setup object does:

@LISTEN Player Setup Object=* has connected.
@AHEAR Player Setup Object=
@switch 0=hasattr(%#,status),
{
@fo %#=@chan/on newbie;
@fo %#=@chan/on public;
@fo #3=@cemit newbie= %N has just created a character! Welcome to [mudname()]!;
@set %#=unregistered;
@fo %#=@lock me=me;
think
[iter(lattr(me/hattr_*),
[setq(0,after(##,_))]
[set(%#,%q0:[u(##,%#)])]
[set(%#/%q0,mortal_dark)]
[atrlock(%#/%q0,on)]
)]
[iter(lattr(me/vattr_*),
[setq(0,after(##,_))]
[set(%#,%q0:[u(##,%#)])]
)]
}

There's a bunch of miscellaneous stuff in there (joining channels, announcing the new player, etc.), but the important part of the code is at the end. It looks for two sets of attributes on the setup object: HATTR_* and VATTR_*. HATTR is for hidden attributes - ones that you don't want the player to be able to view or change. VATTR is for visible attributes - these can be viewed and changed by the player. 99% of the attributes will be hidden, but the VATTR code is there for flexibility.

Some of the settings related to skills and chargen (this is not a complete list):

&HATTR_HAIR Player Setup Object=
&HATTR_SKILLS Player Setup Object=
&HATTR_SKILL_POINTS Player Setup Object=0
&HATTR_WEIGHT Player Setup Object=
&VATTR_ADESCRIBE Player Setup Object=think \%N looked at you.

Notice that the skill points defaults to 0. This may seem weird (why don't they get any skill points?) but it is intentional and will be explained when we get to skills.

So what does the object do to set up a hidden attribute?

1. Set the attribute on the player to match the default value on the setup object. This may be blank if the attribute has no default value, or it may be a function that does additional processing (with the player's DB# passed as argument %0).
2. Set the attribute mortal_dark.
3. Lock the attribute using the atrlock() command.

Visible attributes do only the first step.

NOTE: Blank attributes won't work if your server's behavior is to wipe an empty attribute. If you use the default server config on Penn you'll be OK. On Tiny you may have to change the config or do this differently.

Chargen - Dig rooms
The next task is to dig the rooms. In the Design phase we talked about 5 rooms, and digging them is easy enough. There isn't any code on the rooms themselves, so we really only need to worry about the descriptions and the exits.

I like to put the chargen instructions into the room descriptions. Sure, you could put in all kinds of fancy help files, or point them to the NEWS files or something, but I've always found that people like to type as few commands as possible. The room descs are shown to them automatically. The only trick is keeping the descriptions concise. You don't want to spam poor Joe Newbie with two screens of chargen information when he walks into a room. Sometimes you need auxiliary help files.

The exits are a little trickier. First, we need to make sure that existing players don't accidentally or purposefully wander into chargen to redo their stats. This can be done by locking the initial exit to only those characters with the UNREGISTERED flag (which we set on all new players with our player setup object).

@lock/Basic Chargen=ISOK/1
&ISOK Chargen=
or(isstaff(%#),
and(orflags(%#,?),not(match(%N,guest*))
)

You probably noticed that the exit is checking for more than just the unregistered flag. I added the isstaff() call because the first time I tried to go through the exit it wouldn't let me in :). I need to be able to go in and out of Chargen for test purposes, and I don't want the unregistered flag because it'll keep me from using other commands.

At some point, I also noticed that guests could get into Chagen, which I really didn't want. I locked the exit to prevent anyone with a name of "Guest*" from entering. A better solution would have been to lock it against anyone with the Guest @power. Then it doesn't rely on the names, and you could name your guests after constellations, or ER characters, or whatever else strikes your fancy. I didn't think of that until I wrote this because all my guests are boringly named Guest-1, Guest-2, etc. It's amazing what you think of when you have to explain your code to somebody else :).

So what about the interior exits? Remember that in the design phase, we said we wouldn't let the player into the next room until they've set everything in the current room. Since I know that there are multiple rooms with multiple exits, I decide to use an exit parent.

But how do we make the exit parent generic? Well, most of the rooms are going to be setting up attributes on the player. If they forget to set their character's hair color, the HAIR attribute will be empty. If they don't set any skills, the SKILLS attribute will be empty. Our chargen exit parent can check for that. Just give it a list of which attributes need to be set in this room (stored on a REQUIRED_ATTRS attribute) and it will tell you which ones (if any) are missing.

@lock/Basic Chargen Exit Parent=ISDONE/1
&ISDONE Chargen Exit Parent=
[and(not(t(iter(v(required_attrs),eq(strlen(xget(%#,##)),0),,))),
u(other_checks,%#)
)]

@FAILURE Chargen Exit Parent=
ansi(hr,You have not set everything yet.
[squish(iter(v(required_attrs),
switch(strlen(xget(%#,##)),
0,
%R- Missing ##)
)
)]
%R[u(other_errors,%#)]
)

In retrospect, I probably made this code more complicated than it needed to be by trying to make it "efficient" (with the whole and/not/true thing at the beginning). That's weird, because I nearly always favor clarity over efficiency. You write the code once, but people are going to have to maintain it for (hopefully) years and years. If nobody can figure out what you did (especially if you yourself can't figure out what you did), then you haven't done a good job. I think this command probably falls into that category :).

Anyway, all this is doing is looping through all the required attributes and telling you (in a round-about fashion) if any are empty. It won't let the player through the exit until they have set everything.

The "other_checks" stuff is something I had to add later. I'll come back to it when I get to the demographics commands.

Notice that none of the exits actually change anything on the player. Players can go freely back and forth (as long as they set up the necessary attributes) without resetting themselves. It also means that the exits - and the rooms - don't need to be set wizard. It's always good to minimize the powers you give things. You could set them royal or see_all, depending on your preference.

Lastly, we can't forget to create the backwards exits, so players can go back and fix things. These are unlocked (something we decided in the design phase) so they don't need the exit parent.

Chargen - Physical Profile Commands

The physical profile room is where players will set things like their height, weight, age, etc. These commands are very straightforward since (if you'll recall from the design phase) we're not error-checking this stuff. If they really want to put in "Purple Dreadlocks" as their hair color, the code isn't going to stop them. Ditto if they want to be Andre the Giant or Mini-Me. I'm not going to try to prevent that in code; that's something the approval process can deal with. (A character might legitimately have purple dreadlocks - who am I to judge?)

Remember: all of these commands go on objects that are placed in the chargen rooms. You'll want to set them dark so players don't see them. They set things, so they need to be set wizard. And don't forget to @lock them so Bozo the Prankster can't pick them up and walk off with them!

So let's take the hair color command as an example:

&CMD-+HAIR Profile Commands=$+hair *:think
pemit(%#,ansi(hg,You set your hair color to: %0.))
[set(%#,hair:[capstr(%0)])]

Pretty easy, right? Remember that we don't have to worry about locking the attribute or anything because that's already taken care of by our player setup object.

Random side note: I'm a function coder. I use @commands only when I absolutely have to (for things that have no functions, like @force and @wait). For me, it's easier to read. It also guarantees that things are done in order, in one queue cycle. But I know there are other points of view on this and I don't want to touch off a holy war or anything. It's just my preference, so brace yourself for seeing it a lot in this article.

Also, I tend to use color pretty consistently. Green for success messages, Red for failure messages, other colors for random notifications. Doesn't help the color blind folks, but for most players I think it's beneficial.

Chargen - Demographics Commands

The demographics commands are things like home colony, faction, rank, etc. These are very similar to the physical profile commands, but are a little harder because they require some error-checking.

Let's start with the faction command. We need to make sure the faction they typed is a valid one.

&CMD-+FACTION Demographic Commands=$+faction *:think pemit(%#,
switch(0,
t(match(civilian military,%0)),
ansi(hr,That is not a valid faction. Use civilian or military.),
ansi(hg,You set your faction to %0.)
[set(%#,rank:)]
[set(%#,faction:[trim(capstr(%0))])]
))

Notice that here I hard-coded the possibilities ("civilian" and "military") rather than making a FACTION_LIST attribute somewhere. Why did I do that? Well, at the time I thought that this would be the only command that would need that list. Later, when I coded up a +census/faction command, I discovered I was wrong. Moral of the story: If it's a list, put it on a data object somewhere.

The +faction command also resets the rank attribute. This was something I noticed during my initial testing. If someone started out as military, set their rank, and then changed to civilian - their rank still hung around. So I decided to reset the rank any time the faction changed. This highlights the importance of thorough testing, and of making sure your commands play nice with each other.

Sidebar - Random Coding Tips
I know I promised I wouldn't get bogged down in the details of the code, but there are a few things I just have to mention. Skip to the next section if you don't care about the gory details:

1. Matching
When I match the list, I use t(match(list,item)) rather than just match(list,item). This is a common, subtle bug. match(), by itself, will return the POSITION of the item on the list. Matching against Ô1' won't always work. For example:

switch(1,
match(red blue green,blue),
Blue IS on the list.,
Blue is NOT on the list.)

This will tell you that blue is NOT on the list, which is clearly wrong. This is because the match returns "2" (since blue is the 2nd item on the list). The really sinister part is that it WILL work for the first item on the list. So if you tested with red instead of blue, you might be fooled into thinking your command is OK.

The way around this is to use the t() function to turn the result into a true/false value. 0 will still be "0", but 1-2-3-etc. will become "1".

Another way around this is to reverse the switch and check against 0:

switch(0,
match(red blue green,blue),
Blue is NOT on the list.,
Blue IS on the list.)

I tend to always throw t() around my match calls even when it's not necessary. I've been burned too many times, so now it's just a habit.

2. Trim and Capstr
The two functions trim() and capstr() are your friend when you are dealing with player input. Most players (myself included) tend to be lazy typists. Why type "+hair Blonde" when you can type "+hair blonde"? Yet most of the time we like to see things capitalized when we display them.

You could put the capstr() in your display function (on +sheet or whatever), but then you need to remember to capitalize it in every command that uses it. If you put the capstr() on your SET function, then you know it will already be capitalized whenever you go to display it.

The only caveat here is that capstr will only capitalize the first letter. If you have a multi-word string (like underwater_basketweaving), it will only get the first word. I made a global utility function called CAPSTR_ALL to deal with this scenario, but I forget to use it sometimes.

Trim() is a little stranger. For some bizarre reason that I don't pretend to understand, some people like to put spaces around things. Rather than typing "+hair blonde", they will type "+hair *SPACE**SPACE*blonde*SPACE*". I dunno - maybe their space key gets stuck or something.

Not only can this wreck havoc when you try to line up fields prettily on your +sheet, it can really mess up the code. Many MUSH functions don't deal well with extra whitespace. Fortunately, the trim() function will chop off any extra spaces at the front and back, and leave you a nice, pristine "blonde" string to work with.

3. Switch structure
Most of my commands look something like this:

think
pemit(%#,
switch(1,
error case 1,
error message 1,
error case 2,
error message 2,
...etc. for other errors...
[good message][good processing]
)
)

There are variations, of course (like switching off 0 instead of 1), but that's the default template. Notice that there's only one pemit() and one switch(). This usually makes the code very readable (and efficient, though that's a lesser concern). It doesn't work for all commands, but it's a good starting point.

Back to Demographics

Anyway, enough of my random coding tips. Back to our regularly scheduled chargen system. Next let's look at the rank command.

&CMD-+RANK Demographic Commands=$+rank *:think
pemit(%#,
switch(0,
match(xget(%#,faction),military),
ansi(hr,Ranks are only for the military.),
u(#50/fun_is_valid_rank,%0),
ansi(hr,That is not a valid rank. See +ranks.),
ansi(hg,You set your rank to %0.)
[set(%#,rank:[capstr_all(trim(%0))])]
)
)

The first thing this command checks is faction - only military folks will have ranks. This is mostly theme-specific. In real life, civilians can have ranks too (police lieutenant, fire chief, etc.) so you might need to code this a little differently for other games.

The next thing +rank does is make sure the rank is valid. We don't want them entering "Grand Moff" or "High Priestess of the Universe" or anything like that. This may seem a little inconsistent with the physical profile commands - why error-check rank and not hair color? Partly it's just on general principle - there's a lot more flexibility in hair color than in military ranks. But it's also because I imagine that rank might be used in other code (like a uniform system or a housing system). If I had done the use cases, I might know for sure. (Bad Faraday!) But for now I'll just assume that rank May Be Important Later, and make sure the player enters a sensible one.

So, I'm going to need a list of ranks. I also realize that the PLAYER is going to need to see the list of ranks - especially because this is Battlestar Galactica and their rank system is a little goofy. I could put the player's list in a NEWS file or something, but I like to avoid duplication whenever possible. So I decide to code a +ranks command.

Since the ranks list will be used by both the global +ranks command and the chargen +rank setting command, I put it on a centralized data object. I'm a big fan of organization, but somehow I always end up with one "Miscellaneous Data/Functions" object per MUSH. It's a dumping ground for random bits of data that are used by multiple systems and don't really belong anywhere. Although it offends my sensibilities somewhat, I think it's better than having a dozen different storage objects (Rank Data, Ship Data, Faction Data, Job Data, etc.) with one or two attributes each.

To go along with my rank list, I also created a FUN_IS_VALID_RANK function on my misc. data/functions object. This checks the list and tells me if what the player entered was a valid rank. Mostly this is for future protection. If I later decide to change the rank list to use pipe delimiters instead of space delimiters, I don't want to have to scour the code looking for the places that use the rank list. I only have to change one function. It also lets you make the rank code more complicated, like by storing enlisted ranks in a separate list from officer ranks, or navy ranks from marine ranks. The valid_rank function can check ALL the lists and tell you if the rank is in any of them.

Note that the +rank function points the players to +ranks if they enter an invalid rank. This is a handy thing to do. Don't just tell the player that something's wrong - tell them how to fix it. Print out the list of valid values, or point them to a place where they can find it. Your players will appreciate it.

Fixing The Exit Parent

The other thing I discovered when I tested my rank code is that the exit parent didn't really work quite right for it. The exit parent can't deal with an attribute that is required for some characters (military) and forbidden for others (civilians). What to do?

Well, if you remember the exit parent code - there was a bit in there about "other_checks". This is something I added to handle the rank conundrum. It also turned out to be useful for skills and attributes later. The exit parent will call a function "other_checks" to see if there's anything beyond the REQUIRED_ATTRS that you need to check for. If "other_checks" returns a 0, the exit parent will then call "other_errors" to figure out what error message(s) to tell the player. For example, the demographics room exit has the following checks/errors:

&OTHER_CHECKS Next =
switch(xget(%0,faction),
military,
gt(strlen(xget(%0,rank)),0),
1)
&OTHER_ERRORS Next =
switch(1,
and(match(xget(%0,faction),military),
eq(strlen(xget(%0,rank)),0)),
- Rank must be set for military characters.)

That takes care of the rank command.

Side note: I believe strongly in consistent naming conventions. I tend to use names like CMD-+roll, FUN_IS_VALID_SKILL for commands and functions. So why didn't I name those things FUN_OTHER_CHECKS and FUN_OTHER_ERRORS? I was actually wondering that myself. Nobody's perfect :)

Summary

The other demographics commands are pretty similar to rank and faction, so I won't go into them in detail. The only thing I'll mention once again is the importance of planning and doing use cases for your ENTIRE GAME, not just one system. I had to re-code the +position command (and all the stuff associated with it) because of something required by the census and roster systems. It was a real pain. If I had thought about the census/roster systems ahead of time, I could have coded it right in the first place and saved myself a lot of headaches.

This installment is already getting kinda long, so I'll save the skills/attribute stuff for next time. Until then - happy coding!