From befb41c24fd189e1a3dc5680af018f80f5cb7f99 Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Mon, 2 Feb 2026 17:48:18 +0100 Subject: [PATCH 1/2] feat: implement TimeState storage for mission time sync data Add actual storage for the :NEW:TIME:STATE: command which records mission time synchronization data (frame, system time, mission date, time multiplier, mission time). Previously this was just acknowledged without storing. --- cmd/ocap_recorder/main.go | 19 +++++++++++-- internal/handlers/handlers.go | 44 ++++++++++++++++++++++++++++++ internal/handlers/handlers_test.go | 1 + internal/model/convert/convert.go | 13 +++++++++ internal/model/core/events.go | 12 ++++++++ internal/model/model.go | 16 +++++++++++ internal/storage/memory/memory.go | 10 +++++++ internal/storage/storage.go | 1 + internal/worker/dispatch_test.go | 19 +++++++++++++ internal/worker/worker.go | 1 + 10 files changed, 133 insertions(+), 3 deletions(-) diff --git a/cmd/ocap_recorder/main.go b/cmd/ocap_recorder/main.go index 87d8f84..aa97ab7 100644 --- a/cmd/ocap_recorder/main.go +++ b/cmd/ocap_recorder/main.go @@ -32,6 +32,7 @@ import ( "github.com/OCAP2/extension/v5/internal/handlers" "github.com/OCAP2/extension/v5/internal/logging" "github.com/OCAP2/extension/v5/internal/model" + "github.com/OCAP2/extension/v5/internal/model/convert" "github.com/OCAP2/extension/v5/internal/monitor" intOtel "github.com/OCAP2/extension/v5/internal/otel" "github.com/OCAP2/extension/v5/internal/storage" @@ -863,10 +864,22 @@ func registerLifecycleHandlers(d *dispatcher.Dispatcher) { // Time state tracking - records mission time sync data d.Register(":NEW:TIME:STATE:", func(e dispatcher.Event) (any, error) { - // Time state is currently not stored, just acknowledged - // Args: [frameNo, systemTimeUTC, missionDateTime, timeMultiplier, missionTime] + if handlerService == nil { + return nil, nil + } + + obj, err := handlerService.LogTimeState(e.Args) + if err != nil { + return nil, fmt.Errorf("failed to log time state: %w", err) + } + + if storageBackend != nil { + coreObj := convert.TimeStateToCore(obj) + storageBackend.RecordTimeState(&coreObj) + } + return "ok", nil - }) + }, dispatcher.Buffered(100)) d.Register(":SAVE:MISSION:", func(e dispatcher.Event) (any, error) { Logger.Info("Received :SAVE:MISSION: command, ending mission recording") diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index e06b854..08942fb 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -1215,6 +1215,50 @@ func (s *Service) LogFpsEvent(data []string) (model.ServerFpsEvent, error) { return fpsEvent, nil } +// LogTimeState parses time state data and returns a TimeState model +// Args: [frameNo, systemTimeUTC, missionDateTime, timeMultiplier, missionTime] +func (s *Service) LogTimeState(data []string) (model.TimeState, error) { + var timeState model.TimeState + + // fix received data + for i, v := range data { + data[i] = util.FixEscapeQuotes(util.TrimQuotes(v)) + } + + // get frame + frameStr := data[0] + capframe, err := strconv.ParseFloat(frameStr, 64) + if err != nil { + return timeState, fmt.Errorf(`error converting capture frame to int: %s`, err) + } + + timeState.CaptureFrame = uint(capframe) + timeState.Time = time.Now() + timeState.Mission = *s.ctx.GetMission() + + // systemTimeUTC - e.g., "2024-01-15T14:30:45.123" + timeState.SystemTimeUTC = data[1] + + // missionDateTime - e.g., "2035-06-15T06:00:00" + timeState.MissionDate = data[2] + + // timeMultiplier + timeMult, err := strconv.ParseFloat(data[3], 64) + if err != nil { + return timeState, fmt.Errorf(`error converting timeMultiplier to float: %v`, err) + } + timeState.TimeMultiplier = float32(timeMult) + + // missionTime (seconds since start) + missionTime, err := strconv.ParseFloat(data[4], 64) + if err != nil { + return timeState, fmt.Errorf(`error converting missionTime to float: %v`, err) + } + timeState.MissionTime = float32(missionTime) + + return timeState, nil +} + // LogAce3DeathEvent parses ACE3 death event data and returns an Ace3DeathEvent model func (s *Service) LogAce3DeathEvent(data []string) (model.Ace3DeathEvent, error) { var deathEvent model.Ace3DeathEvent diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index c75f137..f770587 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -32,6 +32,7 @@ func (b *mockBackend) RecordKillEvent(e *core.KillEvent) error { ret func (b *mockBackend) RecordChatEvent(e *core.ChatEvent) error { return nil } func (b *mockBackend) RecordRadioEvent(e *core.RadioEvent) error { return nil } func (b *mockBackend) RecordServerFpsEvent(e *core.ServerFpsEvent) error { return nil } +func (b *mockBackend) RecordTimeState(t *core.TimeState) error { return nil } func (b *mockBackend) RecordAce3DeathEvent(e *core.Ace3DeathEvent) error { return nil } func (b *mockBackend) RecordAce3UnconsciousEvent(e *core.Ace3UnconsciousEvent) error { return nil diff --git a/internal/model/convert/convert.go b/internal/model/convert/convert.go index 833c910..5452403 100644 --- a/internal/model/convert/convert.go +++ b/internal/model/convert/convert.go @@ -273,6 +273,19 @@ func ServerFpsEventToCore(e model.ServerFpsEvent) core.ServerFpsEvent { } } +// TimeStateToCore converts a GORM TimeState to a core.TimeState +func TimeStateToCore(t model.TimeState) core.TimeState { + return core.TimeState{ + MissionID: t.MissionID, + Time: t.Time, + CaptureFrame: t.CaptureFrame, + SystemTimeUTC: t.SystemTimeUTC, + MissionDate: t.MissionDate, + TimeMultiplier: t.TimeMultiplier, + MissionTime: t.MissionTime, + } +} + // Ace3DeathEventToCore converts a GORM Ace3DeathEvent to a core.Ace3DeathEvent func Ace3DeathEventToCore(e model.Ace3DeathEvent) core.Ace3DeathEvent { result := core.Ace3DeathEvent{ diff --git a/internal/model/core/events.go b/internal/model/core/events.go index 91fb2e5..a498fc8 100644 --- a/internal/model/core/events.go +++ b/internal/model/core/events.go @@ -116,3 +116,15 @@ type Ace3UnconsciousEvent struct { CaptureFrame uint IsAwake bool } + +// TimeState represents mission time synchronization data +type TimeState struct { + ID uint + MissionID uint + Time time.Time + CaptureFrame uint + SystemTimeUTC string + MissionDate string + TimeMultiplier float32 + MissionTime float32 +} diff --git a/internal/model/model.go b/internal/model/model.go index 9cd4698..60940d1 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -677,6 +677,22 @@ func (s *ServerFpsEvent) TableName() string { return "server_fps_events" } +// TimeState represents mission time synchronization data +type TimeState struct { + Time time.Time `json:"time" gorm:"type:timestamptz;"` + MissionID uint `json:"missionId" gorm:"index:idx_timestate_mission_id"` + Mission Mission `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignkey:MissionID;"` + CaptureFrame uint `json:"captureFrame" gorm:"index:idx_timestate_capture_frame;"` + SystemTimeUTC string `json:"systemTimeUtc" gorm:"size:64"` + MissionDate string `json:"missionDate" gorm:"size:64"` + TimeMultiplier float32 `json:"timeMultiplier"` + MissionTime float32 `json:"missionTime"` +} + +func (t *TimeState) TableName() string { + return "time_states" +} + // Marker represents a map marker type Marker struct { ID uint `json:"id" gorm:"primarykey;autoIncrement;"` diff --git a/internal/storage/memory/memory.go b/internal/storage/memory/memory.go index d0a949e..72e9175 100644 --- a/internal/storage/memory/memory.go +++ b/internal/storage/memory/memory.go @@ -46,6 +46,7 @@ type Backend struct { chatEvents []core.ChatEvent radioEvents []core.RadioEvent serverFpsEvents []core.ServerFpsEvent + timeStates []core.TimeState ace3DeathEvents []core.Ace3DeathEvent ace3UnconsciousEvents []core.Ace3UnconsciousEvent @@ -91,6 +92,7 @@ func (b *Backend) StartMission(mission *core.Mission, world *core.World) error { b.chatEvents = nil b.radioEvents = nil b.serverFpsEvents = nil + b.timeStates = nil b.ace3DeathEvents = nil b.ace3UnconsciousEvents = nil b.idCounter = 0 @@ -290,6 +292,14 @@ func (b *Backend) RecordServerFpsEvent(e *core.ServerFpsEvent) error { return nil } +// RecordTimeState records a time synchronization state +func (b *Backend) RecordTimeState(t *core.TimeState) error { + b.mu.Lock() + defer b.mu.Unlock() + b.timeStates = append(b.timeStates, *t) + return nil +} + // RecordAce3DeathEvent records an ACE3 death event func (b *Backend) RecordAce3DeathEvent(e *core.Ace3DeathEvent) error { b.mu.Lock() diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 9573908..0b6bcfa 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -31,6 +31,7 @@ type Backend interface { RecordChatEvent(e *core.ChatEvent) error RecordRadioEvent(e *core.RadioEvent) error RecordServerFpsEvent(e *core.ServerFpsEvent) error + RecordTimeState(t *core.TimeState) error RecordAce3DeathEvent(e *core.Ace3DeathEvent) error RecordAce3UnconsciousEvent(e *core.Ace3UnconsciousEvent) error diff --git a/internal/worker/dispatch_test.go b/internal/worker/dispatch_test.go index fb7b68e..aa2f194 100644 --- a/internal/worker/dispatch_test.go +++ b/internal/worker/dispatch_test.go @@ -53,6 +53,7 @@ type mockBackend struct { chatEvents []*core.ChatEvent radioEvents []*core.RadioEvent fpsEvents []*core.ServerFpsEvent + timeStates []*core.TimeState ace3Deaths []*core.Ace3DeathEvent ace3Uncon []*core.Ace3UnconsciousEvent initCalled bool @@ -183,6 +184,13 @@ func (b *mockBackend) RecordServerFpsEvent(e *core.ServerFpsEvent) error { return nil } +func (b *mockBackend) RecordTimeState(t *core.TimeState) error { + b.mu.Lock() + defer b.mu.Unlock() + b.timeStates = append(b.timeStates, t) + return nil +} + func (b *mockBackend) RecordAce3DeathEvent(e *core.Ace3DeathEvent) error { b.mu.Lock() defer b.mu.Unlock() @@ -247,6 +255,7 @@ type mockHandlerService struct { chatEvent model.ChatEvent radioEvent model.RadioEvent fpsEvent model.ServerFpsEvent + timeState model.TimeState ace3Death model.Ace3DeathEvent ace3Uncon model.Ace3UnconsciousEvent marker model.Marker @@ -382,6 +391,16 @@ func (h *mockHandlerService) LogFpsEvent(args []string) (model.ServerFpsEvent, e return h.fpsEvent, nil } +func (h *mockHandlerService) LogTimeState(args []string) (model.TimeState, error) { + h.mu.Lock() + defer h.mu.Unlock() + h.calls = append(h.calls, "LogTimeState") + if h.returnError { + return model.TimeState{}, errors.New(h.errorMsg) + } + return h.timeState, nil +} + func (h *mockHandlerService) LogAce3DeathEvent(args []string) (model.Ace3DeathEvent, error) { h.mu.Lock() defer h.mu.Unlock() diff --git a/internal/worker/worker.go b/internal/worker/worker.go index 3aa6032..5ff2e22 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -31,6 +31,7 @@ type HandlerService interface { LogChatEvent(args []string) (model.ChatEvent, error) LogRadioEvent(args []string) (model.RadioEvent, error) LogFpsEvent(args []string) (model.ServerFpsEvent, error) + LogTimeState(args []string) (model.TimeState, error) LogAce3DeathEvent(args []string) (model.Ace3DeathEvent, error) LogAce3UnconsciousEvent(args []string) (model.Ace3UnconsciousEvent, error) LogMarkerCreate(args []string) (model.Marker, error) From 79e26ef449521c6d35a8b1140fe49a5f4f9e2337 Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Mon, 2 Feb 2026 17:50:52 +0100 Subject: [PATCH 2/2] test: add tests for TimeState conversion and storage Improve test coverage for the TimeState implementation. --- internal/model/convert/convert_test.go | 36 ++++++++++++++++++++++++++ internal/storage/memory/memory_test.go | 33 +++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/internal/model/convert/convert_test.go b/internal/model/convert/convert_test.go index 872a599..1d7b7fb 100644 --- a/internal/model/convert/convert_test.go +++ b/internal/model/convert/convert_test.go @@ -492,6 +492,41 @@ func TestServerFpsEventToCore(t *testing.T) { } } +func TestTimeStateToCore(t *testing.T) { + now := time.Now() + + gormState := model.TimeState{ + MissionID: 1, + Time: now, + CaptureFrame: 100, + SystemTimeUTC: "2024-01-15T14:30:45.123", + MissionDate: "2035-06-15T06:00:00", + TimeMultiplier: 2.0, + MissionTime: 3600.5, + } + + coreState := TimeStateToCore(gormState) + + if coreState.MissionID != 1 { + t.Errorf("expected MissionID=1, got %d", coreState.MissionID) + } + if coreState.CaptureFrame != 100 { + t.Errorf("expected CaptureFrame=100, got %d", coreState.CaptureFrame) + } + if coreState.SystemTimeUTC != "2024-01-15T14:30:45.123" { + t.Errorf("expected SystemTimeUTC=2024-01-15T14:30:45.123, got %s", coreState.SystemTimeUTC) + } + if coreState.MissionDate != "2035-06-15T06:00:00" { + t.Errorf("expected MissionDate=2035-06-15T06:00:00, got %s", coreState.MissionDate) + } + if coreState.TimeMultiplier != 2.0 { + t.Errorf("expected TimeMultiplier=2.0, got %f", coreState.TimeMultiplier) + } + if coreState.MissionTime != 3600.5 { + t.Errorf("expected MissionTime=3600.5, got %f", coreState.MissionTime) + } +} + func TestAce3DeathEventToCore(t *testing.T) { now := time.Now() @@ -606,6 +641,7 @@ var ( _ core.ChatEvent = ChatEventToCore(model.ChatEvent{}) _ core.RadioEvent = RadioEventToCore(model.RadioEvent{}) _ core.ServerFpsEvent = ServerFpsEventToCore(model.ServerFpsEvent{}) + _ core.TimeState = TimeStateToCore(model.TimeState{}) _ core.Ace3DeathEvent = Ace3DeathEventToCore(model.Ace3DeathEvent{}) _ core.Ace3UnconsciousEvent = Ace3UnconsciousEventToCore(model.Ace3UnconsciousEvent{}) _ core.Marker = MarkerToCore(model.Marker{}) diff --git a/internal/storage/memory/memory_test.go b/internal/storage/memory/memory_test.go index 38dc084..8c4d035 100644 --- a/internal/storage/memory/memory_test.go +++ b/internal/storage/memory/memory_test.go @@ -549,6 +549,35 @@ func TestRecordServerFpsEvent(t *testing.T) { } } +func TestRecordTimeState(t *testing.T) { + b := New(config.MemoryConfig{}) + + now := time.Now() + state := &core.TimeState{ + MissionID: 1, + Time: now, + CaptureFrame: 100, + SystemTimeUTC: "2024-01-15T14:30:45.123", + MissionDate: "2035-06-15T06:00:00", + TimeMultiplier: 2.0, + MissionTime: 3600.5, + } + + if err := b.RecordTimeState(state); err != nil { + t.Fatalf("RecordTimeState failed: %v", err) + } + + if len(b.timeStates) != 1 { + t.Errorf("expected 1 state, got %d", len(b.timeStates)) + } + if b.timeStates[0].CaptureFrame != 100 { + t.Errorf("expected CaptureFrame=100, got %d", b.timeStates[0].CaptureFrame) + } + if b.timeStates[0].TimeMultiplier != 2.0 { + t.Errorf("expected TimeMultiplier=2.0, got %f", b.timeStates[0].TimeMultiplier) + } +} + func TestRecordAce3DeathEvent(t *testing.T) { b := New(config.MemoryConfig{}) @@ -664,6 +693,7 @@ func TestStartMissionResetsEverything(t *testing.T) { _ = b.RecordChatEvent(&core.ChatEvent{}) _ = b.RecordRadioEvent(&core.RadioEvent{}) _ = b.RecordServerFpsEvent(&core.ServerFpsEvent{}) + _ = b.RecordTimeState(&core.TimeState{}) _ = b.RecordAce3DeathEvent(&core.Ace3DeathEvent{}) _ = b.RecordAce3UnconsciousEvent(&core.Ace3UnconsciousEvent{}) @@ -699,6 +729,9 @@ func TestStartMissionResetsEverything(t *testing.T) { if len(b.serverFpsEvents) != 0 { t.Error("serverFpsEvents not reset") } + if len(b.timeStates) != 0 { + t.Error("timeStates not reset") + } if len(b.ace3DeathEvents) != 0 { t.Error("ace3DeathEvents not reset") }