Files
panel/agent/internal/dispatch/empyrion_discoveries.go
T
2026-07-14 19:19:43 -07:00

527 lines
19 KiB
Go

package dispatch
// Per-player POI discovery query against the empyrion server's
// global.db SQLite store. Spawns an alpine+sqlite sidecar with the
// instance's saves volume mounted read-only, runs a parameterized
// join query, returns the rows.
//
// Schema reference (DiscoveredPOIs / Entities / Playfields / SolarSystems
// / LoginLogoff) was reverse-engineered from a live empyrion install on
// 2026-04-30. Eleon's mod API does NOT expose per-player discovery —
// Request_GlobalStructure_List returns the server-wide aggregate that
// EAH shows. The DB has the finer per-player data the in-game map uses.
import (
"context"
"encoding/json"
"errors"
"fmt"
"os/exec"
"strings"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// sqlite3's CLI doesn't support `?` parameter binding (that's a
// prepared-statement-only feature on the C API). We string-interpolate
// the SteamID64 — already validated as 17 digits server-side, but we
// also belt-and-suspenders re-validate at the shell layer (digits only).
//
// Two query modes:
// "personal" — POIs the player personally first-walked-into
// "faction" — POIs anyone in the player's faction has discovered
// (matches the in-game shared map)
const sqlPersonalTpl = `
SELECT
COALESCE(ss.name, '') AS solar_system,
COALESCE(pf.name, '') AS playfield,
COALESCE(poi.name, '') AS poi_name,
COALESCE(poi.etype, 0) AS poi_type,
COALESCE(dp.gametime, 0) AS game_time,
COALESCE(ll.playername, '') AS discoverer_name,
COALESCE(ll.playerid, '') AS discoverer_steam_id,
COALESCE(dp.facgroup, 0) AS facgroup,
COALESCE(dp.facid, 0) AS facid
FROM DiscoveredPOIs dp
JOIN Entities poi ON dp.poiid = poi.entityid
LEFT JOIN Playfields pf ON dp.pfid = pf.pfid
LEFT JOIN SolarSystems ss ON pf.ssid = ss.ssid
LEFT JOIN LoginLogoff ll ON dp.entityid = ll.entityid
WHERE dp.entityid IN (
SELECT DISTINCT entityid FROM LoginLogoff WHERE playerid='%SID%'
)
ORDER BY dp.gametime DESC
LIMIT 5000;
`
const sqlFactionTpl = `
SELECT
COALESCE(ss.name, '') AS solar_system,
COALESCE(pf.name, '') AS playfield,
COALESCE(poi.name, '') AS poi_name,
COALESCE(poi.etype, 0) AS poi_type,
COALESCE(dp.gametime, 0) AS game_time,
COALESCE(ll.playername, '') AS discoverer_name,
COALESCE(ll.playerid, '') AS discoverer_steam_id,
COALESCE(dp.facgroup, 0) AS facgroup,
COALESCE(dp.facid, 0) AS facid
FROM DiscoveredPOIs dp
JOIN Entities poi ON dp.poiid = poi.entityid
LEFT JOIN Playfields pf ON dp.pfid = pf.pfid
LEFT JOIN SolarSystems ss ON pf.ssid = ss.ssid
LEFT JOIN LoginLogoff ll ON dp.entityid = ll.entityid
WHERE (dp.facgroup, dp.facid) IN (
SELECT DISTINCT pe.facgroup, pe.facid
FROM Entities pe
WHERE pe.entityid IN (
SELECT DISTINCT entityid FROM LoginLogoff WHERE playerid='%SID%'
)
)
ORDER BY dp.gametime DESC
LIMIT 5000;
`
const sqlPlayerNameTpl = `
SELECT playername FROM LoginLogoff
WHERE playerid='%SID%'
ORDER BY loginticks DESC
LIMIT 1;
`
func (d *Dispatcher) handleEmpyrionDiscoveries(corrID string, req *panelv1.EmpyrionDiscoveriesRequest) {
if req == nil || req.InstanceId == "" || req.SteamId == "" {
d.sendEmpyrionDiscoveriesResult(corrID, &panelv1.EmpyrionDiscoveriesResult{
Ok: false, Error: "instance_id and steam_id required",
})
return
}
go d.runEmpyrionDiscoveriesJob(corrID, req)
}
func (d *Dispatcher) runEmpyrionDiscoveriesJob(corrID string, req *panelv1.EmpyrionDiscoveriesRequest) {
mode := strings.ToLower(strings.TrimSpace(req.Mode))
if mode == "" || mode == "personal" {
mode = "personal"
} else if mode != "faction" {
d.sendEmpyrionDiscoveriesResult(corrID, &panelv1.EmpyrionDiscoveriesResult{
Ok: false, Error: "mode must be 'personal' or 'faction'", Mode: req.Mode, SteamId: req.SteamId,
})
return
}
dockerBin, err := exec.LookPath("docker")
if err != nil {
d.sendEmpyrionDiscoveriesResult(corrID, &panelv1.EmpyrionDiscoveriesResult{
Ok: false, Error: "docker binary not found on agent host", Mode: mode, SteamId: req.SteamId,
})
return
}
// Saves volume name follows the panel convention: panel-<id>-saves.
savesVol := "panel-" + req.InstanceId + "-saves"
// Pre-flight: volume must exist (instance exists on this agent).
if err := exec.Command(dockerBin, "volume", "inspect", savesVol).Run(); err != nil {
d.sendEmpyrionDiscoveriesResult(corrID, &panelv1.EmpyrionDiscoveriesResult{
Ok: false, Error: fmt.Sprintf("instance saves volume %q not found on this agent", savesVol),
Mode: mode, SteamId: req.SteamId,
})
return
}
// Validate steam_id is digits-only — defense in depth, controller
// already checks 17-digit format. Belt-and-suspenders against any
// future caller that bypasses the controller's validation.
for _, c := range req.SteamId {
if c < '0' || c > '9' {
d.sendEmpyrionDiscoveriesResult(corrID, &panelv1.EmpyrionDiscoveriesResult{
Ok: false, Error: "steam_id must be digits only", Mode: mode, SteamId: req.SteamId,
})
return
}
}
tpl := sqlPersonalTpl
if mode == "faction" {
tpl = sqlFactionTpl
}
q := strings.ReplaceAll(tpl, "%SID%", req.SteamId)
nameQ := strings.ReplaceAll(sqlPlayerNameTpl, "%SID%", req.SteamId)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
// alpine+sqlite sidecar. We don't need to discover the savegame
// folder name dynamically — the entrypoint creates exactly one
// (via dedicated.yaml.SaveDirectory which defaults to "DediGame";
// the panel's empyrion module pins this). We pick `*/global.db`
// at the shell layer so renames don't break the query.
//
// Note: -2 disables stdin which avoids the `command timed out`
// path that empty stdin can hit on some Alpine builds. We feed the
// SQL via the third positional arg.
rowsScript := `set -e
apk add --no-cache sqlite >/dev/null 2>&1
DB=$(ls /saves/Saves/Games/*/global.db 2>/dev/null | head -1)
if [ -z "$DB" ]; then echo '{"err":"no global.db found in saves volume"}' >&2; exit 2; fi
NAME=$(sqlite3 "$DB" "$1" 2>/dev/null || echo "")
ROWS=$(sqlite3 -json "$DB" "$2" 2>&1)
if [ -z "$ROWS" ]; then ROWS='[]'; fi
NAME_JSON=$(printf '%s' "$NAME" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')
printf '{"player_name":"%s","rows":%s}\n' "$NAME_JSON" "$ROWS"
`
out, err := exec.CommandContext(ctx, dockerBin,
"run", "--rm",
"-v", savesVol+":/saves:ro",
"--entrypoint", "sh",
"alpine:3.20", "-c", rowsScript, "_", nameQ, q,
).Output()
if err != nil {
d.sendEmpyrionDiscoveriesResult(corrID, &panelv1.EmpyrionDiscoveriesResult{
Ok: false, Error: "sqlite query failed: " + err.Error(),
Mode: mode, SteamId: req.SteamId,
})
return
}
// Parse the wrapper json {"player_name": "...", "rows": [...]}.
type wireRow struct {
SolarSystem string `json:"solar_system"`
Playfield string `json:"playfield"`
PoiName string `json:"poi_name"`
PoiType int32 `json:"poi_type"`
GameTime int64 `json:"game_time"`
DiscovererName string `json:"discoverer_name"`
DiscovererSteamID string `json:"discoverer_steam_id"`
Facgroup int32 `json:"facgroup"`
Facid int32 `json:"facid"`
}
var wrap struct {
PlayerName string `json:"player_name"`
Rows []wireRow `json:"rows"`
}
// Strip leading whitespace; sqlite3 -json sometimes prepends a BOM-ish blank line.
if err := json.Unmarshal([]byte(strings.TrimSpace(string(out))), &wrap); err != nil {
// Tolerate "no rows" — sqlite3 -json prints `[]` (good) or empty
// string when no DB (bad — caught above) or `null` for some
// builds. Fall back to empty-result on parse failure.
var rowsOnly []wireRow
if err2 := json.Unmarshal([]byte(strings.TrimSpace(string(out))), &rowsOnly); err2 == nil {
wrap.Rows = rowsOnly
} else {
d.sendEmpyrionDiscoveriesResult(corrID, &panelv1.EmpyrionDiscoveriesResult{
Ok: false, Error: "parse sqlite output: " + err.Error(),
Mode: mode, SteamId: req.SteamId,
})
return
}
}
pois := make([]*panelv1.DiscoveredPOI, 0, len(wrap.Rows))
for _, r := range wrap.Rows {
pois = append(pois, &panelv1.DiscoveredPOI{
SolarSystem: r.SolarSystem,
Playfield: r.Playfield,
PoiName: r.PoiName,
PoiType: r.PoiType,
GameTime: r.GameTime,
DiscovererName: r.DiscovererName,
DiscovererSteamId: r.DiscovererSteamID,
FactionGroup: r.Facgroup,
FactionId: r.Facid,
})
}
d.sendEmpyrionDiscoveriesResult(corrID, &panelv1.EmpyrionDiscoveriesResult{
Ok: true,
Pois: pois,
Mode: mode,
SteamId: req.SteamId,
PlayerName: strings.Trim(wrap.PlayerName, "\""),
})
_ = errors.New
}
func (d *Dispatcher) sendEmpyrionDiscoveriesResult(corrID string, res *panelv1.EmpyrionDiscoveriesResult) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_EmpyrionDiscoveriesResult{
EmpyrionDiscoveriesResult: res,
},
})
}
// ============================================================================
// Player Summary — stats + sessions + inventory in one query
// ============================================================================
// playerSummaryScript is one big sh script run inside an alpine sidecar.
// It opens global.db, resolves the player's entityid via LoginLogoff,
// then runs four sqlite queries with -json output. The output is one
// JSON object with named keys for each query — we marshal-decode it
// directly into the proto result.
//
// The 4 queries:
// 1. summary — Entities row + LoginLogoff name lookup + last position
// 2. stats — PlayerStatistics row
// 3. sessions — recent LoginLogoff rows
// 4. inv — most recent PlayerInventory snapshot + items
//
// We accept that some of these may return [] (player has no stats row
// yet, never logged in this savegame, etc.) — JSON parser handles it.
func playerSummaryScript() string {
return `set -e
apk add --no-cache sqlite >/dev/null 2>&1
DB=$(ls /saves/Saves/Games/*/global.db 2>/dev/null | head -1)
if [ -z "$DB" ]; then echo '{"err":"no global.db found in saves volume"}' >&2; exit 2; fi
SID="$1"
LIMIT="${2:-50}"
# Resolve entityid for this Steam ID. Use the most recent LoginLogoff
# row to handle character resets that re-bind the same Steam ID to a
# new entityid.
EID=$(sqlite3 "$DB" "SELECT entityid FROM LoginLogoff WHERE playerid='${SID}' ORDER BY loginticks DESC LIMIT 1;")
if [ -z "$EID" ]; then
printf '{"steam_id":"%s","entity_id":0,"player_name":"","stats":null,"sessions":[],"inventory":[]}\n' "$SID"
exit 0
fi
NAME=$(sqlite3 "$DB" "SELECT playername FROM LoginLogoff WHERE entityid=${EID} ORDER BY loginticks DESC LIMIT 1;")
FG=$(sqlite3 "$DB" "SELECT facgroup FROM Entities WHERE entityid=${EID};")
FI=$(sqlite3 "$DB" "SELECT facid FROM Entities WHERE entityid=${EID};")
# Last position from PlayerData.
PD=$(sqlite3 -json "$DB" "
SELECT
COALESCE(pf.name, '') AS last_playfield,
COALESCE(pd.posx, 0) AS last_x,
COALESCE(pd.posy, 0) AS last_y,
COALESCE(pd.posz, 0) AS last_z,
COALESCE(pd.credits, 0) AS credits
FROM PlayerData pd
LEFT JOIN Playfields pf ON pd.pfid = pf.pfid
WHERE pd.entityid=${EID};
")
STATS=$(sqlite3 -json "$DB" "
SELECT
COALESCE(killedenemies, 0) AS killed_enemies,
COALESCE(killedallied, 0) AS killed_allied,
COALESCE(killedanimals, 0) AS killed_animals,
COALESCE(killeddrones, 0) AS killed_drones,
COALESCE(killedplayers, 0) AS killed_players,
COALESCE(killedalliedplayers, 0) AS killed_allied_players,
COALESCE(died, 0) AS died,
COALESCE(score, 0) AS score,
COALESCE(blocksplaced, 0) AS blocks_placed,
COALESCE(blocksdigged, 0) AS blocks_digged,
COALESCE(walkedmeters, 0) AS walked_meters,
COALESCE(jetpackmeters, 0) AS jetpack_meters,
COALESCE(hvmeters, 0) AS hv_meters,
COALESCE(svmeters, 0) AS sv_meters,
COALESCE(cvmeters, 0) AS cv_meters,
COALESCE(playtime, 0.0) AS playtime,
COALESCE(travly, 0.0) AS light_years,
COALESCE(travau, 0.0) AS astronomical_units
FROM PlayerStatistics WHERE entityid=${EID};
")
SESS=$(sqlite3 -json "$DB" "
SELECT
COALESCE(loginticks, 0) AS login_ticks,
COALESCE(logoffticks, 0) AS logoff_ticks,
COALESCE(playername, '') AS player_name,
COALESCE(buildnr, 0) AS build_nr,
COALESCE(ip, '') AS ip,
COALESCE(os, '') AS os,
COALESCE(gfx, '') AS gfx,
COALESCE(cpu, '') AS cpu
FROM LoginLogoff WHERE entityid=${EID}
ORDER BY loginticks DESC LIMIT ${LIMIT};
")
INV_PIID=$(sqlite3 "$DB" "SELECT piid FROM PlayerInventory WHERE entityid=${EID} ORDER BY gametime DESC LIMIT 1;")
INV_GT=0
INV='[]'
if [ -n "$INV_PIID" ]; then
INV_GT=$(sqlite3 "$DB" "SELECT gametime FROM PlayerInventory WHERE piid=${INV_PIID};")
INV=$(sqlite3 -json "$DB" "
SELECT item AS item_id, count
FROM PlayerInventoryItems WHERE piid=${INV_PIID};
")
if [ -z "$INV" ]; then INV='[]'; fi
fi
if [ -z "$STATS" ]; then STATS='[]'; fi
if [ -z "$SESS" ]; then SESS='[]'; fi
if [ -z "$PD" ]; then PD='[]'; fi
printf '{"steam_id":"%s","player_name":%s,"entity_id":%s,"faction_group":%s,"faction_id":%s,"position":%s,"stats":%s,"sessions":%s,"inventory_gametime":%s,"inventory":%s}\n' \
"$SID" \
"$(printf '%%s' "$NAME" | sqlite3 ':memory:' 'select json_quote(?1);' 2>/dev/null || echo '""')" \
"${EID:-0}" "${FG:-0}" "${FI:-0}" \
"$PD" "$STATS" "$SESS" "${INV_GT:-0}" "$INV"
`
}
func (d *Dispatcher) handleEmpyrionPlayerSummary(corrID string, req *panelv1.EmpyrionPlayerSummaryRequest) {
if req == nil || req.InstanceId == "" || req.SteamId == "" {
d.sendEmpyrionPlayerSummaryResult(corrID, &panelv1.EmpyrionPlayerSummaryResult{
Ok: false, Error: "instance_id and steam_id required",
})
return
}
go d.runEmpyrionPlayerSummaryJob(corrID, req)
}
func (d *Dispatcher) runEmpyrionPlayerSummaryJob(corrID string, req *panelv1.EmpyrionPlayerSummaryRequest) {
dockerBin, err := exec.LookPath("docker")
if err != nil {
d.sendEmpyrionPlayerSummaryResult(corrID, &panelv1.EmpyrionPlayerSummaryResult{
Ok: false, Error: "docker binary not found on agent host", SteamId: req.SteamId,
})
return
}
savesVol := "panel-" + req.InstanceId + "-saves"
if err := exec.Command(dockerBin, "volume", "inspect", savesVol).Run(); err != nil {
d.sendEmpyrionPlayerSummaryResult(corrID, &panelv1.EmpyrionPlayerSummaryResult{
Ok: false, Error: fmt.Sprintf("instance saves volume %q not found on this agent", savesVol), SteamId: req.SteamId,
})
return
}
limit := req.MaxLoginRows
if limit <= 0 || limit > 500 {
limit = 50
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, dockerBin,
"run", "--rm",
"-v", savesVol+":/saves:ro",
"--entrypoint", "sh",
"alpine:3.20", "-c", playerSummaryScript(), "_",
req.SteamId, fmt.Sprintf("%d", limit),
).Output()
if err != nil {
d.sendEmpyrionPlayerSummaryResult(corrID, &panelv1.EmpyrionPlayerSummaryResult{
Ok: false, Error: "sqlite query failed: " + err.Error(), SteamId: req.SteamId,
})
return
}
type wireStats struct {
KilledEnemies int64 `json:"killed_enemies"`
KilledAllied int64 `json:"killed_allied"`
KilledAnimals int64 `json:"killed_animals"`
KilledDrones int64 `json:"killed_drones"`
KilledPlayers int64 `json:"killed_players"`
KilledAlliedPlayers int64 `json:"killed_allied_players"`
Died int64 `json:"died"`
Score int64 `json:"score"`
BlocksPlaced int64 `json:"blocks_placed"`
BlocksDigged int64 `json:"blocks_digged"`
WalkedMeters int64 `json:"walked_meters"`
JetpackMeters int64 `json:"jetpack_meters"`
HvMeters int64 `json:"hv_meters"`
SvMeters int64 `json:"sv_meters"`
CvMeters int64 `json:"cv_meters"`
Playtime float64 `json:"playtime"`
LightYears float64 `json:"light_years"`
AstroUnits float64 `json:"astronomical_units"`
}
type wireSession struct {
LoginTicks int64 `json:"login_ticks"`
LogoffTicks int64 `json:"logoff_ticks"`
PlayerName string `json:"player_name"`
BuildNr int64 `json:"build_nr"`
IP string `json:"ip"`
OS string `json:"os"`
GFX string `json:"gfx"`
CPU string `json:"cpu"`
}
type wireInvItem struct {
ItemID int32 `json:"item_id"`
Count int32 `json:"count"`
}
type wirePos struct {
LastPlayfield string `json:"last_playfield"`
LastX float64 `json:"last_x"`
LastY float64 `json:"last_y"`
LastZ float64 `json:"last_z"`
Credits int64 `json:"credits"`
}
type wire struct {
SteamID string `json:"steam_id"`
PlayerName string `json:"player_name"`
EntityID int64 `json:"entity_id"`
FactionGroup int32 `json:"faction_group"`
FactionID int32 `json:"faction_id"`
Position []wirePos `json:"position"`
Stats []wireStats `json:"stats"`
Sessions []wireSession `json:"sessions"`
InventoryGametime int64 `json:"inventory_gametime"`
Inventory []wireInvItem `json:"inventory"`
}
var w wire
if err := json.Unmarshal([]byte(strings.TrimSpace(string(out))), &w); err != nil {
d.sendEmpyrionPlayerSummaryResult(corrID, &panelv1.EmpyrionPlayerSummaryResult{
Ok: false, Error: "parse sqlite output: " + err.Error(), SteamId: req.SteamId,
})
return
}
res := &panelv1.EmpyrionPlayerSummaryResult{
Ok: true,
SteamId: w.SteamID,
PlayerName: strings.Trim(w.PlayerName, "\""),
EntityId: w.EntityID,
FactionGroup: w.FactionGroup,
FactionId: w.FactionID,
InventoryGametime: w.InventoryGametime,
}
if len(w.Position) > 0 {
res.LastPlayfield = w.Position[0].LastPlayfield
res.LastX = w.Position[0].LastX
res.LastY = w.Position[0].LastY
res.LastZ = w.Position[0].LastZ
res.Credits = w.Position[0].Credits
}
if len(w.Stats) > 0 {
s := w.Stats[0]
res.Stats = &panelv1.PlayerStatsRow{
KilledEnemies: s.KilledEnemies, KilledAllied: s.KilledAllied,
KilledAnimals: s.KilledAnimals, KilledDrones: s.KilledDrones,
KilledPlayers: s.KilledPlayers, KilledAlliedPlayers: s.KilledAlliedPlayers,
Died: s.Died, Score: s.Score,
BlocksPlaced: s.BlocksPlaced, BlocksDigged: s.BlocksDigged,
WalkedMeters: s.WalkedMeters, JetpackMeters: s.JetpackMeters,
HvMeters: s.HvMeters, SvMeters: s.SvMeters, CvMeters: s.CvMeters,
Playtime: s.Playtime, LightYears: s.LightYears, AstronomicalUnits: s.AstroUnits,
}
}
for _, s := range w.Sessions {
res.Sessions = append(res.Sessions, &panelv1.PlayerLoginRow{
LoginTicks: s.LoginTicks, LogoffTicks: s.LogoffTicks,
PlayerName: s.PlayerName, BuildNr: s.BuildNr,
Ip: s.IP, Os: s.OS, Gfx: s.GFX, Cpu: s.CPU,
})
}
for _, it := range w.Inventory {
res.Inventory = append(res.Inventory, &panelv1.PlayerInventoryItem{
ItemId: it.ItemID, Count: it.Count,
})
}
d.sendEmpyrionPlayerSummaryResult(corrID, res)
}
func (d *Dispatcher) sendEmpyrionPlayerSummaryResult(corrID string, res *panelv1.EmpyrionPlayerSummaryResult) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_EmpyrionPlayerSummaryResult{
EmpyrionPlayerSummaryResult: res,
},
})
}