summaryrefslogtreecommitdiff
path: root/api/minecraft
diff options
context:
space:
mode:
authorElizabeth Alexander Hunt <me@liz.coffee>2026-06-20 09:40:07 -0700
committerElizabeth Alexander Hunt <me@liz.coffee>2026-06-20 09:40:07 -0700
commitfb653292612eddca5b088ed88466c546d1597107 (patch)
tree900ed32e066812688858ed3bb15128c41bb78054 /api/minecraft
downloadpenguins.lan-fb653292612eddca5b088ed88466c546d1597107.tar.gz
penguins.lan-fb653292612eddca5b088ed88466c546d1597107.zip
Initial commit
Diffstat (limited to 'api/minecraft')
-rw-r--r--api/minecraft/minecraft.go82
-rw-r--r--api/minecraft/minecraft_test.go40
2 files changed, 122 insertions, 0 deletions
diff --git a/api/minecraft/minecraft.go b/api/minecraft/minecraft.go
new file mode 100644
index 0000000..16cc0f3
--- /dev/null
+++ b/api/minecraft/minecraft.go
@@ -0,0 +1,82 @@
+package minecraft
+
+import (
+ "encoding/json"
+ "io"
+ "log"
+ "net/http"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "penguins.lan/adapters/rcon"
+ "penguins.lan/api/types"
+ "penguins.lan/database"
+)
+
+const (
+ queryTimeout = 3 * time.Second
+ // staleAfter marks the cache offline if the scheduler stopped refreshing it.
+ staleAfter = 30 * time.Second
+)
+
+type Status struct {
+ Online bool `json:"online"`
+ Count int `json:"count"`
+ Max int `json:"max"`
+ Players []string `json:"players"`
+}
+
+var listPattern = regexp.MustCompile(`There are (\d+) of a max of (\d+) players online:?\s*(.*)`)
+
+// Query runs "list" over RCON. The scheduler calls this; requests read the cache.
+func Query(address, password string) (Status, error) {
+ client, err := rcon.Dial(address, password, queryTimeout)
+ if err != nil {
+ return Status{}, err
+ }
+ defer client.Close()
+
+ out, err := client.Execute("list")
+ if err != nil {
+ return Status{}, err
+ }
+ return parseList(out), nil
+}
+
+func parseList(out string) Status {
+ m := listPattern.FindStringSubmatch(strings.TrimSpace(out))
+ if m == nil {
+ return Status{Online: true}
+ }
+
+ count, _ := strconv.Atoi(m[1])
+ max, _ := strconv.Atoi(m[2])
+
+ players := []string{}
+ for _, p := range strings.Split(m[3], ",") {
+ if p = strings.TrimSpace(p); p != "" {
+ players = append(players, p)
+ }
+ }
+ return Status{Online: true, Count: count, Max: max, Players: players}
+}
+
+func StatusContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ resp.Header().Set("Content-Type", "application/json")
+
+ cached, updatedAt, err := database.GetMinecraftStatus(context.DBConn)
+ if err != nil {
+ log.Println("minecraft: read cache failed:", err)
+ }
+
+ if cached == "" || time.Since(updatedAt) > staleAfter {
+ json.NewEncoder(resp).Encode(Status{Online: false})
+ } else {
+ io.WriteString(resp, cached)
+ }
+ return success(context, req, resp)
+ }
+}
diff --git a/api/minecraft/minecraft_test.go b/api/minecraft/minecraft_test.go
new file mode 100644
index 0000000..0c89fc5
--- /dev/null
+++ b/api/minecraft/minecraft_test.go
@@ -0,0 +1,40 @@
+package minecraft
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestParseList(t *testing.T) {
+ cases := []struct {
+ out string
+ want Status
+ }{
+ {
+ "There are 2 of a max of 20 players online: Alice, Bob",
+ Status{Online: true, Count: 2, Max: 20, Players: []string{"Alice", "Bob"}},
+ },
+ {
+ "There are 0 of a max of 20 players online:",
+ Status{Online: true, Count: 0, Max: 20, Players: []string{}},
+ },
+ {
+ "There are 1 of a max of 20 players online: Steve\n",
+ Status{Online: true, Count: 1, Max: 20, Players: []string{"Steve"}},
+ },
+ {
+ "some unexpected response",
+ Status{Online: true, Count: 0, Max: 0, Players: []string{}},
+ },
+ }
+
+ for _, c := range cases {
+ got := parseList(c.out)
+ if got.Players == nil {
+ got.Players = []string{}
+ }
+ if !reflect.DeepEqual(got, c.want) {
+ t.Errorf("parseList(%q) = %+v, want %+v", c.out, got, c.want)
+ }
+ }
+}