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--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, }, }) }