ID:34638
 

A BYONDscape Classic! With cuss words on loan from the classic crime drama Johnny Dangerously!

Dream Tutor: The Other Mystery Meat

by Lummox JR

Anyone who's hosted their creations so far, or played any live games at all, has probably encountered a few of life's lesser creatures: the common troll, what I sometimes call subspanizens. They'll cause trouble anywhere they can, and BYOND has its share because it's a gaming community. (It has more than its share for other reasons, which for tact's sake I won't cover here. We all know who they are.)

Troll control is an area in which I have a lot of experience. I started seriously chatting online in 1997 when I started a site for a particular game, and linked to a chat on a public server. About a year later I opened my own chat, with unique abuse-prevention features all its own. Many of those don't apply well to BYOND games, but some apply very well indeed. That's because the worst of the common troll's (Subspana typicus) problems is scrolling, or what is more commonly known as "spam" (the non e-mail kind).

The Obvious Suspects

Some trolls like to cause trouble by saying something very very very (very) long that wraps around so many times it makes all the other text--including game messages and other important stuff--slide right off the top of the text window. The first line of defense is to limit what people can say to a certain length.

mob
verb/Say(msg as text)
set src = usr
if(length(msg) > 400) msg = copytext(msg, 1, 401)
world << "<B>[usr.name]:</B> [msg]"

Time to explain what I'm doing there. copytext() is a proc in DM that can cut a string down to size. The first argument is the string you want to trim, the second is the index of where to start (1 is the first character). If you only use two arguments, you get everything from that point all the way to the end. If you use three arguments, as in copytext(string,start,end), you get everything up to the end index. Make sense? Too bad, we're moving on.

In the example above, I want the first part of the string (starting at index 1) up to and including the 400th character. I want to stop just before I hit the 401st, since that and everything after it is extra.

What if you want to automatically boot someone for exceeding the length limit? That can be done too:

mob
verb/Say(msg as text)
set src = usr
if(length(msg) > 250)
world << "<I>[usr.name] was booted for spam.</I>"
del(usr)
return
world << "<B>[usr.name]:</B> [msg]"

Okay, so that's simple enough. The next thing you need to watch out for is pesky HTML. BYOND supports a little HTML, and you probably don't want people using it. If they could use it, they'd throw in all kinds of stupid stuff like huge fonts, unreadable colored text, or who knows what else. If they didn't close their HTML tags you'd be stuck with everything in italics or something; boy is that ever fun. Some people could cause a problem by accident, so it's a good idea to strip out HTML.

DM has a handy proc for this: html_encode(). It takes a message with HTML tags and translates it so it looks like literal text. < is changed to &lt; so in HTML form it looks like < as typed, and so on.

mob
verb/Say(msg as text)
set src = usr
if(length(msg) > 400) msg = copytext(msg, 1, 401)
world << "<B>[usr.name]:</B> [html_encode(msg)]"

This verb's starting to get complicated, isn't it?

But wait, you say, what about the idiot who keeps pasting in a message every half second? That's spamming too, isn't it? Yes, it is, but we're gonna need a bigger arsenal to deal with it.

Spam On A Timer

One of the very worst kinds of spam attacks are ones that happen quickly. It'd be nice to deal with that, wouldn't it? Well, let's try something basic: Say 4 messages in 4 seconds and you're booted.

mob
var/spamnumber = 0 // number of messages in last 4 seconds
var/spammax = 4 // 4 messages max
var/spamtime = 40 // in 40 ticks (4 seconds)

verb/Say(msg as text)
set src=usr
if(length(msg) > 400) msg = copytext(msg, 1, 401)
if(++usr.spamnumber >= usr.spammax)
world << "<I>[usr.name] was booted for spam.</I>"
del(usr)
return
spawn(spamtime) // in 4 seconds (default)
--usr.spamnumber // the oldest message expires
world << "<B>[usr.name]:</B> [html_encode(msg)]"

What does all this do? To start, we have a counter set to 0. Every time the player says anything, this counter goes up; it goes back down in 4 seconds. If the counter ever reaches as high as 4, meaning 4 things were said in the last 4 seconds, the spam detector triggers.

