Files
VeillaxandClaude Sonnet 5.5 0d2cb31abf
CI / test (push) Successful in 2m26s
Release / release (push) Successful in 2m36s
Megalodon: manage ARK servers over RCON from a web dashboard
Watches any number of ARK:SE servers (RCON for players and chat, the query
port for map, version and player cap), records joins, leaves, chat and an
audit trail, and keeps a player database across servers.

Control: save, broadcast, chat, whisper, kick, time of day, MOTD, wild dino
wipe, a raw console with history and macros, restarts with timed warnings and
a world save, optional start command and auto-start. Bans are kept by
Megalodon: timed, cluster-wide and re-enforced when the player returns.

Automation: schedules (every N or at times of day), Discord/ntfy/webhook
notifications, welcome message, chat word filter.

Access: viewer, moderator and admin roles, API tokens, Prometheus metrics.
cmd/fakeark simulates a server for trying it out.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 23:36:13 -06:00

199 lines
5.9 KiB
Go

// 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 "<steamid>" `,
`ServerChatToPlayer "<name>" `, "KickPlayer <steamid>", "BanPlayer <steamid>", "UnbanPlayer <steamid>",
"AllowPlayerToJoinNoCheck <steamid>", "DisallowPlayerToJoinNoCheck <steamid>", "DestroyWildDinos",
"SetTimeOfDay 12:00", "SetMessageOfTheDay ", "ShowMessageOfTheDay", "GiveItemToPlayer <playerid> <blueprint> 1 0 0",
"GiveExpToPlayer <playerid> 1000 0 0", "RenamePlayer \"<old>\" <new>", "RenameTribe \"<old>\" <new>",
}
// 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()))
}