Phase 2 references for the host-agent Dune adapter, moved out of volatile /tmp
into docs/reference-repos/ (per Commander). Three upstream projects, .git +
node_modules + compiled binaries stripped (16MB source). Nested AI-instruction
files (.claude/, CLAUDE.md) removed so they don't pollute Corrosion sessions.
- icehunter/ dune-admin (Go+React) — 4 control planes; SETUP_DOCKER.md is the
closest analog to our agent's Dune docker control plane (compose
lifecycle, docker logs, RabbitMQ-via-exec, dune Postgres schema)
- adainrivers/ Rust/Tauri desktop — SSH+k8s BattleGroup control, maintenance
daemon, in-game admin console (Rust idiom reference)
- the4rchangel/ Node web UI replacing battlegroup.bat — matches the Commander's
Hyper-V self-host path + game-config schema
See docs/reference-repos/README.md for the full index + how we use each.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
644 lines
22 KiB
Go
644 lines
22 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// exitSetup prints a pause prompt on Windows, then exits with the given code.
|
|
func exitSetup(code int) {
|
|
if runtime.GOOS == "windows" {
|
|
fmt.Println()
|
|
fmt.Println("Press Enter to close...")
|
|
_, _ = bufio.NewReader(os.Stdin).ReadString('\n')
|
|
}
|
|
os.Exit(code)
|
|
}
|
|
|
|
func runSetup() {
|
|
r := bufio.NewReader(os.Stdin)
|
|
|
|
ask := func(label, def string) string {
|
|
if def != "" {
|
|
fmt.Printf(" %s [%s]: ", label, def)
|
|
} else {
|
|
fmt.Printf(" %s: ", label)
|
|
}
|
|
line, _ := r.ReadString('\n')
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
return def
|
|
}
|
|
return line
|
|
}
|
|
|
|
ok := func(msg string) { fmt.Printf(" ✓ %s\n", msg) }
|
|
fail := func(msg string) { fmt.Printf(" ✗ %s\n", msg) }
|
|
|
|
fmt.Println()
|
|
fmt.Println("=== dune-admin setup ===")
|
|
fmt.Println()
|
|
|
|
// ── 1. Control plane ───────────────────────────────────────────────────────
|
|
|
|
fmt.Println("Control plane:")
|
|
fmt.Println(" kubectl — Kubernetes (k3s) via SSH (current default)")
|
|
fmt.Println(" docker — Docker containers (docker CLI with named containers)")
|
|
fmt.Println(" local — Generic local (configurable shell commands)")
|
|
fmt.Println(" amp — CubeCoders AMP with podman game-server container")
|
|
fmt.Println()
|
|
ctrl := ask("Control plane", "kubectl")
|
|
if ctrl != "kubectl" && ctrl != "docker" && ctrl != "local" && ctrl != "amp" {
|
|
ctrl = "kubectl"
|
|
}
|
|
fmt.Println()
|
|
|
|
var cfg appConfig
|
|
cfg.Control = ctrl
|
|
|
|
switch ctrl {
|
|
case "kubectl":
|
|
runKubectlSetup(ask, ok, fail, &cfg)
|
|
case "docker":
|
|
runDockerSetup(ask, ok, fail, &cfg)
|
|
case "local":
|
|
runLocalSetup(ask, ok, fail, &cfg)
|
|
case "amp":
|
|
runAmpSetup(ask, ok, fail, &cfg)
|
|
}
|
|
|
|
// ── Embedded market bot ────────────────────────────────────────────────────
|
|
|
|
runMarketBotSetup(ask, ok, &cfg)
|
|
|
|
// ── Common: listen address ─────────────────────────────────────────────────
|
|
|
|
fmt.Println("Server config:")
|
|
defaultListenAddr := listenAddr
|
|
if ctrl == "amp" && (defaultListenAddr == "" || defaultListenAddr == ":8080") {
|
|
// AMP web panel commonly binds :8080 on host installs.
|
|
defaultListenAddr = ":18080"
|
|
}
|
|
cfg.ListenAddr = ask("HTTP listen address", defaultListenAddr)
|
|
fmt.Println()
|
|
|
|
// ── Write config ───────────────────────────────────────────────────────────
|
|
|
|
writeSetupConfig(ok, fail, cfg)
|
|
|
|
fmt.Println("Setup complete.")
|
|
fmt.Println()
|
|
fmt.Println(" Run: dune-admin")
|
|
fmt.Println()
|
|
}
|
|
|
|
// ── kubectl setup flow ────────────────────────────────────────────────────────
|
|
|
|
func setupKubectlSSHKey(ask func(string, string) string, ok, fail func(string)) string {
|
|
// SSH key
|
|
fmt.Println("Checking for SSH key...")
|
|
keyPath := resolveKeyPath()
|
|
if _, err := os.Stat(keyPath); err != nil {
|
|
fail("SSH key not found (checked ~/.dune-admin/sshKey, next to binary, ./sshKey)")
|
|
fmt.Println()
|
|
sshKeyPath = ask("Path to SSH private key", "")
|
|
if sshKeyPath == "" {
|
|
fmt.Fprintln(os.Stderr, "SSH key is required. Aborting.")
|
|
exitSetup(1)
|
|
}
|
|
if _, err := os.Stat(sshKeyPath); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Key not found at %s. Aborting.\n", sshKeyPath)
|
|
exitSetup(1)
|
|
}
|
|
keyPath = sshKeyPath
|
|
} else {
|
|
ok("SSH key: " + keyPath)
|
|
sshKeyPath = keyPath
|
|
}
|
|
fmt.Println()
|
|
return keyPath
|
|
}
|
|
|
|
func setupKubectlSSHConnection(
|
|
ask func(string, string) string,
|
|
ok, fail func(string),
|
|
keyPath string,
|
|
) *sshExecutor {
|
|
// SSH connection details
|
|
fmt.Println("SSH connection:")
|
|
sshHost = ask("VM host:port", envOr("SSH_HOST", "192.168.0.72:22"))
|
|
sshUser = ask("SSH user", envOr("SSH_USER", "dune"))
|
|
fmt.Println()
|
|
|
|
// Dial
|
|
fmt.Printf("Connecting via SSH to %s...\n", sshHost)
|
|
client, err := dialSSH(sshHost, sshUser, keyPath)
|
|
if err != nil {
|
|
fail("SSH failed: " + err.Error())
|
|
fmt.Println()
|
|
fmt.Printf(" Attempted: user=%s host=%s key=%s\n", sshUser, sshHost, keyPath)
|
|
fmt.Println()
|
|
fmt.Println(" Make sure:")
|
|
fmt.Println(" - The VM is reachable at the given host:port")
|
|
fmt.Println(" - The correct SSH private key is specified")
|
|
fmt.Println(" - That key's public key is in ~/.ssh/authorized_keys on the VM")
|
|
fmt.Println(" - The SSH user matches the account on the VM (default: dune)")
|
|
fmt.Println(" - The SSH user has passwordless sudo for kubectl")
|
|
exitSetup(1)
|
|
}
|
|
ok("SSH connected")
|
|
fmt.Println()
|
|
globalSSH = client
|
|
return &sshExecutor{client: client}
|
|
}
|
|
|
|
func setupKubectlDiscoverDBPod(exec *sshExecutor, ok, fail func(string), cfg *appConfig) {
|
|
// Discover DB pod
|
|
fmt.Println("Discovering database pod...")
|
|
ns, pod, podIP, err := discoverDBPod(exec)
|
|
if err != nil {
|
|
fail("Pod discovery failed: " + err.Error())
|
|
fmt.Println()
|
|
fmt.Println(" Make sure the SSH user can run: sudo kubectl get pods -A")
|
|
exitSetup(1)
|
|
}
|
|
globalPodNS = ns
|
|
globalPod = pod
|
|
globalPodIP = podIP
|
|
cfg.ControlNamespace = ns
|
|
controlNS = ns
|
|
ok("Database pod: " + pod)
|
|
fmt.Println()
|
|
}
|
|
|
|
func selectBattlegroup(battlegroups []string, ask func(string, string) string) string {
|
|
if len(battlegroups) == 0 {
|
|
return ""
|
|
}
|
|
chosen := battlegroups[0]
|
|
if len(battlegroups) == 1 {
|
|
return chosen
|
|
}
|
|
|
|
fmt.Println(" Available battlegroups:")
|
|
for i, bg := range battlegroups {
|
|
fmt.Printf(" [%d] %s\n", i+1, bg)
|
|
}
|
|
fmt.Println()
|
|
idxStr := ask(fmt.Sprintf("Which battlegroup? [1-%d]", len(battlegroups)), "1")
|
|
idx := 1
|
|
_, _ = fmt.Sscanf(idxStr, "%d", &idx)
|
|
if idx >= 1 && idx <= len(battlegroups) {
|
|
chosen = battlegroups[idx-1]
|
|
}
|
|
return chosen
|
|
}
|
|
|
|
func setupKubectlDiscoverDBCredentials(
|
|
exec *sshExecutor,
|
|
ask func(string, string) string,
|
|
ok, fail func(string),
|
|
cfg *appConfig,
|
|
) (string, string) {
|
|
// Discover DB password
|
|
fmt.Println("Discovering database password...")
|
|
discoveredUser := "postgres"
|
|
discoveredPass := ""
|
|
|
|
var battlegroups []string
|
|
if bg := battlegroupFromPod(globalPod); bg != "" {
|
|
battlegroups = []string{bg}
|
|
} else {
|
|
battlegroups = listBattlegroups(exec)
|
|
}
|
|
|
|
if len(battlegroups) == 0 {
|
|
fmt.Println(" Could not determine battlegroup name")
|
|
} else {
|
|
chosen := selectBattlegroup(battlegroups, ask)
|
|
yamlPath := fmt.Sprintf("~/.dune/%s.yaml", chosen)
|
|
if u, pass := extractPasswordFromYAML(exec, yamlPath); pass != "" {
|
|
discoveredUser = u
|
|
discoveredPass = pass
|
|
ok(fmt.Sprintf("Password found in %s (user: %s)", yamlPath, u))
|
|
} else {
|
|
fail("No password found in " + yamlPath)
|
|
}
|
|
}
|
|
|
|
if discoveredPass == "" {
|
|
fmt.Println()
|
|
fmt.Println(" Could not auto-discover the database password.")
|
|
discoveredUser = ask("Database user", "postgres")
|
|
discoveredPass = ask("Database password", "")
|
|
if discoveredPass == "" {
|
|
fmt.Fprintln(os.Stderr, "Database password is required. Aborting.")
|
|
exitSetup(1)
|
|
}
|
|
}
|
|
fmt.Println()
|
|
return discoveredUser, discoveredPass
|
|
}
|
|
|
|
func setupKubectlConnectDB(
|
|
discoveredUser, discoveredPass string,
|
|
exec *sshExecutor,
|
|
cfg *appConfig,
|
|
ok, fail func(string),
|
|
) {
|
|
// Connect to DB
|
|
fmt.Println("Connecting to database...")
|
|
dbUser = discoveredUser
|
|
dbPass = discoveredPass
|
|
pool, err := connectDB(context.Background(), discoveredUser, discoveredPass)
|
|
if err != nil {
|
|
fail("DB connect failed: " + err.Error())
|
|
fmt.Println()
|
|
fmt.Printf(" The password may be wrong. Delete %s and re-run to try again.\n", configPath())
|
|
exitSetup(1)
|
|
}
|
|
globalDB = pool
|
|
globalExecutor = exec
|
|
globalControl = newControlPlane("kubectl", *cfg)
|
|
ok("Database connected as: " + dbUser)
|
|
fmt.Println()
|
|
}
|
|
|
|
func runKubectlSetup(ask func(string, string) string, ok, fail func(string), cfg *appConfig) {
|
|
keyPath := setupKubectlSSHKey(ask, ok, fail)
|
|
exec := setupKubectlSSHConnection(ask, ok, fail, keyPath)
|
|
setupKubectlDiscoverDBPod(exec, ok, fail, cfg)
|
|
discoveredUser, discoveredPass := setupKubectlDiscoverDBCredentials(exec, ask, ok, fail, cfg)
|
|
setupKubectlConnectDB(discoveredUser, discoveredPass, exec, cfg, ok, fail)
|
|
|
|
cfg.ControlNamespace = globalPodNS
|
|
controlNS = globalPodNS
|
|
if abs, err := filepath.Abs(keyPath); err == nil {
|
|
keyPath = abs
|
|
}
|
|
cfg.SSHHost = sshHost
|
|
cfg.SSHUser = sshUser
|
|
cfg.SSHKey = keyPath
|
|
cfg.DBHost = "127.0.0.1"
|
|
cfg.DBPort = dbPort
|
|
cfg.DBUser = dbUser
|
|
cfg.DBPass = dbPass
|
|
cfg.DBName = dbName
|
|
cfg.DBSchema = dbSchema
|
|
cfg.ScripCurrency = scripCurrencyID
|
|
}
|
|
|
|
// ── docker setup flow ─────────────────────────────────────────────────────────
|
|
|
|
func runDockerSetup(ask func(string, string) string, ok, fail func(string), cfg *appConfig) {
|
|
fmt.Println("Docker container names:")
|
|
cfg.DockerGameserver = ask("Game server container name", "dune-gameserver")
|
|
cfg.DockerBrokerGame = ask("mq-game broker container name (optional)", "")
|
|
cfg.DockerBrokerAdmin = ask("mq-admin broker container name (optional)", "")
|
|
fmt.Println()
|
|
|
|
// Test docker access
|
|
exec := &localExecutor{}
|
|
out, err := exec.Exec(fmt.Sprintf("docker inspect --format '{{.State.Status}}' %s 2>&1", cfg.DockerGameserver))
|
|
if err != nil {
|
|
fail(fmt.Sprintf("docker inspect failed: %s", out))
|
|
fmt.Println(" Make sure Docker is running and the container name is correct.")
|
|
fmt.Println(" Continuing anyway...")
|
|
} else {
|
|
ok(fmt.Sprintf("Container %s is %s", cfg.DockerGameserver, strings.TrimSpace(out)))
|
|
}
|
|
fmt.Println()
|
|
|
|
fmt.Println("Database connection:")
|
|
cfg.DBHost = ask("DB host (Docker DNS or IP)", "database")
|
|
cfg.DBPort = dbPort
|
|
portStr := ask(fmt.Sprintf("DB port [%d]", dbPort), fmt.Sprintf("%d", dbPort))
|
|
_, _ = fmt.Sscanf(portStr, "%d", &cfg.DBPort)
|
|
cfg.DBUser = ask("DB user", envOr("DB_USER", "dune"))
|
|
cfg.DBPass = ask("DB password", "")
|
|
if cfg.DBPass == "" {
|
|
fmt.Fprintln(os.Stderr, "Database password is required. Aborting.")
|
|
exitSetup(1)
|
|
}
|
|
cfg.DBName = ask("DB name", envOr("DB_NAME", "dune"))
|
|
cfg.DBSchema = ask("DB schema", envOr("DB_SCHEMA", "dune"))
|
|
fmt.Println()
|
|
|
|
fmt.Println("Connecting to database...")
|
|
dbHost = cfg.DBHost
|
|
dbPort = cfg.DBPort
|
|
dbUser = cfg.DBUser
|
|
dbPass = cfg.DBPass
|
|
dbName = cfg.DBName
|
|
dbSchema = cfg.DBSchema
|
|
globalExecutor = exec
|
|
pool, err := connectDBDirect(context.Background(), *cfg)
|
|
if err != nil {
|
|
fail("DB connect failed: " + err.Error())
|
|
exitSetup(1)
|
|
}
|
|
globalDB = pool
|
|
globalControl = newControlPlane("docker", *cfg)
|
|
ok("Database connected as: " + cfg.DBUser)
|
|
fmt.Println()
|
|
|
|
cfg.ScripCurrency = scripCurrencyID
|
|
}
|
|
|
|
// ── local setup flow ──────────────────────────────────────────────────────────
|
|
|
|
func runLocalSetup(ask func(string, string) string, ok, fail func(string), cfg *appConfig) {
|
|
fmt.Println("Database connection:")
|
|
cfg.DBHost = ask("DB host", "127.0.0.1")
|
|
cfg.DBPort = dbPort
|
|
portStr := ask(fmt.Sprintf("DB port [%d]", dbPort), fmt.Sprintf("%d", dbPort))
|
|
_, _ = fmt.Sscanf(portStr, "%d", &cfg.DBPort)
|
|
cfg.DBUser = ask("DB user", envOr("DB_USER", "dune"))
|
|
cfg.DBPass = ask("DB password", "")
|
|
if cfg.DBPass == "" {
|
|
fmt.Fprintln(os.Stderr, "Database password is required. Aborting.")
|
|
exitSetup(1)
|
|
}
|
|
cfg.DBName = ask("DB name", envOr("DB_NAME", "dune"))
|
|
cfg.DBSchema = ask("DB schema", envOr("DB_SCHEMA", "dune"))
|
|
fmt.Println()
|
|
|
|
fmt.Println("Server control commands (optional — leave blank to skip):")
|
|
cfg.CmdStart = ask("Start command (e.g. 'amp start dune')", "")
|
|
cfg.CmdStop = ask("Stop command", "")
|
|
cfg.CmdRestart = ask("Restart command", "")
|
|
cfg.CmdStatus = ask("Status command", "")
|
|
fmt.Println()
|
|
|
|
fmt.Println("Connecting to database...")
|
|
dbHost = cfg.DBHost
|
|
dbPort = cfg.DBPort
|
|
dbUser = cfg.DBUser
|
|
dbPass = cfg.DBPass
|
|
dbName = cfg.DBName
|
|
dbSchema = cfg.DBSchema
|
|
exec := &localExecutor{}
|
|
globalExecutor = exec
|
|
pool, err := connectDBDirect(context.Background(), *cfg)
|
|
if err != nil {
|
|
fail("DB connect failed: " + err.Error())
|
|
exitSetup(1)
|
|
}
|
|
globalDB = pool
|
|
globalControl = newControlPlane("local", *cfg)
|
|
ok("Database connected as: " + cfg.DBUser)
|
|
fmt.Println()
|
|
|
|
cfg.ScripCurrency = scripCurrencyID
|
|
}
|
|
|
|
// ── amp setup flow ────────────────────────────────────────────────────────────
|
|
|
|
type ampSetupDefaults struct {
|
|
instance string
|
|
user string
|
|
topology string
|
|
selected *ampInstance
|
|
}
|
|
|
|
func detectAmpSetupDefaults(ask func(string, string) string) ampSetupDefaults {
|
|
d := ampSetupDefaults{instance: "DuneAwakening01", user: "amp", topology: "container"}
|
|
instances, detectedUser, _ := detectAmpInstances()
|
|
if len(instances) == 0 {
|
|
return d
|
|
}
|
|
fmt.Printf("Detected %d AMP instance(s):\n", len(instances))
|
|
for i, inst := range instances {
|
|
fmt.Printf(" %d) %s\n", i+1, summarizeInstance(inst))
|
|
}
|
|
choice := 1
|
|
if len(instances) > 1 {
|
|
pickStr := ask("Pick instance [1]", "1")
|
|
if n, err := strconv.Atoi(strings.TrimSpace(pickStr)); err == nil && n >= 1 && n <= len(instances) {
|
|
choice = n
|
|
}
|
|
}
|
|
d.selected = &instances[choice-1]
|
|
d.instance = d.selected.Name
|
|
if d.selected.InContainer {
|
|
d.topology = "container"
|
|
} else {
|
|
d.topology = "native"
|
|
}
|
|
if detectedUser != "" {
|
|
d.user = detectedUser
|
|
}
|
|
fmt.Println()
|
|
return d
|
|
}
|
|
|
|
// runAmpSetup configures the AMP control plane (CubeCoders AMP). AMP supports
|
|
// two deployment topologies — game server in a podman container, or running
|
|
// natively on the host as the AMP user. All paths/names are configurable.
|
|
//
|
|
// The wizard auto-detects AMP instances via `ampinstmgr -l` when available
|
|
// and pre-fills prompts with discovered values. Falls back to historical
|
|
// hardcoded defaults (DuneAwakening01, /AMP/duneawakening/...) when
|
|
// detection isn't possible — every prompt is still overridable.
|
|
func runAmpSetup(ask func(string, string) string, ok, fail func(string), cfg *appConfig) {
|
|
d := detectAmpSetupDefaults(ask)
|
|
defaultInstance := d.instance
|
|
defaultUser := d.user
|
|
defaultTopology := d.topology
|
|
selected := d.selected
|
|
|
|
fmt.Println("AMP instance:")
|
|
cfg.AmpInstance = ask("Instance name (ampinstmgr instance)", defaultInstance)
|
|
cfg.AmpUser = ask("OS user that runs AMP", defaultUser)
|
|
fmt.Println()
|
|
|
|
fmt.Println("AMP topology:")
|
|
fmt.Println(" container — game server runs inside the AMP container (podman or docker)")
|
|
fmt.Println(" native — game server runs directly on the host as the AMP user")
|
|
topology := ask("Topology [container/native]", defaultTopology)
|
|
useContainer := topology != "native"
|
|
cfg.AmpUseContainer = &useContainer
|
|
|
|
// ── Game install root: try to probe the container instead of hardcoding
|
|
// `/AMP/duneawakening`. Falls back to the historical default if the
|
|
// probe can't answer (container not running, non-standard layout, etc.)
|
|
gameRoot := "/AMP/duneawakening"
|
|
if useContainer && selected != nil && selected.InContainer {
|
|
probeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
container := "AMP_" + cfg.AmpInstance
|
|
if root, _ := probeGameRoot(probeCtx, cfg.AmpUser, container); root != "" {
|
|
gameRoot = root
|
|
fmt.Printf("Detected game install root: %s\n", gameRoot)
|
|
}
|
|
cancel()
|
|
}
|
|
gameFolder := filepath.Base(gameRoot) // e.g. "duneawakening"
|
|
|
|
defaultLogPath := gameRoot + "/logs"
|
|
defaultIniDir := fmt.Sprintf("/home/%s/.ampdata/instances/%s/%s/server/state",
|
|
cfg.AmpUser, cfg.AmpInstance, gameFolder)
|
|
// The game's stock Default*.ini ship under the extracted game-server tree,
|
|
// the same in container and native topologies.
|
|
defaultDefaultIniDir := gameRoot + "/extracted/game-server/home/dune/server/DuneSandbox/Config"
|
|
if !useContainer {
|
|
// Native topology: game files live under <game-root>/extracted/...
|
|
defaultLogPath = gameRoot + "/extracted/game-server/home/dune/server/DuneSandbox/Saved/Logs"
|
|
defaultIniDir = gameRoot + "/extracted/game-server/home/dune/server/DuneSandbox/Saved/Config/LinuxServer"
|
|
}
|
|
|
|
if useContainer {
|
|
defaultContainer := "AMP_" + cfg.AmpInstance
|
|
cfg.AmpContainer = ask("Container name", defaultContainer)
|
|
cfg.AmpContainerRuntime = ask("Container runtime [podman/docker]", "podman")
|
|
}
|
|
cfg.AmpLogPath = ask("Log directory", defaultLogPath)
|
|
cfg.DirectorURL = ask("Battlegroup Director URL (optional)", "http://127.0.0.1:11717")
|
|
fmt.Println()
|
|
|
|
// INI paths are fully determined by the AMP instance layout, so we derive
|
|
// them instead of asking. The instance state dir holds UserOverrides.ini
|
|
// (where dune-admin writes game settings) and ue5-saved/UserSettings (where
|
|
// runtime auto-discovery finds UserGame.ini / UserEngine.ini).
|
|
cfg.ServerIniDir = defaultIniDir
|
|
cfg.DefaultIniDir = defaultDefaultIniDir
|
|
fmt.Println("INI directories (auto-derived from the AMP instance):")
|
|
fmt.Printf(" instance state: %s\n", cfg.ServerIniDir)
|
|
fmt.Printf(" game defaults: %s\n", cfg.DefaultIniDir)
|
|
fmt.Println()
|
|
|
|
fmt.Println("RabbitMQ broker (used by capture mode AND live RMQ commands):")
|
|
var defaultBrokerPrefix string
|
|
if useContainer {
|
|
runtime := cfg.AmpContainerRuntime
|
|
if runtime == "" {
|
|
runtime = "podman"
|
|
}
|
|
defaultBrokerPrefix = fmt.Sprintf("sudo -i -u %s %s exec %s", cfg.AmpUser, runtime, cfg.AmpContainer)
|
|
} else {
|
|
defaultBrokerPrefix = fmt.Sprintf("sudo -i -u %s", cfg.AmpUser)
|
|
}
|
|
cfg.BrokerExecPrefix = ask("Broker exec prefix", defaultBrokerPrefix)
|
|
fmt.Println()
|
|
|
|
fmt.Println("Database connection:")
|
|
cfg.DBHost = ask("DB host", "127.0.0.1")
|
|
cfg.DBPort = dbPort
|
|
portStr := ask(fmt.Sprintf("DB port [%d]", dbPort), fmt.Sprintf("%d", dbPort))
|
|
_, _ = fmt.Sscanf(portStr, "%d", &cfg.DBPort)
|
|
cfg.DBUser = ask("DB user", envOr("DB_USER", "dune"))
|
|
cfg.DBPass = ask("DB password", "")
|
|
if cfg.DBPass == "" {
|
|
fmt.Fprintln(os.Stderr, "Database password is required. Aborting.")
|
|
exitSetup(1)
|
|
}
|
|
cfg.DBName = ask("DB name", envOr("DB_NAME", "dune"))
|
|
cfg.DBSchema = ask("DB schema", envOr("DB_SCHEMA", "dune"))
|
|
fmt.Println()
|
|
|
|
fmt.Println("Connecting to database...")
|
|
dbHost = cfg.DBHost
|
|
dbPort = cfg.DBPort
|
|
dbUser = cfg.DBUser
|
|
dbPass = cfg.DBPass
|
|
dbName = cfg.DBName
|
|
dbSchema = cfg.DBSchema
|
|
exec := &localExecutor{}
|
|
globalExecutor = &Executor{Executor: exec, ampUser: cfg.AmpUser}
|
|
pool, err := connectDBDirect(context.Background(), *cfg)
|
|
if err != nil {
|
|
fail("DB connect failed: " + err.Error())
|
|
exitSetup(1)
|
|
}
|
|
globalDB = pool
|
|
globalControl = newControlPlane("amp", *cfg)
|
|
ok("Database connected as: " + cfg.DBUser)
|
|
fmt.Println()
|
|
fmt.Println("Reminder: dune-admin writes game settings to UserOverrides.ini and engine")
|
|
fmt.Println("settings to UserEngine.ini as " + cfg.AmpUser + " (AMP owns UserGame.ini itself).")
|
|
fmt.Println("Example /etc/sudoers.d/dune-admin entry:")
|
|
fmt.Printf(" %s ALL=(%s) NOPASSWD: /usr/bin/tee %s/UserOverrides.ini, /usr/bin/tee %s/ue5-saved/UserSettings/UserEngine.ini\n",
|
|
envOr("USER", "dune-admin"), cfg.AmpUser, cfg.ServerIniDir, cfg.ServerIniDir)
|
|
fmt.Println(" (If the service user does not own the INI files, also add: /usr/bin/cat)")
|
|
fmt.Println()
|
|
|
|
cfg.ScripCurrency = scripCurrencyID
|
|
}
|
|
|
|
// ── Market bot setup ───────────────────────────────────────────────────────────
|
|
|
|
func runMarketBotSetup(ask func(string, string) string, ok func(string), cfg *appConfig) {
|
|
fmt.Println("Embedded market bot:")
|
|
enabled := strings.ToLower(strings.TrimSpace(ask("Enable embedded market bot [yes/no]", "yes")))
|
|
enabledBool := enabled != "n" && enabled != "no" && enabled != "false" && enabled != "0"
|
|
cfg.MarketBotEnabled = &enabledBool
|
|
if !enabledBool {
|
|
ok("Embedded market bot disabled")
|
|
|
|
// Offer remote proxy config.
|
|
remoteURL := strings.TrimSpace(ask("Remote bot URL (leave blank to skip)", ""))
|
|
if remoteURL != "" {
|
|
cfg.MarketBotRemoteURL = remoteURL
|
|
cfg.MarketBotRemoteToken = strings.TrimSpace(ask("Remote bot API token", ""))
|
|
ok("Remote market bot proxy configured")
|
|
}
|
|
fmt.Println()
|
|
return
|
|
}
|
|
|
|
cfg.MarketBotCacheDB = ask("Bot cache database path", filepath.Join(configDir(), "market-bot-cache.db"))
|
|
cfg.MarketBotItemData = ask("Bot item-data.json path (optional)", "")
|
|
|
|
parseFloat := func(input string, fallback float64) float64 {
|
|
v, err := strconv.ParseFloat(strings.TrimSpace(input), 64)
|
|
if err != nil || v <= 0 {
|
|
return fallback
|
|
}
|
|
return v
|
|
}
|
|
parseInt := func(input string, fallback int) int {
|
|
v, err := strconv.Atoi(strings.TrimSpace(input))
|
|
if err != nil || v <= 0 {
|
|
return fallback
|
|
}
|
|
return v
|
|
}
|
|
|
|
cfg.MarketBotBuyInt = ask("Buy tick interval", "5m")
|
|
cfg.MarketBotListInt = ask("List tick interval", "30m")
|
|
cfg.MarketBotThresh = parseFloat(ask("Buy threshold multiplier", "1.05"), 1.05)
|
|
cfg.MarketBotMaxBuys = parseInt(ask("Max buys per tick", "50"), 50)
|
|
ok("Embedded market bot configured")
|
|
fmt.Println()
|
|
}
|
|
|
|
// ── Write config ──────────────────────────────────────────────────────────────
|
|
|
|
func writeSetupConfig(ok, fail func(string), cfg appConfig) {
|
|
cfgDir := configDir()
|
|
if err := os.MkdirAll(cfgDir, 0700); err != nil {
|
|
fail("Failed to create config directory: " + err.Error())
|
|
exitSetup(1)
|
|
}
|
|
cfgData, err := yaml.Marshal(cfg)
|
|
if err != nil {
|
|
fail("Failed to marshal config: " + err.Error())
|
|
exitSetup(1)
|
|
}
|
|
cfgFile := configPath()
|
|
if err := os.WriteFile(cfgFile, cfgData, 0600); err != nil {
|
|
fail("Failed to write config: " + err.Error())
|
|
exitSetup(1)
|
|
}
|
|
ok("Config written to " + cfgFile)
|
|
fmt.Println()
|
|
}
|