// Package ark knows the text ARK: Survival Evolved's admin console speaks: the commands to send, // and how to read what comes back from ListPlayers and GetChat. package ark import ( "errors" "fmt" "regexp" "strings" "time" ) // NoResponse is what the server answers to a command that has nothing to say. const NoResponse = "Server received, But no response!!" // Player is one person connected to a server. type Player struct { Name string `json:"name"` SteamID string `json:"steam_id"` } var playerLine = regexp.MustCompile(`^\s*\d+\.\s+(.*),\s*(\d{15,20})\s*$`) // ParsePlayers reads the reply to ListPlayers: "0. Name, 76561198000000000" per line. Names may // contain commas, so the Steam ID is taken from the end of the line. func ParsePlayers(s string) []Player { var out []Player for _, line := range strings.Split(s, "\n") { if m := playerLine.FindStringSubmatch(strings.TrimRight(line, "\r\x00 ")); m != nil { out = append(out, Player{Name: strings.TrimSpace(m[1]), SteamID: m[2]}) } } return out } // ChatLine is one line from GetChat. type ChatLine struct { Sender string `json:"sender"` Character string `json:"character,omitempty"` Message string `json:"message"` Server bool `json:"server"` // the server itself speaking (broadcasts, admin messages) } var timestamp = regexp.MustCompile(`^\[\d{4}\.\d\d\.\d\d[-_]\d\d\.\d\d\.\d\d\]\s*`) // ParseChat reads the reply to GetChat. Player lines look like "Steam Name (Character): hello". func ParseChat(s string) []ChatLine { var out []ChatLine for _, line := range strings.Split(s, "\n") { line = strings.TrimSpace(strings.Trim(line, "\x00")) line = timestamp.ReplaceAllString(line, "") if line == "" || strings.HasPrefix(line, NoResponse) { continue } cl := ChatLine{Message: line, Server: true} if i := strings.Index(line, ": "); i > 0 { who := line[:i] cl.Message = line[i+2:] cl.Sender = who if j := strings.LastIndex(who, " ("); j > 0 && strings.HasSuffix(who, ")") { cl.Sender = who[:j] cl.Character = who[j+2 : len(who)-1] } cl.Server = cl.Sender == "SERVER" } out = append(out, cl) } return out } // IsEmptyReply reports whether a reply carries nothing worth showing. func IsEmptyReply(s string) bool { s = strings.TrimSpace(s) return s == "" || strings.HasPrefix(s, NoResponse) } // ValidSteamID says whether s is a 64-bit Steam ID: digits only, so it can never smuggle anything // else into a command. func ValidSteamID(s string) bool { if len(s) < 15 || len(s) > 20 { return false } for _, r := range s { if r < '0' || r > '9' { return false } } return true } // Clean makes text safe to put in a command: one line, no control characters, no double quotes. func Clean(s string) string { var sb strings.Builder for _, r := range s { switch { case r == '"': sb.WriteRune('\'') case r == '\n' || r == '\r' || r == '\t': sb.WriteRune(' ') case r < 0x20 || r == 0x7f: default: sb.WriteRune(r) } } out := strings.TrimSpace(sb.String()) if r := []rune(out); len(r) > 400 { out = string(r[:400]) } return out } // Command lines with fixed text. const ( ListPlayers = "ListPlayers" GetChat = "GetChat" SaveWorld = "SaveWorld" DoExit = "DoExit" DestroyWildDinos = "DestroyWildDinos" ) // Broadcast shows a message in the middle of every player's screen. func Broadcast(msg string) (string, error) { return withText("Broadcast", msg) } // ServerChat posts a line to global chat as SERVER. func ServerChat(msg string) (string, error) { return withText("ServerChat", msg) } // SetMOTD changes the message of the day. func SetMOTD(msg string) (string, error) { return withText("SetMessageOfTheDay", msg) } func withText(cmd, msg string) (string, error) { msg = Clean(msg) if msg == "" { return "", errors.New("the message is empty") } return cmd + " " + msg, nil } // ServerChatTo whispers a message to one player. func ServerChatTo(steamID, msg string) (string, error) { if !ValidSteamID(steamID) { return "", errors.New("not a Steam ID") } msg = Clean(msg) if msg == "" { return "", errors.New("the message is empty") } return fmt.Sprintf(`ServerChatTo "%s" %s`, steamID, msg), nil } // ForPlayer builds a command that takes only a Steam ID: KickPlayer, BanPlayer, UnbanPlayer, // AllowPlayerToJoinNoCheck, DisallowPlayerToJoinNoCheck. func ForPlayer(cmd, steamID string) (string, error) { if !ValidSteamID(steamID) { return "", errors.New("not a Steam ID") } return cmd + " " + steamID, nil } var timeOfDay = regexp.MustCompile(`^([01]?\d|2[0-3]):[0-5]\d(:[0-5]\d)?$`) // SetTimeOfDay sets the in-game clock to HH:MM or HH:MM:SS. func SetTimeOfDay(t string) (string, error) { t = strings.TrimSpace(t) if !timeOfDay.MatchString(t) { return "", errors.New("time must look like 06:30") } if strings.Count(t, ":") == 1 { t += ":00" } return "SetTimeOfDay " + t, nil } // Reference lists the console commands worth offering as suggestions in a UI. var Reference = []string{ "ListPlayers", "GetChat", "SaveWorld", "DoExit", "Broadcast ", "ServerChat ", `ServerChatTo "" `, `ServerChatToPlayer "" `, "KickPlayer ", "BanPlayer ", "UnbanPlayer ", "AllowPlayerToJoinNoCheck ", "DisallowPlayerToJoinNoCheck ", "DestroyWildDinos", "SetTimeOfDay 12:00", "SetMessageOfTheDay ", "ShowMessageOfTheDay", "GiveItemToPlayer 1 0 0", "GiveExpToPlayer 1000 0 0", "RenamePlayer \"\" ", "RenameTribe \"\" ", } // Duration formats d the way a player would say it ("10 minutes", "30 seconds"). func Duration(d time.Duration) string { d = d.Round(time.Second) switch { case d >= 2*time.Hour: return fmt.Sprintf("%d hours", int(d.Hours())) case d >= time.Hour: return "1 hour" case d >= 2*time.Minute: return fmt.Sprintf("%d minutes", int(d.Minutes())) case d >= time.Minute: return "1 minute" case d == time.Second: return "1 second" } return fmt.Sprintf("%d seconds", int(d.Seconds())) }