diff --git a/README.md b/README.md index 27cec96..2a5658b 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/cmd/droneops-sim/simulate.go b/cmd/droneops-sim/simulate.go index a2e149f..7d7136d 100644 --- a/cmd/droneops-sim/simulate.go +++ b/cmd/droneops-sim/simulate.go @@ -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 { diff --git a/go.mod b/go.mod index c1487b9..5dc415e 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 93f2c35..249aa69 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/sim/multi_writer.go b/internal/sim/multi_writer.go index 113e51c..20de376 100644 --- a/internal/sim/multi_writer.go +++ b/internal/sim/multi_writer.go @@ -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 { diff --git a/internal/sim/multi_writer_test.go b/internal/sim/multi_writer_test.go new file mode 100644 index 0000000..5f96c92 --- /dev/null +++ b/internal/sim/multi_writer_test.go @@ -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") + } +} diff --git a/internal/sim/simulator.go b/internal/sim/simulator.go index 6fbb7cf..8942611 100644 --- a/internal/sim/simulator.go +++ b/internal/sim/simulator.go @@ -10,6 +10,8 @@ import ( "sync" "time" + "github.com/google/uuid" + "droneops-sim/internal/config" "droneops-sim/internal/enemy" "droneops-sim/internal/telemetry" @@ -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() diff --git a/internal/sim/simulator_test.go b/internal/sim/simulator_test.go index 0aa5c68..7a163e2 100644 --- a/internal/sim/simulator_test.go +++ b/internal/sim/simulator_test.go @@ -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") + } +} diff --git a/internal/sim/spawner.go b/internal/sim/spawner.go new file mode 100644 index 0000000..06dffd0 --- /dev/null +++ b/internal/sim/spawner.go @@ -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)) +} diff --git a/internal/sim/tui_writer.go b/internal/sim/tui_writer.go index c41bd23..c0482ba 100644 --- a/internal/sim/tui_writer.go +++ b/internal/sim/tui_writer.go @@ -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" @@ -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 @@ -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 } @@ -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) @@ -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 { @@ -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 @@ -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) @@ -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 } @@ -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 { @@ -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 +} diff --git a/internal/sim/tui_writer_test.go b/internal/sim/tui_writer_test.go index 56f9edc..953c958 100644 --- a/internal/sim/tui_writer_test.go +++ b/internal/sim/tui_writer_test.go @@ -1,6 +1,7 @@ package sim import ( + "fmt" "strings" "testing" "time" @@ -26,22 +27,25 @@ func TestTUIWriterMessages(t *testing.T) { if _, ok := p.msgs[0].(logMsg); !ok { t.Fatalf("expected logMsg, got %T", p.msgs[0]) } + if _, ok := p.msgs[1].(telemetryMsg); !ok { + t.Fatalf("expected telemetryMsg, got %T", p.msgs[1]) + } st := telemetry.SimulationStateRow{MessagesSent: 1} if err := w.WriteState(st); err != nil { t.Fatalf("state: %v", err) } - if _, ok := p.msgs[1].(stateMsg); !ok { - t.Fatalf("expected stateMsg, got %T", p.msgs[1]) + if _, ok := p.msgs[2].(stateMsg); !ok { + t.Fatalf("expected stateMsg, got %T", p.msgs[2]) } w.SetAdminStatus(true) - if _, ok := p.msgs[2].(adminMsg); !ok { - t.Fatalf("expected adminMsg, got %T", p.msgs[2]) + if _, ok := p.msgs[3].(adminMsg); !ok { + t.Fatalf("expected adminMsg, got %T", p.msgs[3]) } d := enemy.DetectionRow{DroneID: "d", EnemyID: "e", Timestamp: time.Unix(0, 0).UTC()} if err := w.WriteDetection(d); err != nil { t.Fatalf("detect: %v", err) } - if _, ok := p.msgs[3].(logMsg); !ok { + if _, ok := p.msgs[4].(logMsg); !ok { t.Fatalf("expected logMsg for detection") } } @@ -118,3 +122,64 @@ func TestScrollToggle(t *testing.T) { t.Fatalf("expected YOffset %d after new log, got %d", expected, m.vp.YOffset) } } + +func TestEnemySpawn(t *testing.T) { + cfg := &config.SimulationConfig{} + m := newTUIModel(cfg, nil) + m.spawn = func(enemy.Enemy) {} + // provide last known drone position + mi, _ := m.Update(telemetryMsg{telemetry.TelemetryRow{Lat: 1, Lon: 2, Alt: 3}}) + m = mi.(tuiModel) + mi, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}) + m = mi.(tuiModel) + if !m.enemyDialog { + t.Fatalf("expected enemy dialog to open") + } + expected := fmt.Sprintf("vehicle,%.5f,%.5f,%.1f", 1+enemyOffset, 2+enemyOffset, 3.0) + if m.enemyInput.Value() != expected { + t.Fatalf("expected default input %q, got %q", expected, m.enemyInput.Value()) + } + mi, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = mi.(tuiModel) + if len(m.enemies) != 1 { + t.Fatalf("expected enemy added") + } + en := m.enemies[0] + if en.Type != enemy.EnemyVehicle || en.Position.Lat != 1+enemyOffset || en.Position.Lon != 2+enemyOffset || en.Position.Alt != 3 { + t.Fatalf("unexpected enemy spawned: %+v", en) + } +} + +func TestEnemySpawnFallback(t *testing.T) { + cfg := &config.SimulationConfig{} + m := newTUIModel(cfg, nil) + m.spawn = func(enemy.Enemy) {} + mi, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}) + m = mi.(tuiModel) + if m.enemyInput.Value() != fallbackEnemyInput { + t.Fatalf("expected fallback input %q, got %q", fallbackEnemyInput, m.enemyInput.Value()) + } +} + +func TestEnemySpawnHint(t *testing.T) { + cfg := &config.SimulationConfig{} + m := newTUIModel(cfg, nil) + mi, _ := m.Update(telemetryMsg{telemetry.TelemetryRow{Lat: 4, Lon: 5, Alt: 6}}) + m = mi.(tuiModel) + mi, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}) + m = mi.(tuiModel) + hint := m.renderEnemies() + if !strings.Contains(hint, "type,lat,lon,alt") { + t.Fatalf("expected input format hint, got %q", hint) + } + if !strings.Contains(hint, "Enter to spawn") { + t.Fatalf("expected Enter instruction, got %q", hint) + } + if !strings.Contains(hint, "Esc to cancel") { + t.Fatalf("expected Esc instruction, got %q", hint) + } + expected := fmt.Sprintf("vehicle,%.5f,%.5f,%.1f", 4+enemyOffset, 5+enemyOffset, 6.0) + if !strings.Contains(hint, expected) { + t.Fatalf("expected default value %q in hint, got %q", expected, hint) + } +}