2021-03-06 20:06:27 +00:00
|
|
|
package main
|
|
|
|
|
|
|
|
/*
|
|
|
|
this is the source code for thebuttonbot, an IRC bot that plays the game "the button" on the tilde.town IRC network.
|
|
|
|
the game is played like this:
|
|
|
|
the bot maintains a counter. At a random interval, the counter ticks down.
|
|
|
|
at any point, any person can press the button. This resets the counter. This gives the player points. You get more points the lower the counter.
|
|
|
|
If the counter hits zero, the button is frozen and nobody can press it any more.
|
|
|
|
|
|
|
|
firstly, we need to import all the libraries we need.
|
2021-03-07 12:03:39 +00:00
|
|
|
* fmt for debugging and formatting IRC messages
|
|
|
|
* net for connecting to IRC
|
|
|
|
* bufio for reading from the network socket we create to connect to IRC with
|
|
|
|
* strings for splitting and testing strings
|
|
|
|
* regexp to help parse messages.
|
|
|
|
* sync to make things concurrency safe
|
|
|
|
* time to sleep for some time before reducing the counter
|
2021-03-07 12:06:54 +00:00
|
|
|
* rand to randomise the timer ticks
|
2021-03-07 17:30:15 +00:00
|
|
|
* sort to sort things
|
2021-03-06 20:06:27 +00:00
|
|
|
*/
|
|
|
|
import (
|
|
|
|
"bufio"
|
|
|
|
"fmt"
|
|
|
|
"net"
|
|
|
|
"regexp"
|
|
|
|
"strings"
|
2021-03-07 12:03:39 +00:00
|
|
|
"sync"
|
|
|
|
"time"
|
2021-03-07 12:06:54 +00:00
|
|
|
"math/rand"
|
2021-03-07 17:30:15 +00:00
|
|
|
"sort"
|
2021-03-06 20:06:27 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
/*
|
|
|
|
as we'll be referencing them a lot later, we should create some constants to store things like the network address and our IRC nick/user name. This is set up for connecting to tilde.town from localhost.
|
2021-03-07 17:30:15 +00:00
|
|
|
we also set the minimum and maximum timeout lengths (in minutes) for the counter to tick down, and the value to reset to.
|
2021-03-06 20:06:27 +00:00
|
|
|
*/
|
|
|
|
const (
|
2021-03-07 12:03:39 +00:00
|
|
|
nick string = "thebuttonbot"
|
|
|
|
user string = "thebuttonbot"
|
|
|
|
realname string = "thebuttonbot"
|
|
|
|
channel string = "#thebutton"
|
|
|
|
server string = "localhost:6667"
|
2021-03-08 16:30:17 +00:00
|
|
|
maxTime int = 20
|
|
|
|
minTime int = 15
|
2021-03-07 12:03:39 +00:00
|
|
|
resetCount int = 10
|
|
|
|
)
|
|
|
|
|
|
|
|
/*
|
|
|
|
game state variables. counter is the current count, scores is the scoreboard, and the two mutexes are for protecting the outgoing connection and the counter.
|
|
|
|
*/
|
|
|
|
var (
|
|
|
|
counter int = resetCount
|
2021-03-07 17:30:15 +00:00
|
|
|
scores map[string]int = make(map[string]int)
|
2021-03-07 12:03:39 +00:00
|
|
|
countMutex sync.Mutex
|
|
|
|
connMutex sync.Mutex
|
2021-03-06 20:06:27 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
// IRCMessage is a type that contains the contents of an incoming IRC message.
|
|
|
|
// I'm only using this for message parsing, not construction, as I'm only ever constructing a small subset of IRC messages.
|
|
|
|
// [@Tags] [:SourceNick!SourceUser@SourceAddress] <Command> <Parameters>
|
|
|
|
type IRCMessage struct {
|
|
|
|
Tags string // Not parsing tags further than I need to right now
|
|
|
|
SourceNick string
|
|
|
|
SourceUser string
|
|
|
|
SourceHost string
|
|
|
|
Command string
|
|
|
|
Parameters []string
|
|
|
|
}
|
2021-03-07 17:30:15 +00:00
|
|
|
/*
|
|
|
|
types Pair and PairList (and the associated functions) only exist for the sake of sorting the high score list.
|
|
|
|
the high scores are converted from a map into a PairList, and then sorted.
|
|
|
|
PairList implements the interface for sort.Sort()
|
|
|
|
*/
|
|
|
|
|
|
|
|
type Pair struct {
|
|
|
|
Key string
|
|
|
|
Value int
|
|
|
|
}
|
|
|
|
|
|
|
|
type PairList []Pair
|
|
|
|
|
|
|
|
func (p PairList) Len() int { return len(p) }
|
|
|
|
func (p PairList) Swap(i, j int) { p[i], p[j] = p[j], p[i] }
|
|
|
|
func (p PairList) Less(i, j int) bool { return p[i].Value < p[j].Value }
|
2021-03-06 20:06:27 +00:00
|
|
|
|
|
|
|
/*
|
|
|
|
function parseMessage parses an IRC message, and returns an IRCMessage.
|
|
|
|
It assumes it's getting well-formed messages.
|
|
|
|
reference: modern.ircdocs.horse/index.html#messages
|
|
|
|
*/
|
|
|
|
func parseMessage(s string) IRCMessage {
|
|
|
|
// create an empty message object we're writing into.
|
|
|
|
var m IRCMessage
|
|
|
|
// split on spaces, as everything in the message is delimited by them
|
|
|
|
parts := strings.Split(s, " ")
|
|
|
|
for index, item := range parts {
|
|
|
|
// tags start with an @, and can only occur as the first item in a message.
|
|
|
|
if index == 0 && strings.HasPrefix(item, "@") {
|
|
|
|
m.Tags = strings.TrimPrefix(item, "@")
|
|
|
|
// source can be either the first or second item in a message, depending on if there are tags first.
|
|
|
|
} else if (m.Tags != "" && index == 1 || m.Tags == "" && index == 0) && strings.HasPrefix(item, ":") {
|
|
|
|
// TODO tidy this up
|
|
|
|
// set the source regexp. Matches :nick!user@host or :host
|
|
|
|
sourceRegexp, _ := regexp.Compile(":((.+)!(.+)@)?(.+)")
|
|
|
|
// use it
|
|
|
|
matches := sourceRegexp.FindStringSubmatch(item)
|
|
|
|
// if the length of matches is longer than 2, we matched a nick and user. Otherwise we only matched a host.
|
|
|
|
if len(matches) > 3 {
|
|
|
|
m.SourceNick = matches[2]
|
|
|
|
m.SourceUser = matches[3]
|
|
|
|
m.SourceHost = matches[4]
|
|
|
|
} else {
|
|
|
|
m.SourceHost = matches[1]
|
|
|
|
}
|
|
|
|
// if we don't already have a command and the thing doesn't match otherwise, it's a command.
|
|
|
|
} else if m.Command == "" {
|
|
|
|
m.Command = item
|
|
|
|
// if nothing else is true, it's a parameter to the command.
|
|
|
|
} else {
|
|
|
|
m.Parameters = append(m.Parameters, item)
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
return m
|
|
|
|
|
|
|
|
}
|
|
|
|
|
2021-04-10 18:20:21 +00:00
|
|
|
/*
|
|
|
|
function scoreString formats the score list and returns a string.
|
|
|
|
*/
|
|
|
|
func scoreString() string {
|
|
|
|
// Make an empty starting string
|
|
|
|
s := ""
|
|
|
|
// Initialise a pairlist for names and fill it
|
|
|
|
names := make(PairList, len(scores))
|
|
|
|
i := 0
|
|
|
|
for k, v := range scores {
|
|
|
|
names[i] = Pair{Key: k, Value: v}
|
|
|
|
i++
|
|
|
|
}
|
|
|
|
// sort and format for sending
|
|
|
|
sort.Reverse(names)
|
|
|
|
for i, v := range names {
|
|
|
|
// append all the scores to it
|
|
|
|
s += fmt.Sprintf("%s: %d", v.Key, v.Value)
|
|
|
|
if i != len(names)-1 {
|
|
|
|
s += ","
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return s
|
|
|
|
}
|
|
|
|
|
2021-03-07 12:03:39 +00:00
|
|
|
/*
|
|
|
|
function sendMessage sends a message using connection conn on IRC channel c with content s.
|
|
|
|
It first locks the connection mutex, and unlocks it afterwards, to make the function concurrency-safe.
|
|
|
|
When the mutex is locked by one goroutine, another cannot write to it, preventing a race condition.
|
|
|
|
(We don't need this for recieving messages, as only the main routine recieves messages)
|
|
|
|
*/
|
2021-03-06 20:06:27 +00:00
|
|
|
func sendMessage(conn *net.Conn, c string, s string) {
|
2021-03-07 12:03:39 +00:00
|
|
|
connMutex.Lock()
|
2021-03-06 20:06:27 +00:00
|
|
|
fmt.Fprintf(*conn, "PRIVMSG %s :%s\r\n", c, s)
|
2021-03-07 12:03:39 +00:00
|
|
|
connMutex.Unlock()
|
|
|
|
}
|
|
|
|
|
|
|
|
// function setCounter sets the counter variable to a value, concurrency-safely using a mutex.
|
|
|
|
func setCounter(value int) {
|
|
|
|
countMutex.Lock()
|
|
|
|
counter = value
|
|
|
|
countMutex.Unlock()
|
|
|
|
}
|
|
|
|
|
|
|
|
// function getCounter gets the current value of the counter in a concurrency-safe way.
|
|
|
|
func getCounter() int {
|
|
|
|
var c int
|
|
|
|
countMutex.Lock()
|
|
|
|
c = counter
|
|
|
|
countMutex.Unlock()
|
|
|
|
return c
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
function countdown is a goroutine that starts a timer and counts down on the countdown when the timer times out.
|
|
|
|
if it gets a message over the reset channel, the timer and counter are reset.
|
|
|
|
when the timer would hit 0, the locked variable is set. If the timer is locked, timeouts are ignored until it's reset.
|
|
|
|
*/
|
|
|
|
func countdown(conn *net.Conn, reset chan int) {
|
|
|
|
locked := false
|
|
|
|
for {
|
2021-03-07 12:06:54 +00:00
|
|
|
duration := rand.Intn(maxTime-minTime) + minTime
|
2021-03-07 17:30:15 +00:00
|
|
|
timer := time.NewTimer(time.Duration(duration) * time.Minute)
|
2021-03-07 12:03:39 +00:00
|
|
|
select {
|
|
|
|
case <-timer.C: // on a timer timeout
|
|
|
|
if !locked {
|
|
|
|
c := getCounter()
|
|
|
|
setCounter(c - 1)
|
|
|
|
if c-1 == 0 {
|
|
|
|
locked = true
|
2021-04-10 18:20:21 +00:00
|
|
|
sendMessage(conn, channel, "Ker-Chunk! The timer ticked down to the final position. You're all dead.")
|
|
|
|
sendMessage(conn, channel, scoreString())
|
2021-03-07 12:03:39 +00:00
|
|
|
} else {
|
|
|
|
sendMessage(conn, channel, fmt.Sprintf("*tick, tick, tick* The timer ticks down. It now reads %d", c-1))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
case <-reset:
|
|
|
|
timer.Stop()
|
|
|
|
setCounter(resetCount)
|
|
|
|
locked = false
|
|
|
|
}
|
|
|
|
}
|
2021-03-06 20:06:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func main() {
|
|
|
|
// open a connection to the IRC network over a TCP connection.
|
|
|
|
conn, err := net.Dial("tcp", server)
|
|
|
|
if err != nil {
|
|
|
|
panic(err)
|
|
|
|
}
|
|
|
|
// create a buffered reader to read from the connection.
|
|
|
|
reader := bufio.NewReader(conn)
|
|
|
|
// create a scanner that will let us easily read single messages from IRC.
|
|
|
|
// as IRC happens in lines, we can just scan lines.
|
|
|
|
scanner := bufio.NewScanner(reader)
|
|
|
|
/*
|
|
|
|
Upon connecting to IRC, we need to set our username with the USER command, and nickname with the NICK command.
|
|
|
|
They have the following structures:
|
|
|
|
```
|
|
|
|
USER [username] 0 * :[realname]
|
|
|
|
NICK [nickname]
|
|
|
|
```
|
|
|
|
Then we connect to the channel and send a message as a test, with JOIN and PRIVMSG.
|
|
|
|
```
|
|
|
|
JOIN [channel]
|
|
|
|
PRIVMSG [destination] :[content]
|
|
|
|
```
|
|
|
|
We can write these to the connection with fmt.Fprintf.
|
|
|
|
ref: https://modern.ircdocs.horse
|
|
|
|
*/
|
|
|
|
fmt.Fprintf(conn, "USER %s 0 * :%s\r\n", user, realname)
|
|
|
|
fmt.Fprintf(conn, "NICK %s\r\n", nick)
|
|
|
|
fmt.Fprintf(conn, "JOIN %s\r\n", channel)
|
2021-03-07 12:03:39 +00:00
|
|
|
// Run the goroutine that does the countdown. Create channels we can use to reset it or press the button.
|
|
|
|
reset := make(chan int)
|
|
|
|
go countdown(&conn, reset)
|
2021-03-06 20:06:27 +00:00
|
|
|
/*
|
|
|
|
loop every time there is a new line sent over the connection (a new IRC message/command).
|
|
|
|
We can get the content of it with scanner.Text().
|
|
|
|
*/
|
|
|
|
for scanner.Scan() {
|
|
|
|
t := scanner.Text()
|
|
|
|
// parse the message using the message parsing function.
|
|
|
|
msg := parseMessage(t)
|
|
|
|
/*
|
|
|
|
Now we actually deal with the IRC messages!
|
|
|
|
First, PING. The server will send a PING sometimes to make sure the client is still awake.
|
|
|
|
When we get a PING, we need to respond with a PONG:
|
|
|
|
Server: PING [parameter]
|
|
|
|
Client: PONG [parameter]
|
|
|
|
*/
|
|
|
|
// Print the message for debugging purposes
|
|
|
|
fmt.Println(msg)
|
|
|
|
if msg.Command == "PING" {
|
|
|
|
fmt.Fprintf(conn, "PONG %s\r\n", msg.Parameters[0])
|
|
|
|
}
|
|
|
|
/*
|
|
|
|
Next, actual messages from people. These use the PRIVMSG command.
|
|
|
|
PRIVMSG from :content
|
|
|
|
because of my dumb message parser, the contents are spread throughout the parameters array.
|
|
|
|
However, we only need to look at the first bit, because we're just reading some set commands:
|
|
|
|
,scores: prints the scoreboard (when it's implemented)
|
2021-03-07 12:03:39 +00:00
|
|
|
,press: press the button, resetting the countdown and scoring points, as long as it's not locked (when that's implemented)
|
2021-03-06 20:06:27 +00:00
|
|
|
,ping: sends a "pong" as a PRIVMSG to the channel.
|
2021-03-07 12:03:39 +00:00
|
|
|
,count: sends the current countdown value
|
2021-03-06 20:06:27 +00:00
|
|
|
,help: prints the things you can do
|
2021-03-07 12:03:39 +00:00
|
|
|
,reset: resets the game unconditionally
|
2021-03-06 20:06:27 +00:00
|
|
|
these only matter if they're sent in the bot's channel.
|
|
|
|
scores might get an argument sometime idk
|
|
|
|
*/
|
|
|
|
if msg.Command == "PRIVMSG" {
|
|
|
|
msgChannel := msg.Parameters[0]
|
2021-03-07 17:30:15 +00:00
|
|
|
msgSender := msg.SourceNick
|
2021-03-06 20:06:27 +00:00
|
|
|
msgContent := strings.TrimPrefix(msg.Parameters[1], ":")
|
|
|
|
if msgChannel == channel {
|
|
|
|
if msgContent == ",ping" {
|
|
|
|
sendMessage(&conn, channel, "Pong!\r\n")
|
|
|
|
} else if msgContent == ",scores" {
|
2021-04-10 18:20:21 +00:00
|
|
|
sendMessage(&conn, channel, scoreString())
|
2021-03-06 20:06:27 +00:00
|
|
|
} else if msgContent == ",press" {
|
2021-03-07 12:03:39 +00:00
|
|
|
// TODO score these
|
|
|
|
c := getCounter()
|
|
|
|
if c != 0 {
|
|
|
|
reset <- 0
|
2021-04-10 18:20:21 +00:00
|
|
|
points := (resetCount - c)*(resetCount - c)
|
2021-03-07 17:30:15 +00:00
|
|
|
scores[msgSender] += points
|
|
|
|
sendMessage(&conn, channel, fmt.Sprintf("%s presses the button for %d points. The countdown resets to %d", msgSender, points, resetCount))
|
2021-03-07 12:03:39 +00:00
|
|
|
} else {
|
2021-03-07 15:05:46 +00:00
|
|
|
sendMessage(&conn, channel, "You're all dead. Dead people can't press buttons.")
|
2021-03-07 12:03:39 +00:00
|
|
|
}
|
|
|
|
} else if msgContent == ",reset" {
|
2021-03-07 12:06:54 +00:00
|
|
|
// TODO maybe check some kind of auth on doing this
|
2021-04-10 18:20:21 +00:00
|
|
|
if getCounter() == 0 {
|
|
|
|
reset <- 0
|
|
|
|
scores = map[string]int{} // empty the scoreboard
|
|
|
|
sendMessage(&conn, channel, "Timer reset!")
|
|
|
|
} else {
|
|
|
|
sendMessage(&conn, channel, "You can't reset the timer while the game is running!")
|
|
|
|
}
|
2021-03-07 12:03:39 +00:00
|
|
|
} else if msgContent == ",count" {
|
|
|
|
sendMessage(&conn, channel, fmt.Sprintf("You look up at the ominous countdown. It reads %d", getCounter()))
|
2021-03-06 20:06:27 +00:00
|
|
|
} else if msgContent == ",help" {
|
|
|
|
sendMessage(&conn, channel, ",scores: prints current scores")
|
|
|
|
sendMessage(&conn, channel, ",press: press the button! resets the countdown, adds to your score. The lower the count, the higher your score!")
|
2021-03-07 12:03:39 +00:00
|
|
|
sendMessage(&conn, channel, ",count: prints current countdown position")
|
2021-03-06 20:06:27 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
// If the loop is broken, there might be a scanner error. If so, just panic for now.
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
|
|
panic(err)
|
|
|
|
}
|
|
|
|
}
|