Files
panel/controller/cmd/controller/empyrion_galaxy.go
T
2026-07-14 19:22:07 -07:00

205 lines
6.0 KiB
Go

package main
// Galaxy / star-map view. Reads the running savegame's `Sectors.yaml`
// (Eleon's per-scenario star catalog) via the agent's FsRead RPC, parses
// the YAML on the controller, and returns a flattened solar-system list
// ready for SVG rendering on the dashboard.
//
// Sectors.yaml lives at:
// /game-saves/Saves/Games/<gameName>/Sectors/Sectors.yaml
//
// The default game name is "DediGame" but operators can change it via
// dedicated.yaml `GameName:`. We auto-discover by listing /game-saves/Saves/Games/
// and grabbing the first dir with a Sectors/Sectors.yaml.
import (
"context"
"errors"
"net/http"
"path"
"strings"
"time"
"gopkg.in/yaml.v3"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
"google.golang.org/protobuf/types/known/timestamppb"
)
// sectorsYAML is the subset of Empyrion's Sectors.yaml that drives the
// galaxy view. We don't unmarshal everything — just stars + sectors +
// playfields with their coordinates.
type sectorsYAML struct {
GalaxyMode bool `yaml:"GalaxyMode"`
SolarSystems []sectorsSolarSystem `yaml:"SolarSystems"`
}
type sectorsSolarSystem struct {
Name string `yaml:"Name"`
Coordinates []float64 `yaml:"Coordinates"`
StarClass string `yaml:"StarClass"`
Sectors []sectorsEntry `yaml:"Sectors"`
}
type sectorsEntry struct {
Coordinates []float64 `yaml:"Coordinates"`
SectorMapType string `yaml:"SectorMapType"`
Color string `yaml:"Color"`
Playfields [][]interface{} `yaml:"Playfields"`
AllowFactions string `yaml:"AllowFactions"`
DenyFactions string `yaml:"DenyFactions"`
}
// GET /api/instances/{id}/empyrion/galaxy
// Returns: { stars: [{ name, x, y, z, starClass, sectorCount, playfieldCount }],
// sectors: [{ star, x, y, z, type, color, playfields: [{ name, type }] }] }
func (h *httpServer) handleEmpyrionGalaxy(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
conn := h.registry.get(agentID)
if conn == nil {
writeError(w, http.StatusNotFound, "agent_offline", "agent not connected")
return
}
// Discover the savegame folder. Empyrion default is DediGame but it can
// be renamed via dedicated.yaml.GameName, so we list /game-saves/Saves/Games/.
root := "/game-saves/Saves/Games"
games, err := h.fsListAt(r.Context(), conn, id, root)
if err != nil {
writeError(w, http.StatusInternalServerError, "fs_list_failed", err.Error())
return
}
var sectorsPath string
for _, g := range games {
if !g.IsDir {
continue
}
candidate := path.Join(root, g.Name, "Sectors", "Sectors.yaml")
// Probe by attempting a read.
if data, err := h.empyrionReadFile(r.Context(), conn, id, candidate); err == nil && len(data) > 0 {
sectorsPath = candidate
break
} else {
_ = data
}
}
if sectorsPath == "" {
writeError(w, http.StatusNotFound, "no_sectors", "no Sectors.yaml in /game-saves/Saves/Games/*/Sectors/")
return
}
data, err := h.empyrionReadFile(r.Context(), conn, id, sectorsPath)
if err != nil {
writeError(w, http.StatusInternalServerError, "read_failed", err.Error())
return
}
var doc sectorsYAML
if err := yaml.Unmarshal(data, &doc); err != nil {
writeError(w, http.StatusInternalServerError, "yaml_parse", err.Error())
return
}
type starOut struct {
Name string `json:"name"`
X float64 `json:"x"`
Y float64 `json:"y"`
Z float64 `json:"z"`
StarClass string `json:"starClass"`
SectorCount int `json:"sectorCount"`
PlayfieldCount int `json:"playfieldCount"`
}
type pfOut struct {
Name string `json:"name"`
Type string `json:"type"`
}
type sectorOut struct {
Star string `json:"star"`
X float64 `json:"x"`
Y float64 `json:"y"`
Z float64 `json:"z"`
Type string `json:"type"`
Color string `json:"color,omitempty"`
Playfields []pfOut `json:"playfields,omitempty"`
}
stars := make([]starOut, 0, len(doc.SolarSystems))
sectors := make([]sectorOut, 0, 64)
for _, s := range doc.SolarSystems {
x, y, z := pickXYZ(s.Coordinates)
so := starOut{
Name: s.Name,
X: x, Y: y, Z: z,
StarClass: s.StarClass,
SectorCount: len(s.Sectors),
}
for _, sec := range s.Sectors {
sx, sy, sz := pickXYZ(sec.Coordinates)
pfs := make([]pfOut, 0, len(sec.Playfields))
for _, p := range sec.Playfields {
if len(p) >= 3 {
name, _ := p[1].(string)
typ, _ := p[2].(string)
pfs = append(pfs, pfOut{Name: strings.TrimSpace(name), Type: strings.TrimSpace(typ)})
}
}
so.PlayfieldCount += len(pfs)
sectors = append(sectors, sectorOut{
Star: s.Name, X: sx, Y: sy, Z: sz,
Type: sec.SectorMapType, Color: sec.Color,
Playfields: pfs,
})
}
stars = append(stars, so)
}
writeJSON(w, http.StatusOK, map[string]any{
"stars": stars,
"sectors": sectors,
"path": sectorsPath,
"galaxyMode": doc.GalaxyMode,
})
}
func pickXYZ(coords []float64) (x, y, z float64) {
for i, v := range coords {
switch i {
case 0:
x = v
case 1:
y = v
case 2:
z = v
}
}
return
}
// empyrionReadFile is a typed wrapper around the FsRead RPC matching the
// dayzXmlReadFile pattern; returns just the bytes (or an error).
func (h *httpServer) empyrionReadFile(ctx context.Context, conn *agentConn, instanceID, absPath string) ([]byte, error) {
corrID := newCorrelationID("emp_read")
if err := conn.Send(&panelv1.ControllerEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.ControllerEnvelope_FsRead{
FsRead: &panelv1.FsReadRequest{InstanceId: instanceID, Path: absPath},
},
}); err != nil {
return nil, err
}
env, err := h.pending.Await(ctx, corrID, 15*time.Second)
if err != nil {
return nil, err
}
res := env.GetFsReadResult()
if res == nil {
return nil, errors.New("no fs read reply")
}
if res.Error != "" {
return nil, errors.New(res.Error)
}
return res.Content, nil
}