658bda1d24
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
205 lines
6.0 KiB
Go
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
|
|
}
|