From d7f4c4cb859d14be388bfd7ba69a8f39fb5cbeeb Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Tue, 3 Feb 2026 22:42:04 +0100 Subject: [PATCH 1/3] fix: include projectile events in v1 JSON export Grenades and smoke grenades were being captured via :PROJECTILE: command but not exported to JSON because the handler skipped memory backend storage and the v1 builder didn't include them. Changes: - Add ProjectileEventToFiredEvent converter to extract start/end positions from projectile trajectory LineStringZM geometry - Update handleProjectileEvent to record to memory backend by converting to FiredEvent format - Simplify framesFired format to [frameNum, [x, y, z]] to match old C++ extension and web frontend expectations The extended format with weapon/magazine/firingMode can be added in v2 when the web frontend supports it. --- internal/model/convert/convert.go | 34 ++++++++ internal/model/convert/convert_test.go | 81 +++++++++++++++++++ internal/storage/memory/export/v1/builder.go | 7 +- .../storage/memory/export/v1/builder_test.go | 15 ++-- internal/storage/memory/export_test.go | 32 ++++---- internal/worker/dispatch.go | 11 +++ 6 files changed, 151 insertions(+), 29 deletions(-) diff --git a/internal/model/convert/convert.go b/internal/model/convert/convert.go index fcdf5db..836d39b 100644 --- a/internal/model/convert/convert.go +++ b/internal/model/convert/convert.go @@ -371,6 +371,40 @@ func MarkerStateToCore(m model.MarkerState) core.MarkerState { } } +// ProjectileEventToFiredEvent converts a ProjectileEvent to a FiredEvent for the memory backend. +// This extracts start/end positions from the projectile trajectory for fireline rendering. +func ProjectileEventToFiredEvent(p model.ProjectileEvent) core.FiredEvent { + var startPos, endPos core.Position3D + + // Extract positions from the LineStringZM geometry + if !p.Positions.IsEmpty() { + if ls, ok := p.Positions.AsLineString(); ok { + seq := ls.Coordinates() + if seq.Length() > 0 { + // First point is start position + start := seq.Get(0) + startPos = core.Position3D{X: start.X, Y: start.Y, Z: start.Z} + + // Last point is end position + end := seq.Get(seq.Length() - 1) + endPos = core.Position3D{X: end.X, Y: end.Y, Z: end.Z} + } + } + } + + return core.FiredEvent{ + MissionID: p.MissionID, + SoldierID: p.FirerObjectID, + Time: p.Time, + CaptureFrame: p.CaptureFrame, + Weapon: p.Weapon, + Magazine: p.Magazine, + FiringMode: p.Mode, + StartPos: startPos, + EndPos: endPos, + } +} + // MissionToCore converts a GORM Mission to a core.Mission func MissionToCore(m *model.Mission) core.Mission { addons := make([]core.Addon, 0, len(m.Addons)) diff --git a/internal/model/convert/convert_test.go b/internal/model/convert/convert_test.go index bc684e1..6f627fa 100644 --- a/internal/model/convert/convert_test.go +++ b/internal/model/convert/convert_test.go @@ -625,6 +625,86 @@ func TestHitEventToCore_NilIDs(t *testing.T) { } } +func TestProjectileEventToFiredEvent(t *testing.T) { + now := time.Now() + + // Create a LineStringZM with 3 points (start, middle, end) + // Coordinates: X, Y, Z, M (M is timestamp) + coords := []float64{ + 100.0, 200.0, 10.0, 1000.0, // start position + 150.0, 250.0, 15.0, 1001.0, // middle position + 200.0, 300.0, 5.0, 1002.0, // end position (impact) + } + seq := geom.NewSequence(coords, geom.DimXYZM) + ls, _ := geom.NewLineString(seq) + + gormEvent := model.ProjectileEvent{ + MissionID: 1, + Time: now, + FirerObjectID: 42, + CaptureFrame: 100, + Weapon: "throw", + Magazine: "HandGrenade", + Mode: "HandGrenadeMuzzle", + Positions: ls.AsGeometry(), + } + + coreEvent := ProjectileEventToFiredEvent(gormEvent) + + if coreEvent.MissionID != 1 { + t.Errorf("expected MissionID=1, got %d", coreEvent.MissionID) + } + if coreEvent.SoldierID != 42 { + t.Errorf("expected SoldierID=42, got %d", coreEvent.SoldierID) + } + if coreEvent.CaptureFrame != 100 { + t.Errorf("expected CaptureFrame=100, got %d", coreEvent.CaptureFrame) + } + if coreEvent.Weapon != "throw" { + t.Errorf("expected Weapon=throw, got %s", coreEvent.Weapon) + } + if coreEvent.Magazine != "HandGrenade" { + t.Errorf("expected Magazine=HandGrenade, got %s", coreEvent.Magazine) + } + if coreEvent.FiringMode != "HandGrenadeMuzzle" { + t.Errorf("expected FiringMode=HandGrenadeMuzzle, got %s", coreEvent.FiringMode) + } + + // Start position should be first point + if coreEvent.StartPos.X != 100.0 || coreEvent.StartPos.Y != 200.0 || coreEvent.StartPos.Z != 10.0 { + t.Errorf("expected StartPos=(100,200,10), got (%f,%f,%f)", + coreEvent.StartPos.X, coreEvent.StartPos.Y, coreEvent.StartPos.Z) + } + + // End position should be last point + if coreEvent.EndPos.X != 200.0 || coreEvent.EndPos.Y != 300.0 || coreEvent.EndPos.Z != 5.0 { + t.Errorf("expected EndPos=(200,300,5), got (%f,%f,%f)", + coreEvent.EndPos.X, coreEvent.EndPos.Y, coreEvent.EndPos.Z) + } +} + +func TestProjectileEventToFiredEvent_EmptyPositions(t *testing.T) { + gormEvent := model.ProjectileEvent{ + MissionID: 1, + FirerObjectID: 42, + CaptureFrame: 100, + Weapon: "throw", + Positions: geom.Geometry{}, // empty geometry + } + + coreEvent := ProjectileEventToFiredEvent(gormEvent) + + // Should handle empty positions gracefully + if coreEvent.StartPos.X != 0 || coreEvent.StartPos.Y != 0 { + t.Errorf("expected zero StartPos for empty geometry, got (%f,%f)", + coreEvent.StartPos.X, coreEvent.StartPos.Y) + } + if coreEvent.EndPos.X != 0 || coreEvent.EndPos.Y != 0 { + t.Errorf("expected zero EndPos for empty geometry, got (%f,%f)", + coreEvent.EndPos.X, coreEvent.EndPos.Y) + } +} + // Compile-time interface checks var ( _ core.Soldier = SoldierToCore(model.Soldier{}) @@ -632,6 +712,7 @@ var ( _ core.Vehicle = VehicleToCore(model.Vehicle{}) _ core.VehicleState = VehicleStateToCore(model.VehicleState{}) _ core.FiredEvent = FiredEventToCore(model.FiredEvent{}) + _ core.FiredEvent = ProjectileEventToFiredEvent(model.ProjectileEvent{}) _ core.GeneralEvent = GeneralEventToCore(model.GeneralEvent{}) _ core.HitEvent = HitEventToCore(model.HitEvent{}) _ core.KillEvent = KillEventToCore(model.KillEvent{}) diff --git a/internal/storage/memory/export/v1/builder.go b/internal/storage/memory/export/v1/builder.go index 2815b62..d0cf812 100644 --- a/internal/storage/memory/export/v1/builder.go +++ b/internal/storage/memory/export/v1/builder.go @@ -128,13 +128,10 @@ func Build(data *MissionData) Export { } for _, fired := range record.FiredEvents { + // v1 format: [frameNum, [x, y, z]] - matches old C++ extension ff := []any{ fired.CaptureFrame, - []float64{fired.EndPos.X, fired.EndPos.Y}, - []float64{fired.StartPos.X, fired.StartPos.Y}, - fired.Weapon, - fired.Magazine, - fired.FiringMode, + []float64{fired.EndPos.X, fired.EndPos.Y, fired.EndPos.Z}, } entity.FramesFired = append(entity.FramesFired, ff) } diff --git a/internal/storage/memory/export/v1/builder_test.go b/internal/storage/memory/export/v1/builder_test.go index 1effe12..2c0d2aa 100644 --- a/internal/storage/memory/export/v1/builder_test.go +++ b/internal/storage/memory/export/v1/builder_test.go @@ -196,17 +196,16 @@ func TestBuildWithSoldier(t *testing.T) { assert.Equal(t, 1, pos[5]) // isPlayer assert.Equal(t, "Rifleman", pos[6]) // currentRole - // Check fired events + // Check fired events - v1 format: [frameNum, [x, y, z]] require.Len(t, entity.FramesFired, 1) ff := entity.FramesFired[0] - assert.Equal(t, uint(15), ff[0]) // captureFrame + require.Len(t, ff, 2) + assert.Equal(t, uint(15), ff[0]) // captureFrame endPos := ff[1].([]float64) - assert.Equal(t, 1200.0, endPos[0]) - startPos := ff[2].([]float64) - assert.Equal(t, 1050.0, startPos[0]) - assert.Equal(t, "arifle_MX_F", ff[3]) // weapon - assert.Equal(t, "30Rnd_65x39", ff[4]) // magazine - assert.Equal(t, "Single", ff[5]) // firingMode + require.Len(t, endPos, 3) + assert.Equal(t, 1200.0, endPos[0]) // X + assert.Equal(t, 2200.0, endPos[1]) // Y + assert.Equal(t, 0.0, endPos[2]) // Z // EndFrame should be max state frame assert.Equal(t, uint(20), export.EndFrame) diff --git a/internal/storage/memory/export_test.go b/internal/storage/memory/export_test.go index d37fbd1..1e4d92d 100644 --- a/internal/storage/memory/export_test.go +++ b/internal/storage/memory/export_test.go @@ -131,10 +131,13 @@ func TestIntegrationFullExport(t *testing.T) { assert.Equal(t, 1000.0, coords[0]) assert.Equal(t, 2000.0, coords[1]) - // Verify fired event + // Verify fired event - v1 format: [frameNum, [x, y, z]] ff := soldierEntity.FramesFired[0] + require.Len(t, ff, 2) assert.Equal(t, 5.0, ff[0]) // JSON unmarshals numbers as float64 - assert.Equal(t, "arifle_MX_F", ff[3]) + endPos, ok := ff[1].([]any) + require.True(t, ok, "endPos should be []any after JSON unmarshal") + require.Len(t, endPos, 3) // Verify vehicle entity require.NotNil(t, vehicleEntity, "vehicle entity not found") @@ -349,22 +352,16 @@ func TestFiredEventFormat(t *testing.T) { require.Len(t, export.Entities[1].FramesFired, 1) ff := export.Entities[1].FramesFired[0] - require.Len(t, ff, 6) // [captureFrame, [endX, endY], [startX, startY], weapon, magazine, firingMode] + // v1 format: [frameNum, [x, y, z]] - matches old C++ extension + require.Len(t, ff, 2) assert.Equal(t, uint(100), ff[0]) endPos, ok := ff[1].([]float64) require.True(t, ok, "endPos should be []float64") + require.Len(t, endPos, 3, "position should have X, Y, Z") assert.Equal(t, 300.0, endPos[0]) assert.Equal(t, 400.0, endPos[1]) - - startPos, ok := ff[2].([]float64) - require.True(t, ok, "startPos should be []float64") - assert.Equal(t, 100.0, startPos[0]) - assert.Equal(t, 200.0, startPos[1]) - - assert.Equal(t, "arifle_MX_F", ff[3]) - assert.Equal(t, "30Rnd_65x39_caseless_mag", ff[4]) - assert.Equal(t, "FullAuto", ff[5]) + assert.Equal(t, 1.8, endPos[2]) } func TestMarkerPositionFormat(t *testing.T) { @@ -646,12 +643,15 @@ func TestMultipleFiredEvents(t *testing.T) { entity := export.Entities[1] require.Len(t, entity.FramesFired, 3) - weapons := make(map[string]bool) + // v1 format: [frameNum, [x, y, z]] - verify frames and positions + frames := make(map[uint]bool) for _, ff := range entity.FramesFired { - weapons[ff[3].(string)] = true + require.Len(t, ff, 2) + frames[ff[0].(uint)] = true } - assert.True(t, weapons["arifle_MX_F"], "arifle_MX_F should be recorded") - assert.True(t, weapons["launch_NLAW_F"], "launch_NLAW_F should be recorded") + assert.True(t, frames[10], "frame 10 should be recorded") + assert.True(t, frames[15], "frame 15 should be recorded") + assert.True(t, frames[50], "frame 50 should be recorded") } func TestVehicleWithJoinFrame(t *testing.T) { diff --git a/internal/worker/dispatch.go b/internal/worker/dispatch.go index dd91894..c500f8a 100644 --- a/internal/worker/dispatch.go +++ b/internal/worker/dispatch.go @@ -149,6 +149,17 @@ func (m *Manager) handleFiredEvent(e dispatcher.Event) (any, error) { } func (m *Manager) handleProjectileEvent(e dispatcher.Event) (any, error) { + // For memory backend, convert projectile to fired event (simpler format) + if m.hasBackend() { + obj, err := m.deps.HandlerService.LogProjectileEvent(e.Args) + if err != nil { + return nil, fmt.Errorf("failed to log projectile event: %w", err) + } + coreObj := convert.ProjectileEventToFiredEvent(obj) + m.backend.RecordFiredEvent(&coreObj) + return nil, nil + } + // Projectile events use linestringzm geo format, not supported by SQLite if !m.deps.IsDatabaseValid() || m.deps.ShouldSaveLocal() { return nil, nil From 0cec567935aebb88b90a7a194b40f74bbf8339be Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Tue, 3 Feb 2026 22:56:33 +0100 Subject: [PATCH 2/3] fix: handle :TIMESTAMP: command in RVExtensionArgs The :TIMESTAMP: command was only handled in RVExtension (simple format) but not in RVExtensionArgs (args format). When the addon called via the args format, it resulted in "no handler registered" errors. --- pkg/a3interface/rvextension.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/a3interface/rvextension.go b/pkg/a3interface/rvextension.go index ba77710..2f24d77 100644 --- a/pkg/a3interface/rvextension.go +++ b/pkg/a3interface/rvextension.go @@ -79,6 +79,12 @@ func RVExtensionArgs(output *C.char, outputsize C.size_t, input *C.char, argv ** command := C.GoString(input) args := parseArgsFromC(argv, argc) + // Handle built-in timestamp command + if command == ":TIMESTAMP:" { + replyToSyncArmaCall(fmt.Sprintf(`["ok", "%s"]`, getTimestamp()), output, outputsize) + return + } + // Use dispatcher if Config.dispatcher != nil && Config.dispatcher.HasHandler(command) { event := dispatcher.Event{ From 46a5aa8ae31944e7d86d2a68da45c09effcadcb6 Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Tue, 3 Feb 2026 23:52:50 +0100 Subject: [PATCH 3/3] refactor: update :PROJECTILE: handler for SQF array format Replace JSON parsing with direct SQF array format parsing for better performance. The new format uses: - Indexed array elements instead of JSON object keys - diag_tickTime instead of extension :TIMESTAMP: calls - Stringified nested arrays for positions and hitParts This aligns with the addon changes that eliminate the JSON encoding overhead and remove the :TIMESTAMP: extension calls. --- internal/handlers/handlers.go | 292 +++++++++++++++++++--------------- 1 file changed, 164 insertions(+), 128 deletions(-) diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 7c5a7e0..6071c01 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -666,6 +666,27 @@ func (s *Service) LogFiredEvent(data []string) (model.FiredEvent, error) { } // LogProjectileEvent parses projectile event data and returns a ProjectileEvent model +// New SQF array format (indices): +// +// 0: firedFrame (uint) +// 1: firedTime (float - diag_tickTime) +// 2: firerID (uint) +// 3: vehicleID (uint, -1 if not in vehicle) +// 4: vehicleRole (string) +// 5: remoteControllerID (uint) +// 6: weapon (string) +// 7: weaponDisplay (string) +// 8: muzzle (string) +// 9: muzzleDisplay (string) +// 10: magazine (string) +// 11: magazineDisplay (string) +// 12: ammo (string) +// 13: fireMode (string) +// 14: positions (array string "[[tickTime,frameNo,\"x,y,z\"],...]") +// 15: initialVelocity (string "x,y,z") +// 16: hitParts (array string) +// 17: sim (string - simulation type) +// 18: isSub (bool - is submunition) func (s *Service) LogProjectileEvent(data []string) (model.ProjectileEvent, error) { var projectileEvent model.ProjectileEvent logger := s.deps.LogManager.Logger() @@ -675,78 +696,100 @@ func (s *Service) LogProjectileEvent(data []string) (model.ProjectileEvent, erro data[i] = util.FixEscapeQuotes(util.TrimQuotes(v)) } - projectileEvent.MissionID = s.ctx.GetMission().ID + if len(data) < 17 { + return projectileEvent, fmt.Errorf("insufficient data fields: got %d, need at least 17", len(data)) + } - logger.Debug("Projectile data", "data", data[0]) + projectileEvent.MissionID = s.ctx.GetMission().ID - var rawJsonData map[string]interface{} - err := json.Unmarshal([]byte(data[0]), &rawJsonData) + // [0] firedFrame + capframe, err := strconv.ParseFloat(data[0], 64) if err != nil { - return projectileEvent, fmt.Errorf(`error unmarshalling json data: %v`, err) + return projectileEvent, fmt.Errorf("error parsing firedFrame: %v", err) } + projectileEvent.CaptureFrame = uint(capframe) + + // [1] firedTime (diag_tickTime) - convert to Time using current time as base + // diag_tickTime is seconds since game start, so we just store current time + projectileEvent.Time = time.Now() - logger.Debug("Processing time and frame") - firedTime := rawJsonData["firedTime"].(string) - firedTimeInt, err := strconv.ParseInt(firedTime, 10, 64) + // [2] firerID + firerID, err := strconv.ParseUint(data[2], 10, 64) if err != nil { - return projectileEvent, fmt.Errorf(`error converting firedTime to int: %v`, err) + return projectileEvent, fmt.Errorf("error parsing firerID: %v", err) } - projectileEvent.Time = time.Unix(0, firedTimeInt) - projectileEvent.CaptureFrame = uint(rawJsonData["firedFrame"].(float64)) - - logger.Debug("Processing soldierFired") - soldierFired, ok := s.deps.EntityCache.GetSoldier(uint16(rawJsonData["firerID"].(float64))) + soldierFired, ok := s.deps.EntityCache.GetSoldier(uint16(firerID)) if !ok { - return projectileEvent, fmt.Errorf("soldier %d not found in cache", uint16(rawJsonData["firerID"].(float64))) + return projectileEvent, fmt.Errorf("soldier %d not found in cache", firerID) } projectileEvent.FirerObjectID = soldierFired.ObjectID - logger.Debug("Processing actualFirer") - actualFirer, ok := s.deps.EntityCache.GetSoldier(uint16(rawJsonData["remoteControllerID"].(float64))) + // [3] vehicleID (-1 if not in vehicle) + vehicleID, err := strconv.ParseInt(data[3], 10, 64) + if err != nil { + return projectileEvent, fmt.Errorf("error parsing vehicleID: %v", err) + } + if vehicleID >= 0 { + vehicle, ok := s.deps.EntityCache.GetVehicle(uint16(vehicleID)) + if ok { + projectileEvent.VehicleObjectID = sql.NullInt32{Int32: int32(vehicle.ObjectID), Valid: true} + } + } + + // [4] vehicleRole + projectileEvent.VehicleRole = data[4] + + // [5] remoteControllerID + remoteControllerID, err := strconv.ParseUint(data[5], 10, 64) + if err != nil { + return projectileEvent, fmt.Errorf("error parsing remoteControllerID: %v", err) + } + actualFirer, ok := s.deps.EntityCache.GetSoldier(uint16(remoteControllerID)) if !ok { - return projectileEvent, fmt.Errorf("soldier %d not found in cache", uint16(rawJsonData["remoteControllerID"].(float64))) + return projectileEvent, fmt.Errorf("soldier %d (remoteController) not found in cache", remoteControllerID) } projectileEvent.ActualFirerObjectID = actualFirer.ObjectID - logger.Debug("Processing vehicleID") - vehicleID := rawJsonData["vehicleID"].(float64) - vehicle, ok := s.deps.EntityCache.GetVehicle(uint16(vehicleID)) - if ok { - projectileEvent.VehicleObjectID = sql.NullInt32{ - Int32: int32(vehicle.ObjectID), - Valid: true, - } - } else { - projectileEvent.VehicleObjectID = sql.NullInt32{ - Int32: 0, - Valid: false, - } + // [6-13] weapon info + projectileEvent.Weapon = data[6] + projectileEvent.WeaponDisplay = data[7] + projectileEvent.Muzzle = data[8] + projectileEvent.MuzzleDisplay = data[9] + projectileEvent.Magazine = data[10] + projectileEvent.MagazineDisplay = data[11] + projectileEvent.Ammo = data[12] + projectileEvent.Mode = data[13] + + // [14] positions - SQF array "[[tickTime,frameNo,\"x,y,z\"],...]" + var positions [][]interface{} + if err := json.Unmarshal([]byte(data[14]), &positions); err != nil { + return projectileEvent, fmt.Errorf("error parsing positions: %v", err) } - // for Positions parsing, we need to create a Linestring with XYZM dimensions - logger.Debug("Projectile positions", "positions", rawJsonData["positions"]) positionSequence := []float64{} - for _, v := range rawJsonData["positions"].([]interface{}) { - posArr := v.([]interface{}) + for _, posArr := range positions { + if len(posArr) < 3 { + continue + } - logger.Debug("Projectile posArr", "posArr", posArr) + // posArr[0] = tickTime (float) + tickTime, ok := posArr[0].(float64) + if !ok { + logger.Warn("Invalid tickTime in position", "value", posArr[0]) + continue + } - // process time as posArr[0] - unixTimeNano := posArr[0].(string) - unixTimeNanoFloat, err := strconv.ParseFloat(unixTimeNano, 64) - if err != nil { - jsonData, _ := json.Marshal(posArr) - logger.Error("Error converting timestamp to float64", "error", err, "json", string(jsonData)) - return projectileEvent, err + // posArr[2] = "x,y,z" position string + posStr, ok := posArr[2].(string) + if !ok { + logger.Warn("Invalid position string", "value", posArr[2]) + continue } - // process actual position xyz as posArr[2] - pos := posArr[2].(string) - point, _, err := geo.Coord3857FromString(pos) + point, _, err := geo.Coord3857FromString(posStr) if err != nil { - jsonData, _ := json.Marshal(posArr) - logger.Error("Error converting position to Point", "error", err, "json", string(jsonData)) - return projectileEvent, err + logger.Warn("Error converting position to Point", "error", err, "pos", posStr) + continue } coords, _ := point.Coordinates() @@ -755,107 +798,100 @@ func (s *Service) LogProjectileEvent(data []string) (model.ProjectileEvent, erro coords.XY.X, coords.XY.Y, coords.Z, - unixTimeNanoFloat, + tickTime, ) } - // create the linestring - posSeq := geom.NewSequence(positionSequence, geom.DimXYZM) - ls, err := geom.NewLineString(posSeq) - if err != nil { - jsonData, _ := json.Marshal(posSeq) - logger.Error("Error creating linestring", "error", err, "json", string(jsonData)) - return projectileEvent, err + // create the linestring if we have positions + if len(positionSequence) >= 8 { // at least 2 points (4 values each) + posSeq := geom.NewSequence(positionSequence, geom.DimXYZM) + ls, err := geom.NewLineString(posSeq) + if err != nil { + logger.Warn("Error creating linestring", "error", err) + } else { + projectileEvent.Positions = ls.AsGeometry() + } } - logger.Debug("Created linestring", - "sequence", posSeq, - "linestring", ls, - "wkt", ls.AsText(), - "wkb", ls.AsBinary()) - - projectileEvent.Positions = ls.AsGeometry() + // [15] initialVelocity + projectileEvent.InitialVelocity = data[15] - logger.Debug("Processing hit events") - // hit events + // [16] hitParts - SQF array "[[entityID,[components],\"x,y,z\",frameNo],...]" projectileEvent.HitSoldiers = []model.ProjectileHitsSoldier{} projectileEvent.HitVehicles = []model.ProjectileHitsVehicle{} - for _, event := range rawJsonData["hitParts"].([]interface{}) { - eventArr := event.([]interface{}) - logger.Debug("Processing hit event", "eventArr", eventArr) - - // [1] is []string containing hit components - hitComponents := []string{} - for _, v := range eventArr[1].([]interface{}) { - hitComponents = append(hitComponents, v.(string)) - } + var hitParts [][]interface{} + if err := json.Unmarshal([]byte(data[16]), &hitParts); err != nil { + logger.Warn("Error parsing hitParts", "error", err, "data", data[16]) + } else { + for _, eventArr := range hitParts { + if len(eventArr) < 4 { + continue + } - // [2] is string with positionASL - hitPos := eventArr[2].(string) - hitPoint, _, err := geo.Coord3857FromString(hitPos) - if err != nil { - jsonData, _ := json.Marshal(eventArr) - logger.Error("Error converting hit position to Point", "error", err, "json", string(jsonData)) - return projectileEvent, err - } + // [0] hit entity ocap id + hitEntityID, ok := eventArr[0].(float64) + if !ok { + continue + } - // [3] is uint capture frame - hitFrame := eventArr[3].(float64) + // [1] hit component(s) - string for HitPart, array for HitExplosion + hitComponents := []string{} + switch comp := eventArr[1].(type) { + case string: + // Single component (HitPart event) + hitComponents = append(hitComponents, comp) + case []interface{}: + // Multiple components (HitExplosion event) + for _, v := range comp { + if s, ok := v.(string); ok { + hitComponents = append(hitComponents, s) + } + } + } - // marshal hit components to json array - hitComponentsJSON, err := json.Marshal(hitComponents) - if err != nil { - logger.Error("Error marshalling hit components to json", "error", err) - return projectileEvent, err - } + // [2] hit position "x,y,z" + hitPosStr, ok := eventArr[2].(string) + if !ok { + continue + } + hitPoint, _, err := geo.Coord3857FromString(hitPosStr) + if err != nil { + logger.Warn("Error converting hit position", "error", err) + continue + } - logger.Debug("Processed hit components", "hitComponents", hitComponents) + // [3] capture frame + hitFrame, ok := eventArr[3].(float64) + if !ok { + continue + } - // [0] is the hit entity ocap id - hitEntityID := eventArr[0].(float64) - hitEntity, ok := s.deps.EntityCache.GetSoldier(uint16(hitEntityID)) - if ok { - projectileEvent.HitSoldiers = append( - projectileEvent.HitSoldiers, - model.ProjectileHitsSoldier{ - SoldierObjectID: hitEntity.ObjectID, - ComponentsHit: hitComponentsJSON, - CaptureFrame: uint(hitFrame), - Position: hitPoint, - }, - ) - } else { - hitVehicle, ok := s.deps.EntityCache.GetVehicle(uint16(hitEntityID)) - if ok { - projectileEvent.HitVehicles = append( - projectileEvent.HitVehicles, + hitComponentsJSON, _ := json.Marshal(hitComponents) + + // Try soldier first, then vehicle + if hitEntity, ok := s.deps.EntityCache.GetSoldier(uint16(hitEntityID)); ok { + projectileEvent.HitSoldiers = append(projectileEvent.HitSoldiers, + model.ProjectileHitsSoldier{ + SoldierObjectID: hitEntity.ObjectID, + ComponentsHit: hitComponentsJSON, + CaptureFrame: uint(hitFrame), + Position: hitPoint, + }) + } else if hitVehicle, ok := s.deps.EntityCache.GetVehicle(uint16(hitEntityID)); ok { + projectileEvent.HitVehicles = append(projectileEvent.HitVehicles, model.ProjectileHitsVehicle{ VehicleObjectID: hitVehicle.ObjectID, - ComponentsHit: hitComponentsJSON, - CaptureFrame: uint(hitFrame), - Position: hitPoint, - }, - ) + ComponentsHit: hitComponentsJSON, + CaptureFrame: uint(hitFrame), + Position: hitPoint, + }) } else { logger.Warn("Hit entity not found in cache", "hitEntityID", uint16(hitEntityID)) } } } - logger.Debug("Processing other properties") - - projectileEvent.VehicleRole = rawJsonData["vehicleRole"].(string) - projectileEvent.Weapon = rawJsonData["weapon"].(string) - projectileEvent.WeaponDisplay = rawJsonData["weaponDisplay"].(string) - projectileEvent.Magazine = rawJsonData["magazine"].(string) - projectileEvent.MagazineDisplay = rawJsonData["magazineDisplay"].(string) - projectileEvent.Muzzle = rawJsonData["muzzle"].(string) - projectileEvent.MuzzleDisplay = rawJsonData["muzzleDisplay"].(string) - projectileEvent.Ammo = rawJsonData["ammo"].(string) - projectileEvent.Mode = rawJsonData["fireMode"].(string) - projectileEvent.InitialVelocity = rawJsonData["initialVelocity"].(string) - return projectileEvent, nil }