As a rudimentary defense against spam, so far this is pretty cool. You don't need any admin controls yet at all; an offending player is deleted automatically without any intervention--which is good if the admin or host is busy.

Let's try something slightly more complicated: Say I want to detect 4 messages in 4 seconds, or 3 messages in 2 seconds, figuring that either would be spam. That way, any troll who knows about the 4/4 rule and stops at 3 could still get his butt kicked on the 3rd try. This requires a little more work, so I'm going to use an associative list.

mob
var/list/spamnumber // number of messages in last n seconds
var/list/spammax = list("40"=4, "20"=3) // "[time]"=max format

verb/Say(msg as text)
set src = usr
if(length(msg) > 400) msg = copytext(msg, 1, 401)
if(!usr.spamnumber) usr.spamnumber = new
for(var/spamtime in usr.spammax)
if(++usr.spamnumber[spamtime] >= usr.spammax[spamtime])
world << "<I>[usr.name] was booted for spam.</I>"
del(usr)
return
spawn(text2num(spamtime))
--usr.spamnumber[spamtime]
world << "<B>[usr.name]:</B> [html_encode(msg)]"

What I've done here is a little different. Basically we're checking two rules: You can't say 3 messages in 2 seconds, or 4 messages in 4 seconds. For this I used two counters instead of one, and I put them in a list. There are lists for the counters, and for the max/time vars. Instead of making spammax and spamtime two different lists (they were two different vars before), I decided I'd prefer an associative list. Look how much cleaner this is:

var/list/spammax = list("40"=4, "20"=3)

...as opposed to this:

var/list/spammax = list(4,3)
var/list/spamtime = list(40,20)

You'd have to go through both lists at the same time with an index var, and it's harder to edit without making a mistake. Associative lists can be a real time saver.

So in the code, you see that the list is checked over for each spamtime value ("40", "20", etc.), and each counter goes up. We compare the counter to the maximum messages it can hold for that amount of time, and if it's too high, we've got spam.

@#$%&! Dirty Words!

Not everyone uses the cleanest language. How much you're willing to tolerate may depend a lot on your own personality. Now language filters are far from perfect, and that's being generous; make them too cautious and they catch next to nothing, but make them too trigger-happy and you can't say "password" without the thing freaking out. You should aim for something that will catch the most blatant attempts while leaving you free to handle other cases your own way. My own chat uses a hybrid solution that works reasonably well: For each nasty word found, a counter goes up by a certain amount (depending on the word) that boots the player when it reaches 100%; if they don't cuss, the counter goes down slowly. This is meant to catch extreme vulgarity.

Now some words are different than others. Most cuss words can occur within another word; some can't. So it makes sense to treat these differently. Here's how that would look:

proc/CussScore(msg)
var/list/whole_cuss = list("spit"=60, "birch"=10)
var/list/any_cuss = list("farg"=100, "bastage"=20, "icehole"=50)
var/score = 0
var/index
var/cussword
for(cussword in any_cuss) // this can occur even as part of a word
index = findtext(msg,cussword)
while(index)
score += any_cuss[cussword]
index = findtext(msg, cussword, index+length(cussword))
for(cussword in whole_cuss) // this must be a whole word
index=findtext(msg, cussword)
while(index)
// start of context
var/beforeindex = max(1, index-1)
// end+1 of context
var/afterindex = min(index+length(cussword), length(msg)) + 1
var/context = copytext(msg, beforeindex, afterindex)
if(ckey(cussword) == ckey(context))
score += whole_cuss[cussword]
index = findtext(msg, cussword, index+length(cussword))
return score

As you can see the list of what scores are associated with which words is purely arbitrary--it's your call. These lists should ideally be at the host's discretion, not the game author's. Therefore you should have something to load and save the lists. More on that later.

So here's what's going on in the complicated loops above: First we check for the words that could be anywhere, using findtext(). The first line in the first loop is index=findtext(msg,cussword), which is simple enough; if the word isn't found, index is 0 and it moves on to the next word. If the word is found, the while() loop is entered. The "score" for the line goes up, and it looks for the same cuss word again, starting from the end of the last one.

