diff options
| author | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-06-20 09:40:07 -0700 |
|---|---|---|
| committer | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-06-20 09:40:07 -0700 |
| commit | fb653292612eddca5b088ed88466c546d1597107 (patch) | |
| tree | 900ed32e066812688858ed3bb15128c41bb78054 /api/minecraft | |
| download | penguins.lan-fb653292612eddca5b088ed88466c546d1597107.tar.gz penguins.lan-fb653292612eddca5b088ed88466c546d1597107.zip | |
Initial commit
Diffstat (limited to 'api/minecraft')
| -rw-r--r-- | api/minecraft/minecraft.go | 82 | ||||
| -rw-r--r-- | api/minecraft/minecraft_test.go | 40 |
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) + } + } +} |
