Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ It supports:
- **Observer dashboard** for stepping through mission events, switching perspectives, and injecting commands
- **Swarm-event logs** for follower assignments, reassignments, and formation changes
- **Per-tick simulation state metrics** including communication reliability, sensor noise, weather impact, and chaos-mode status
- **Interactive TUI** with hotkeys (`q` quit, `w` wrap, `s` scroll, `e` spawn enemy via `type,lat,lon,alt` + `Enter`; dialog auto-fills near the latest drone position for quick spawning)

This project was designed to support visualization dashboards (e.g., Grafana Geomap panel) and multi-cluster sync scenarios (mission clusters → command cluster).

Expand Down
3 changes: 3 additions & 0 deletions cmd/droneops-sim/simulate.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ var simulateCmd = &cobra.Command{
defer cancel()

simulator := sim.NewSimulator(clusterID, cfg, writer, detectWriter, tickInterval, nil, nil)
if sp, ok := writer.(sim.EnemySpawner); ok {
sp.SetSpawner(simulator.SpawnEnemy)
}

srv := admin.NewServer(simulator)
if aw, ok := writer.(sim.AdminStatusWriter); ok {
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ require (
)

require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.9.3 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
Expand Down
9 changes: 9 additions & 0 deletions internal/sim/multi_writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,15 @@ func (mw *MultiWriter) SetAdminStatus(active bool) {
}
}

// SetSpawner forwards enemy spawn callbacks to writers that support it.
func (mw *MultiWriter) SetSpawner(fn func(enemy.Enemy)) {
for _, w := range mw.telewriters {
if sp, ok := w.(EnemySpawner); ok {
sp.SetSpawner(fn)
}
}
}

// Close closes underlying writers that support it.
func (mw *MultiWriter) Close() error {
for _, w := range mw.telewriters {
Expand Down
22 changes: 22 additions & 0 deletions internal/sim/multi_writer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package sim

import (
"testing"

"droneops-sim/internal/enemy"
"droneops-sim/internal/telemetry"
)

type stubSpawnWriter struct{ fn func(enemy.Enemy) }

func (s *stubSpawnWriter) Write(telemetry.TelemetryRow) error { return nil }
func (s *stubSpawnWriter) SetSpawner(f func(enemy.Enemy)) { s.fn = f }

func TestMultiWriterSetSpawner(t *testing.T) {
s := &stubSpawnWriter{}
mw := NewMultiWriter([]TelemetryWriter{s}, nil, nil)
mw.SetSpawner(func(enemy.Enemy) {})
if s.fn == nil {
t.Fatalf("spawner not forwarded")
}
}
19 changes: 19 additions & 0 deletions internal/sim/simulator.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"sync"
"time"

"github.com/google/uuid"

"droneops-sim/internal/config"
"droneops-sim/internal/enemy"
"droneops-sim/internal/telemetry"
Expand Down Expand Up @@ -274,6 +276,23 @@ func NewSimulator(clusterID string, cfg *config.SimulationConfig, writer Telemet
return sim
}

// SpawnEnemy adds a new enemy to the simulation.
func (s *Simulator) SpawnEnemy(en enemy.Enemy) {
s.mu.Lock()
defer s.mu.Unlock()
if s.enemyEng == nil {
s.enemyEng = enemy.NewEngine(0, nil, s.rand)
}
if en.ID == "" {
en.ID = uuid.New().String()
}
s.enemyEng.Enemies = append(s.enemyEng.Enemies, &en)
if s.enemyObjects == nil {
s.enemyObjects = make(map[string]*enemy.Enemy)
}
s.enemyObjects[en.ID] = &en
}

// ToggleChaos flips chaos mode on or off and returns the new state.
func (s *Simulator) ToggleChaos() bool {
s.mu.Lock()
Expand Down
8 changes: 8 additions & 0 deletions internal/sim/simulator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -955,3 +955,11 @@ func TestProcessDetectionsPopulatesFields(t *testing.T) {
t.Fatalf("velocity mismatch: got %f want %f", det.EnemyVelMS, expVel)
}
}

func TestSimulatorSpawnEnemy(t *testing.T) {
sim := &Simulator{rand: rand.New(rand.NewSource(1))}
sim.SpawnEnemy(enemy.Enemy{Type: enemy.EnemyPerson, Position: telemetry.Position{Lat: 1, Lon: 2, Alt: 3}})
if sim.enemyEng == nil || len(sim.enemyEng.Enemies) != 1 {
t.Fatalf("expected enemy to be spawned")
}
}
8 changes: 8 additions & 0 deletions internal/sim/spawner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package sim

import "droneops-sim/internal/enemy"

// EnemySpawner allows setting a callback used to spawn enemies.
type EnemySpawner interface {
SetSpawner(func(enemy.Enemy))
}
115 changes: 111 additions & 4 deletions internal/sim/tui_writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ package sim
import (
"fmt"
"os"
"strconv"
"strings"
"sync/atomic"
"time"

"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/google/uuid"
"github.com/muesli/reflow/wordwrap"

"droneops-sim/internal/config"
Expand All @@ -32,6 +35,14 @@ type stateMsg struct{ telemetry.SimulationStateRow }
// adminMsg reports admin UI status.
type adminMsg struct{ active bool }

type setSpawnMsg struct{ fn func(enemy.Enemy) }
type telemetryMsg struct{ telemetry.TelemetryRow }

const (
fallbackEnemyInput = "vehicle,0,0,0"
enemyOffset = 0.0001
)

// TUIWriter renders telemetry using a bubbletea TUI.
type TUIWriter struct {
program teaProgram
Expand Down Expand Up @@ -104,6 +115,7 @@ func (w *TUIWriter) Write(row telemetry.TelemetryRow) error {
line += fmt.Sprintf(" %sfollow%s", colorMagenta, colorReset)
}
w.program.Send(logMsg{line: line})
w.program.Send(telemetryMsg{row})
return nil
}

Expand Down Expand Up @@ -172,6 +184,11 @@ func (w *TUIWriter) SetAdminStatus(active bool) {
w.program.Send(adminMsg{active: active})
}

// SetSpawner registers a callback to spawn enemies.
func (w *TUIWriter) SetSpawner(fn func(enemy.Enemy)) {
w.program.Send(setSpawnMsg{fn: fn})
}

// Close shuts down the TUI program and waits for cleanup.
func (w *TUIWriter) Close() error {
w.sendSignal.Store(false)
Expand All @@ -197,6 +214,12 @@ type tuiModel struct {
headerHeight int
height int
missionColors map[string]string
enemies []enemy.Enemy
spawn func(enemy.Enemy)
enemyInput textinput.Model
enemyDialog bool
lastDrone telemetry.Position
haveDrone bool
}

func newTUIModel(cfg *config.SimulationConfig, missionColors map[string]string) tuiModel {
Expand Down Expand Up @@ -229,9 +252,36 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.header = m.renderHeader()
m.headerHeight = lipgloss.Height(m.header)
bottomHeight := lipgloss.Height(m.renderBottom())
m.vp.Height = m.height - m.headerHeight - bottomHeight - 2
enemyHeight := lipgloss.Height(m.renderEnemies())
m.vp.Height = m.height - m.headerHeight - bottomHeight - enemyHeight - 3
m.refreshViewport()
case tea.KeyMsg:
if m.enemyDialog {
switch msg.Type {
case tea.KeyEnter:
en, err := parseEnemyInput(m.enemyInput.Value())
if err == nil {
if m.spawn != nil {
m.spawn(en)
}
m.enemies = append(m.enemies, en)
}
m.enemyDialog = false
bottomHeight := lipgloss.Height(m.renderBottom())
enemyHeight := lipgloss.Height(m.renderEnemies())
m.vp.Height = m.height - m.headerHeight - bottomHeight - enemyHeight - 3
case tea.KeyEsc:
m.enemyDialog = false
bottomHeight := lipgloss.Height(m.renderBottom())
enemyHeight := lipgloss.Height(m.renderEnemies())
m.vp.Height = m.height - m.headerHeight - bottomHeight - enemyHeight - 3
default:
var cmd tea.Cmd
m.enemyInput, cmd = m.enemyInput.Update(msg)
return m, cmd
}
return m, nil
}
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
Expand All @@ -241,12 +291,27 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.header = m.renderHeader()
m.headerHeight = lipgloss.Height(m.header)
bottomHeight := lipgloss.Height(m.renderBottom())
m.vp.Height = m.height - m.headerHeight - bottomHeight - 2
enemyHeight := lipgloss.Height(m.renderEnemies())
m.vp.Height = m.height - m.headerHeight - bottomHeight - enemyHeight - 3
case "s":
m.autoscroll = !m.autoscroll
if m.autoscroll {
m.vp.GotoBottom()
}
case "e":
m.enemyInput = textinput.New()
m.enemyInput.Placeholder = "type,lat,lon,alt"
val := fallbackEnemyInput
if m.haveDrone {
val = fmt.Sprintf("vehicle,%.5f,%.5f,%.1f", m.lastDrone.Lat+enemyOffset, m.lastDrone.Lon+enemyOffset, m.lastDrone.Alt)
}
m.enemyInput.SetValue(val)
m.enemyInput.CursorEnd()
m.enemyInput.Focus()
m.enemyDialog = true
bottomHeight := lipgloss.Height(m.renderBottom())
enemyHeight := lipgloss.Height(m.renderEnemies())
m.vp.Height = m.height - m.headerHeight - bottomHeight - enemyHeight - 3
}
var cmd tea.Cmd
m.vp, cmd = m.vp.Update(msg)
Expand All @@ -257,10 +322,15 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.logs = m.logs[len(m.logs)-1000:]
}
m.refreshViewport()
case telemetryMsg:
m.lastDrone = telemetry.Position{Lat: msg.Lat, Lon: msg.Lon, Alt: msg.Alt}
m.haveDrone = true
case stateMsg:
m.state = msg.SimulationStateRow
case adminMsg:
m.admin = msg.active
case setSpawnMsg:
m.spawn = msg.fn
}
return m, nil
}
Expand All @@ -283,7 +353,8 @@ func (m *tuiModel) refreshViewport() {
func (m tuiModel) View() string {
bottom := m.renderBottom()
divider := strings.Repeat("─", m.vp.Width)
return fmt.Sprintf("%s\n%s\n%s\n%s\n%s", m.header, divider, m.vp.View(), divider, bottom)
enemies := m.renderEnemies()
return fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s\n%s", m.header, divider, m.vp.View(), divider, enemies, divider, bottom)
}

func (m tuiModel) renderHeader() string {
Expand Down Expand Up @@ -330,6 +401,42 @@ func (m tuiModel) renderBottom() string {
scrollIndicator := lipgloss.NewStyle().Foreground(scrollColor).Render("●")
state := fmt.Sprintf("%sSTATE%s comm_loss=%.2f msgs=%d sensor=%.2f weather=%.2f chaos=%t",
colorBlue, colorReset, m.state.CommunicationLoss, m.state.MessagesSent, m.state.SensorNoise, m.state.WeatherImpact, m.state.ChaosMode)
keys := lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Render("q:quit w:wrap s:scroll")
keys := lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Render("q:quit w:wrap s:scroll e:enemy")
return fmt.Sprintf("%s | Admin UI %s | Wrap %s | Scroll %s | %s", state, adminIndicator, wrapIndicator, scrollIndicator, keys)
}

func (m tuiModel) renderEnemies() string {
if m.enemyDialog {
return fmt.Sprintf("Spawn Enemy (type,lat,lon,alt) - Enter to spawn, Esc to cancel: %s", m.enemyInput.View())
}
if len(m.enemies) == 0 {
return "Enemies: none"
}
var b strings.Builder
b.WriteString("Enemies:\n")
for _, e := range m.enemies {
b.WriteString(fmt.Sprintf("%s %s lat=%.5f lon=%.5f alt=%.1f\n", e.ID, e.Type, e.Position.Lat, e.Position.Lon, e.Position.Alt))
}
return strings.TrimRight(b.String(), "\n")
}

func parseEnemyInput(val string) (enemy.Enemy, error) {
parts := strings.Split(val, ",")
if len(parts) < 4 {
return enemy.Enemy{}, fmt.Errorf("expected type,lat,lon,alt")
}
typ := enemy.EnemyType(strings.TrimSpace(parts[0]))
lat, err := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
if err != nil {
return enemy.Enemy{}, err
}
lon, err := strconv.ParseFloat(strings.TrimSpace(parts[2]), 64)
if err != nil {
return enemy.Enemy{}, err
}
alt, err := strconv.ParseFloat(strings.TrimSpace(parts[3]), 64)
if err != nil {
return enemy.Enemy{}, err
}
return enemy.Enemy{ID: uuid.New().String(), Type: typ, Position: telemetry.Position{Lat: lat, Lon: lon, Alt: alt}}, nil
}
Loading