For whole words, the process is a little harder. If the word can be found, we have to verify that it's a whole word. It can't have an alphanumeric character (that is, A-Z, a-z, or 0-9) to either side, or it's not a whole word. So, we find another string called context by going into msg and finding what's around the word we found. Going one character in either direction, we'd get a string like, for example, " spit!". Using the ckey() proc that can be stripped of all non-alphanumeric characters; if ckey(context) matches ckey(cussword), we've got a winner.

Now, that goes into our Say verb like this:

mob
var/list/spamnumber // number of messages in last n seconds
var/list/spammax = list("40"=4, "20"=3) // "[time]"=max format
var/cuss = 0

verb/Say(msg as text)
set src = usr
if(length(msg) > 400) msg = copytext(msg, 1, 401)
if(!usr.spamnumber) usr.spamnumber = new
for(var/spamtime in usr.spammax)
if(++usr.spamnumber[spamtime] >= usr.spammax[spamtime])
world << "<I>[usr.name] was booted for spam.</I>"
del(usr)
return
spawn(text2num(spamtime))
--usr.spamnumber[spamtime]
var/cussscore = CussScore(msg)
if(cussscore > 0)
usr.cuss += cussscore
if(usr.cuss >= 100)
world << "<I>[usr.name] was booted for cussing.</I>"
del(usr)
return
// if they don't cuss, it goes down by 10%
else usr.cuss = round(usr.cuss * 0.9)
world << "<B>[usr.name]:</B> [html_encode(msg)]"

So far, so good. I've managed to take a simple-sounding problem and turn it into two nightmarish pieces of code. Odds are you understand most of it, but not all of it. That's close enough.

One extra note on the CussScore() proc: You may have noticed that I didn't explicitly say that's a mob proc or not. It could be used for a mob, or it could be global; it doesn't really matter because no mob really has to be involved. I think it makes a little more sense to leave it as a global proc.

The Little Last-Minute Details

Ha! You were expecting the worst to be over, weren't you? Well it isn't. As it turns out, people can type \n into the command parser, and the parser will interpret it as a new line. That means any idiot can still wreak havoc if they want to. Well, one line should take care of that:

msg = copytext(msg, 1, findText(msg, "\n"))

So what does that do? Well in DM, "\n" doesn't mean \n as you see it here. In DM, "\n" means the character that prints a new line. If that character has been slipped into msg by a malicious player typing out \n, then we'll cut off everything past that. If it isn't found, findText() will return 0, which in copytext() means to copy all the way to the end. So we get msg from the beginning all the way to the end or to a new line, whichever comes first.

(Notice I used findText() instead of findtext()? The version with the capital T is case-sensitive; that is, it doesn't matter if what we find is upper- or lowercase. Case-sensitive searching for text is a smidge faster, and since we're looking for a non-letter, it doesn't matter which kind of search we use.)

Finally, if you put in any admin verbs at all you're going to want a mute command, so let's give the mob an ismute var:

if(usr.ismute) return

And let's also throw in an isadmin var if the user is an admin. (How you set that var is up to you.) The Say() verb should look like this:

mob
var/list/spamnumber // number of messages in last n seconds
var/list/spammax = list("40"=4, "20"=3) // "[time]"=max format
var/cuss = 0
var/isadmin = 0
var/ismute = 0

verb/Say(msg as text)
set src = usr
if(!usr.isadmin) // the admins can say what they want
if(usr.ismute) return
if(length(msg) > 400) msg = copytext(msg, 1, 401)
if(!usr.spamnumber) usr.spamnumber = new
for(var/spamtime in usr.spammax)
if(++usr.spamnumber[spamtime] >= usr.spammax[spamtime])
world << "<I>[usr.name] was booted for spam.</I>"
del(src)
return
spawn(text2num(spamtime))
--usr.spamnumber[spamtime]
var/cussscore = CussScore(msg)
if(cussscore > 0)
usr.cuss += cussscore
if(usr.cuss >= 100)
world << "<I>[usr.name] was booted for cussing.</I>"
del(usr)
return
// if they don't cuss, it goes down by 10%
else usr.cuss = round(usr.cuss * 0.9)
msg = copytext(msg, 1, findText(msg, "\n"))
// let's still assume the host doesn't want to use HTML
world << "<B>[usr.name]:</B> [html_encode(msg)]"

Wow. We're finally done.

NOT QUITE FINISHED!

Dang, I almost forgot about SHOUTING. Anyone who's spent any time online knows HOW ANNOYING IT CAN BE when someone SHOUTS ALL THE TIME. Let's put a stop to that:

mob
...
var/shout = 0

verb/Say(...)
... // Do I really have to repeat all that?
if(msg == uppertext(msg) &amp;&amp; length(msg) > 10)
usr.shout += 20
if(usr.shout >= 100 || usr.cuss + usr.shout >= 120)
world << "<I>[usr.name] was booted for shouting.</I>"
del(usr)
return
// if they don't shout, it goes down by 10%
else usr.shout = round(usr.shout * 0.9)
var/cussscore = CussScore(msg)
if(cussscore > 0)
usr.cuss += cussscore
if(usr.cuss >= 100 || usr.cuss + usr.shout >= 120)
world << "<I>[usr.name] was booted for cussing.</I>"
del(usr)
return
// if they don't cuss, it goes down by 10%
else usr.cuss = round(usr.cuss * 0.9)
...

Here's what I did: If the message is in all caps, uppertext(msg) will be the same as msg. I made an exception at anything 10 characters or less, because people will still want to use some common expressions like "LOL" and such. Longer than that, somebody's just trying to get attention. I set this up just like the cuss filter, with its own rating: It goes up by 20% each time, and drops slowly if you don't shout. At 100% it boots the player.

I combined this with the language filter so that if someone who's been doing a little shouting has also done a little mouthing off, the filter will snap. If both ratings combined get over 120%, the idiot's toast, and good riddance.

Wrapping It All Up

If you use more than one speech verb in your game, it would be tedious to repeat this code for every verb. Instead, put it all in another proc. To save a little time, I'm going to demonstrate something else, too: If you want to mute unruly players instead of booting them, you can set the ismute var (which I put in the code above) instead of calling del(usr).

mob
...
proc/SpamCheck(msg)
if(!isadmin) // the admins can say what they want
if(ismute) return null
if(length(msg) > 400) msg = copytext(msg, 1, 401)
if(!spamnumber) spamnumber = new
for(var/spamtime in spammax)
if(++spamnumber[spamtime] >= spammax[spamtime])
world << "<I>[name] was muted for spam.</I>"
ismute = 1
return null
spawn(text2num(spamtime))
--spamnumber[spamtime]
if(msg == uppertext(msg) &amp;&amp; length(msg) > 10)
shout += 20
if(shout >= 100 || cuss + shout >= 120)
world << "<I>[name] was muted for shouting.</I>"
ismute = 1
return null
// if they don't shout, it goes down by 10%
else shout = round(shout * 0.9)
var/cussscore = CussScore(msg)
if(cussscore > 0)
cuss += cussscore
if(cuss >= 100 || cuss + shout >= 120)
world << "<I>[name] was muted for cussing.</I>"
ismute = 1
return null
// if they don't cuss, it goes down by 10%
else cuss = round(cuss * 0.9)
// let's still assume the host doesn't want to use HTML
return html_encode(msg)

verb/Say(msg as text)
set src = usr
msg = usr.SpamCheck(msg)
if(msg) world << "<B>[usr.name]:</B> [msg]"

verb/Private(mob/recipient as mob,msg as text)
set src = usr
if(!recipient) return
msg = usr.SpamCheck(msg)
if(msg)
recipient << "<B>\[From [usr.name]\]</B> [msg]"
usr << "<B>\[To [recipient.name]\]</B> [msg]"

The SpamCheck() proc takes a message and checks it for spam. If it's satisfied, it sends back the message after passing it through html_encode() and doing all the other stuff for you; all you have to do is print out the result. Did you notice SpamCheck() doesn't use usr even once, even though all the earlier code is peppered with it? When it's called as usr.SpamCheck(), usr and src are guaranteed to be the same thing, so there's no reason to bother with usr.

That Saving/Loading Thing

There's one more task before we're finally finished. Earlier I mentioned that it's important to be able to load and save the list of cuss words used in CussScore(). Well, that can be done. This piece of code is a special datum to keep track of admin settings (more on that another time).

admin
var/list/whole_cuss = list("spit"=60, "birch"=10)
var/list/any_cuss = list("farg"=100, "bastage"=20, "icehole"=50)
var/list/spammax = list("40"=4, "20"=3) // "[time]"=max format

New()
if(fexists("admin.txt")) Load()
else Save()

Del()
Save()
..()

proc/Load()
var/savefile/F = new()
var/txtfile = file("admin.txt")
F.ImportText("/", txtfile)
for(var/varname in F.dir)
F[varname] >> vars[varname]

proc/Save()
var/savefile/F = new()
var/txtfile = file("admin.txt")
var/datum/D = new
for(var/varname in vars - D.vars)
F[varname] << vars[varname]
fdel(txtfile)
F.ExportText("/", txtfile)

var/admin/admin = new

You can create your own verbs to edit the contents of these lists. Once you start up your game, the list will be loaded from admin.txt (or admin.txt will be created for you). The file will appear in the game's folder (the same folder as the .dmb file), and it's set up as plain text. You can load admin.txt into Notepad, or a better text editor if you use one, and it will look like regular DM code:

whole_cuss = list("spit" = 60,"birch" = 10)
any_cuss = list("farg" = 100,"bastage" = 20,"icehole" = 50)
spammax = list("40" = 4,"20" = 3)

That's nice and neat, isn't it? The CussScore() and SpamCheck() procs have to be edited to use this:

proc/CussScore(msg)
var/score = 0
var/index
var/cussword
for(cussword in admin.any_cuss) // this can occur even as part of a word
index=findtext(msg, cussword)
while(index)
score += admin.any_cuss[cussword]
index = findtext(msg, cussword, index+length(cussword))
for(cussword in admin.whole_cuss) // this must be a whole word
index = findtext(msg,cussword)
while(index)
// start of context
var/beforeindex = max(1, index-1)
// end+1 of context
var/afterindex = min(index+length(cussword), length(msg)) + 1
var/context = copytext(msg, beforeindex, afterindex)
if(ckey(cussword) == ckey(context))
score += admin.whole_cuss[cussword]
index = findtext(msg, cussword, index+length(cussword))
return score

mob
proc/SpamCheck(msg)
...
// change these lines to use admin.spammax
for(var/spamtime in admin.spammax)
if(++spamnumber[spamtime] >= admin.spammax[spamtime])
...

If you don't understand how this admin datum comes into play, don't worry: I'll be discussing datums another time. If you do understand it, you can use this--or similar code--as a basis for adding other host-settable features as well. People who host your game can set up preferences to their liking, and have those preferences stay put when they start up again the next time.

Have fun writing your games, and always remember: Not all players taste like chicken. You're gonna run into a few who could make a living filling those rectangular blue cans (and they just might yet), so prepare your game for the worst. Your players will thank you.

Dude! The ? operator!

That aside, fascinating reading! I'll be sure to check back when it comes time to actually get serious about text spam. I generally just remove all spaces from the text and check to see if it has a length. That allows your smilies but removes anyone just pressing space bar and enter often.

(rofl: 'icehole' - classic!)
Eat spit you farging icehole ;-)

Trolls regenerate- you must use fire!
Ripper man5 wrote:
mob
> verb/Say(msg as text)
> set src = usr
> if(length(msg) > 400) msg = copytext(msg, 1, 401)
> world << "<B>[usr.name]:</B> [msg]"
>

In this case explain what copytext does and what it locates. Also is there a way to locate the first letter with copytext and be able to change the first leter?

*In I roll, a year late*
var/first_letter = copytext(msg,1,2)
first_letter = (..changes..)
msg = "[first_letter][copytext(msg,2,min(401,(length(msg)+1)))]"