diff --git a/internal/storage/memory/export.go b/internal/storage/memory/export.go index 8d94a58..f1d365f 100644 --- a/internal/storage/memory/export.go +++ b/internal/storage/memory/export.go @@ -8,80 +8,14 @@ import ( "os" "path/filepath" "strings" -) - -// OcapExport is the root JSON structure -// Note: Markers uses capital M for compatibility with ocap2-web -type OcapExport struct { - AddonVersion string `json:"addonVersion"` - ExtensionVersion string `json:"extensionVersion"` - ExtensionBuild string `json:"extensionBuild"` - MissionName string `json:"missionName"` - MissionAuthor string `json:"missionAuthor"` - WorldName string `json:"worldName"` - EndFrame uint `json:"endFrame"` - CaptureDelay float32 `json:"captureDelay"` - Tags string `json:"tags"` - Times []TimeJSON `json:"times"` - Entities []EntityJSON `json:"entities"` - Events [][]any `json:"events"` - Markers [][]any `json:"Markers"` // Capital M for ocap2-web compatibility -} - -// TimeJSON represents time synchronization data for a frame -type TimeJSON struct { - Date string `json:"date"` - FrameNum uint `json:"frameNum"` - SystemTimeUTC string `json:"systemTimeUTC"` - Time float32 `json:"time"` - TimeMultiplier float32 `json:"timeMultiplier"` -} - -// EntityJSON represents a soldier or vehicle -type EntityJSON struct { - ID uint16 `json:"id"` - Name string `json:"name"` - Group string `json:"group,omitempty"` - Side string `json:"side"` - IsPlayer int `json:"isPlayer"` - Type string `json:"type"` - Class string `json:"class,omitempty"` - StartFrameNum uint `json:"startFrameNum"` - Positions [][]any `json:"positions"` - FramesFired [][]any `json:"framesFired"` -} - -// parseMarkerSize converts size string "[w,h]" to []float64{w, h} -// Falls back to [1.0, 1.0] if parsing fails -func parseMarkerSize(sizeStr string) []float64 { - var size []float64 - if err := json.Unmarshal([]byte(sizeStr), &size); err != nil || len(size) != 2 { - return []float64{1.0, 1.0} - } - return size -} -// sideToIndex converts side string to numeric index for markers -// Input: result of "str side" from SQF (EAST, WEST, GUER, CIV, EMPTY, LOGIC, UNKNOWN) -// Returns: -1=GLOBAL, 0=EAST, 1=WEST, 2=GUER, 3=CIV -func sideToIndex(side string) int { - switch strings.ToUpper(side) { - case "EAST", "OPFOR": - return 0 - case "WEST", "BLUFOR": - return 1 - case "GUER", "INDEPENDENT": - return 2 - case "CIV", "CIVILIAN": - return 3 - default: - return -1 // GLOBAL (includes EMPTY, LOGIC, UNKNOWN) - } -} + v1 "github.com/OCAP2/extension/v5/internal/storage/memory/export/v1" +) // exportJSON writes the mission data to a gzipped JSON file +// Caller must hold b.mu lock. func (b *Backend) exportJSON() error { - export := b.buildExport() + export := b.buildExportUnlocked() // Build filename missionName := strings.ReplaceAll(b.mission.MissionName, " ", "_") @@ -104,11 +38,11 @@ func (b *Backend) exportJSON() error { // Write file if b.cfg.CompressOutput { - if err := b.writeGzipJSON(outputPath, export); err != nil { + if err := writeGzipJSON(outputPath, export); err != nil { return err } } else { - if err := b.writeJSON(outputPath, export); err != nil { + if err := writeJSON(outputPath, export); err != nil { return err } } @@ -117,286 +51,7 @@ func (b *Backend) exportJSON() error { return nil } -func (b *Backend) buildExport() OcapExport { - export := OcapExport{ - AddonVersion: b.mission.AddonVersion, - ExtensionVersion: b.mission.ExtensionVersion, - ExtensionBuild: b.mission.ExtensionBuild, - MissionName: b.mission.MissionName, - MissionAuthor: b.mission.Author, - WorldName: b.world.WorldName, - CaptureDelay: b.mission.CaptureDelay, - Tags: b.mission.Tag, - Times: make([]TimeJSON, 0, len(b.timeStates)), - Entities: make([]EntityJSON, 0), - Events: make([][]any, 0), - Markers: make([][]any, 0), - } - - // Convert time states - for _, ts := range b.timeStates { - export.Times = append(export.Times, TimeJSON{ - Date: ts.MissionDate, - FrameNum: ts.CaptureFrame, - SystemTimeUTC: ts.SystemTimeUTC, - Time: ts.MissionTime, - TimeMultiplier: ts.TimeMultiplier, - }) - } - - var maxFrame uint = 0 - - // Find max entity ID to size the entities array correctly - // The JS frontend uses entities[id] to look up entities, so array index must equal entity ID - var maxEntityID uint16 = 0 - hasEntities := len(b.soldiers) > 0 || len(b.vehicles) > 0 - for _, record := range b.soldiers { - if record.Soldier.ID > maxEntityID { - maxEntityID = record.Soldier.ID - } - } - for _, record := range b.vehicles { - if record.Vehicle.ID > maxEntityID { - maxEntityID = record.Vehicle.ID - } - } - - // Create entities array with placeholder entries - // Index N will contain entity with ID=N - if hasEntities { - export.Entities = make([]EntityJSON, maxEntityID+1) - } - - // Convert soldiers - place at index matching their ID - for _, record := range b.soldiers { - entity := EntityJSON{ - ID: record.Soldier.ID, - Name: record.Soldier.UnitName, - Group: record.Soldier.GroupID, - Side: record.Soldier.Side, - IsPlayer: boolToInt(record.Soldier.IsPlayer), - Type: "unit", - StartFrameNum: record.Soldier.JoinFrame, - Positions: make([][]any, 0, len(record.States)), - FramesFired: make([][]any, 0, len(record.FiredEvents)), - } - - for _, state := range record.States { - // Convert nil InVehicleObjectID to 0 (old C++ extension uses 0 for "not in vehicle") - var inVehicleID any = 0 - if state.InVehicleObjectID != nil { - inVehicleID = *state.InVehicleObjectID - } - - pos := []any{ - []float64{state.Position.X, state.Position.Y}, - state.Bearing, - state.Lifestate, - inVehicleID, - state.UnitName, - boolToInt(state.IsPlayer), - state.CurrentRole, - } - entity.Positions = append(entity.Positions, pos) - if state.CaptureFrame > maxFrame { - maxFrame = state.CaptureFrame - } - } - - for _, fired := range record.FiredEvents { - ff := []any{ - fired.CaptureFrame, - []float64{fired.EndPos.X, fired.EndPos.Y}, - []float64{fired.StartPos.X, fired.StartPos.Y}, - fired.Weapon, - fired.Magazine, - fired.FiringMode, - } - entity.FramesFired = append(entity.FramesFired, ff) - } - - export.Entities[record.Soldier.ID] = entity - } - - // Convert vehicles - place at index matching their ID - for _, record := range b.vehicles { - entity := EntityJSON{ - ID: record.Vehicle.ID, - Name: record.Vehicle.DisplayName, - Side: "UNKNOWN", - IsPlayer: 0, - Type: record.Vehicle.OcapType, - Class: record.Vehicle.ClassName, - StartFrameNum: record.Vehicle.JoinFrame, - Positions: make([][]any, 0, len(record.States)), - FramesFired: [][]any{}, - } - - for _, state := range record.States { - // Parse crew JSON string into actual JSON array - var crew any - if state.Crew != "" { - if err := json.Unmarshal([]byte(state.Crew), &crew); err != nil { - crew = []any{} // Fallback to empty array on parse error - } - } else { - crew = []any{} - } - - pos := []any{ - []float64{state.Position.X, state.Position.Y}, - state.Bearing, - boolToInt(state.IsAlive), - crew, - } - entity.Positions = append(entity.Positions, pos) - if state.CaptureFrame > maxFrame { - maxFrame = state.CaptureFrame - } - } - - export.Entities[record.Vehicle.ID] = entity - } - - export.EndFrame = maxFrame - - // Convert general events - // Format: [frameNum, "type", message] - for _, evt := range b.generalEvents { - // Try to parse message as JSON - if it's a valid JSON array/object, use parsed value - // Otherwise keep as string - var message any = evt.Message - if len(evt.Message) > 0 && (evt.Message[0] == '[' || evt.Message[0] == '{') { - var parsed any - if err := json.Unmarshal([]byte(evt.Message), &parsed); err == nil { - message = parsed - } - } - export.Events = append(export.Events, []any{ - evt.CaptureFrame, - evt.Name, - message, - }) - } - - // Convert hit events - // Format: [frameNum, "hit", victimId, [causedById, weapon], distance] - for _, evt := range b.hitEvents { - var victimID uint - if evt.VictimVehicleID != nil { - victimID = *evt.VictimVehicleID - } else if evt.VictimSoldierID != nil { - victimID = *evt.VictimSoldierID - } - - var sourceID uint - if evt.ShooterVehicleID != nil { - sourceID = *evt.ShooterVehicleID - } else if evt.ShooterSoldierID != nil { - sourceID = *evt.ShooterSoldierID - } - - export.Events = append(export.Events, []any{ - evt.CaptureFrame, - "hit", - victimID, - []any{sourceID, evt.EventText}, // [causedById, weapon] - evt.Distance, - }) - } - - // Convert kill events - // Format: [frameNum, "killed", victimId, [causedById, weapon], distance] - for _, evt := range b.killEvents { - var victimID uint - if evt.VictimVehicleID != nil { - victimID = *evt.VictimVehicleID - } else if evt.VictimSoldierID != nil { - victimID = *evt.VictimSoldierID - } - - var killerID uint - if evt.KillerVehicleID != nil { - killerID = *evt.KillerVehicleID - } else if evt.KillerSoldierID != nil { - killerID = *evt.KillerSoldierID - } - - export.Events = append(export.Events, []any{ - evt.CaptureFrame, - "killed", - victimID, - []any{killerID, evt.EventText}, // [causedById, weapon] - evt.Distance, - }) - } - - // Convert markers - // Format: [type, text, startFrame, endFrame, playerId, color, sideIndex, positions, size, shape, brush] - // positions is always: [[frameNum, pos, direction, alpha], ...] - // For POLYLINE: pos is [[x1,y1],[x2,y2],...] (array of coordinates) - // For other shapes: pos is [x, y] (single coordinate) - for _, record := range b.markers { - posArray := make([][]any, 0) - - if record.Marker.Shape == "POLYLINE" { - // For polylines: pos contains the coordinate array - coords := make([][]float64, len(record.Marker.Polyline)) - for i, pt := range record.Marker.Polyline { - coords[i] = []float64{pt.X, pt.Y} - } - posArray = append(posArray, []any{ - record.Marker.CaptureFrame, - coords, // [[x1,y1], [x2,y2], ...] - record.Marker.Direction, - record.Marker.Alpha, - }) - } else { - // For other shapes: pos is a single coordinate - posArray = append(posArray, []any{ - record.Marker.CaptureFrame, - []float64{record.Marker.Position.X, record.Marker.Position.Y}, - record.Marker.Direction, - record.Marker.Alpha, - }) - - // State changes - for _, state := range record.States { - posArray = append(posArray, []any{ - state.CaptureFrame, - []float64{state.Position.X, state.Position.Y}, - state.Direction, - state.Alpha, - }) - } - } - - // Strip "#" prefix from hex colors (e.g., "#800000" -> "800000") for URL compatibility - // The web UI constructs URLs like: /images/markers/${type}/${color}.png - // With "#" prefix, browsers interpret the fragment as an anchor, causing 404s - markerColor := strings.TrimPrefix(record.Marker.Color, "#") - - marker := []any{ - record.Marker.MarkerType, // [0] type - record.Marker.Text, // [1] text - record.Marker.CaptureFrame, // [2] startFrame - -1, // [3] endFrame (-1 = persists until end) - record.Marker.OwnerID, // [4] playerId (entity ID of creating player, -1 for system markers) - markerColor, // [5] color (# prefix stripped for URL compatibility) - sideToIndex(record.Marker.Side), // [6] sideIndex - posArray, // [7] positions - parseMarkerSize(record.Marker.Size), // [8] size - record.Marker.Shape, // [9] shape - record.Marker.Brush, // [10] brush - } - - export.Markers = append(export.Markers, marker) - } - - return export -} - -func (b *Backend) writeJSON(path string, data OcapExport) error { +func writeJSON(path string, data v1.Export) error { f, err := os.Create(path) if err != nil { return fmt.Errorf("failed to create file: %w", err) @@ -407,7 +62,7 @@ func (b *Backend) writeJSON(path string, data OcapExport) error { return encoder.Encode(data) } -func (b *Backend) writeGzipJSON(path string, data OcapExport) error { +func writeGzipJSON(path string, data v1.Export) error { f, err := os.Create(path) if err != nil { return fmt.Errorf("failed to create file: %w", err) @@ -420,10 +75,3 @@ func (b *Backend) writeGzipJSON(path string, data OcapExport) error { encoder := json.NewEncoder(gzWriter) return encoder.Encode(data) } - -func boolToInt(b bool) int { - if b { - return 1 - } - return 0 -} diff --git a/internal/storage/memory/export/v1/builder.go b/internal/storage/memory/export/v1/builder.go new file mode 100644 index 0000000..2815b62 --- /dev/null +++ b/internal/storage/memory/export/v1/builder.go @@ -0,0 +1,356 @@ +package v1 + +import ( + "encoding/json" + "strings" + + "github.com/OCAP2/extension/v5/internal/model/core" +) + +// MissionData contains all the data needed to build an export +type MissionData struct { + Mission *core.Mission + World *core.World + Soldiers map[uint16]*SoldierRecord + Vehicles map[uint16]*VehicleRecord + Markers map[string]*MarkerRecord + + GeneralEvents []core.GeneralEvent + HitEvents []core.HitEvent + KillEvents []core.KillEvent + TimeStates []core.TimeState +} + +// SoldierRecord groups a soldier with all its time-series data +type SoldierRecord struct { + Soldier core.Soldier + States []core.SoldierState + FiredEvents []core.FiredEvent +} + +// VehicleRecord groups a vehicle with all its time-series data +type VehicleRecord struct { + Vehicle core.Vehicle + States []core.VehicleState +} + +// MarkerRecord groups a marker with all its state changes +type MarkerRecord struct { + Marker core.Marker + States []core.MarkerState +} + +// Build creates an Export from the mission data +func Build(data *MissionData) Export { + export := Export{ + AddonVersion: data.Mission.AddonVersion, + ExtensionVersion: data.Mission.ExtensionVersion, + ExtensionBuild: data.Mission.ExtensionBuild, + MissionName: data.Mission.MissionName, + MissionAuthor: data.Mission.Author, + WorldName: data.World.WorldName, + CaptureDelay: data.Mission.CaptureDelay, + Tags: data.Mission.Tag, + Times: make([]Time, 0, len(data.TimeStates)), + Entities: make([]Entity, 0), + Events: make([][]any, 0), + Markers: make([][]any, 0), + } + + // Convert time states + for _, ts := range data.TimeStates { + export.Times = append(export.Times, Time{ + Date: ts.MissionDate, + FrameNum: ts.CaptureFrame, + SystemTimeUTC: ts.SystemTimeUTC, + Time: ts.MissionTime, + TimeMultiplier: ts.TimeMultiplier, + }) + } + + var maxFrame uint = 0 + + // Find max entity ID to size the entities array correctly + // The JS frontend uses entities[id] to look up entities, so array index must equal entity ID + var maxEntityID uint16 = 0 + hasEntities := len(data.Soldiers) > 0 || len(data.Vehicles) > 0 + for _, record := range data.Soldiers { + if record.Soldier.ID > maxEntityID { + maxEntityID = record.Soldier.ID + } + } + for _, record := range data.Vehicles { + if record.Vehicle.ID > maxEntityID { + maxEntityID = record.Vehicle.ID + } + } + + // Create entities array with placeholder entries + // Index N will contain entity with ID=N + if hasEntities { + export.Entities = make([]Entity, maxEntityID+1) + } + + // Convert soldiers - place at index matching their ID + for _, record := range data.Soldiers { + entity := Entity{ + ID: record.Soldier.ID, + Name: record.Soldier.UnitName, + Group: record.Soldier.GroupID, + Side: record.Soldier.Side, + IsPlayer: boolToInt(record.Soldier.IsPlayer), + Type: "unit", + StartFrameNum: record.Soldier.JoinFrame, + Positions: make([][]any, 0, len(record.States)), + FramesFired: make([][]any, 0, len(record.FiredEvents)), + } + + for _, state := range record.States { + // Convert nil InVehicleObjectID to 0 (old C++ extension uses 0 for "not in vehicle") + var inVehicleID any = 0 + if state.InVehicleObjectID != nil { + inVehicleID = *state.InVehicleObjectID + } + + pos := []any{ + []float64{state.Position.X, state.Position.Y}, + state.Bearing, + state.Lifestate, + inVehicleID, + state.UnitName, + boolToInt(state.IsPlayer), + state.CurrentRole, + } + entity.Positions = append(entity.Positions, pos) + if state.CaptureFrame > maxFrame { + maxFrame = state.CaptureFrame + } + } + + for _, fired := range record.FiredEvents { + ff := []any{ + fired.CaptureFrame, + []float64{fired.EndPos.X, fired.EndPos.Y}, + []float64{fired.StartPos.X, fired.StartPos.Y}, + fired.Weapon, + fired.Magazine, + fired.FiringMode, + } + entity.FramesFired = append(entity.FramesFired, ff) + } + + export.Entities[record.Soldier.ID] = entity + } + + // Convert vehicles - place at index matching their ID + for _, record := range data.Vehicles { + entity := Entity{ + ID: record.Vehicle.ID, + Name: record.Vehicle.DisplayName, + Side: "UNKNOWN", + IsPlayer: 0, + Type: record.Vehicle.OcapType, + Class: record.Vehicle.ClassName, + StartFrameNum: record.Vehicle.JoinFrame, + Positions: make([][]any, 0, len(record.States)), + FramesFired: [][]any{}, + } + + for _, state := range record.States { + // Parse crew JSON string into actual JSON array + var crew any + if state.Crew != "" { + if err := json.Unmarshal([]byte(state.Crew), &crew); err != nil { + crew = []any{} // Fallback to empty array on parse error + } + } else { + crew = []any{} + } + + pos := []any{ + []float64{state.Position.X, state.Position.Y}, + state.Bearing, + boolToInt(state.IsAlive), + crew, + } + entity.Positions = append(entity.Positions, pos) + if state.CaptureFrame > maxFrame { + maxFrame = state.CaptureFrame + } + } + + export.Entities[record.Vehicle.ID] = entity + } + + export.EndFrame = maxFrame + + // Convert general events + // Format: [frameNum, "type", message] + for _, evt := range data.GeneralEvents { + // Try to parse message as JSON - if it's a valid JSON array/object, use parsed value + // Otherwise keep as string + var message any = evt.Message + if len(evt.Message) > 0 && (evt.Message[0] == '[' || evt.Message[0] == '{') { + var parsed any + if err := json.Unmarshal([]byte(evt.Message), &parsed); err == nil { + message = parsed + } + } + export.Events = append(export.Events, []any{ + evt.CaptureFrame, + evt.Name, + message, + }) + } + + // Convert hit events + // Format: [frameNum, "hit", victimId, [causedById, weapon], distance] + for _, evt := range data.HitEvents { + var victimID uint + if evt.VictimVehicleID != nil { + victimID = *evt.VictimVehicleID + } else if evt.VictimSoldierID != nil { + victimID = *evt.VictimSoldierID + } + + var sourceID uint + if evt.ShooterVehicleID != nil { + sourceID = *evt.ShooterVehicleID + } else if evt.ShooterSoldierID != nil { + sourceID = *evt.ShooterSoldierID + } + + export.Events = append(export.Events, []any{ + evt.CaptureFrame, + "hit", + victimID, + []any{sourceID, evt.EventText}, // [causedById, weapon] + evt.Distance, + }) + } + + // Convert kill events + // Format: [frameNum, "killed", victimId, [causedById, weapon], distance] + for _, evt := range data.KillEvents { + var victimID uint + if evt.VictimVehicleID != nil { + victimID = *evt.VictimVehicleID + } else if evt.VictimSoldierID != nil { + victimID = *evt.VictimSoldierID + } + + var killerID uint + if evt.KillerVehicleID != nil { + killerID = *evt.KillerVehicleID + } else if evt.KillerSoldierID != nil { + killerID = *evt.KillerSoldierID + } + + export.Events = append(export.Events, []any{ + evt.CaptureFrame, + "killed", + victimID, + []any{killerID, evt.EventText}, // [causedById, weapon] + evt.Distance, + }) + } + + // Convert markers + // Format: [type, text, startFrame, endFrame, playerId, color, sideIndex, positions, size, shape, brush] + // positions is always: [[frameNum, pos, direction, alpha], ...] + // For POLYLINE: pos is [[x1,y1],[x2,y2],...] (array of coordinates) + // For other shapes: pos is [x, y] (single coordinate) + for _, record := range data.Markers { + posArray := make([][]any, 0) + + if record.Marker.Shape == "POLYLINE" { + // For polylines: pos contains the coordinate array + coords := make([][]float64, len(record.Marker.Polyline)) + for i, pt := range record.Marker.Polyline { + coords[i] = []float64{pt.X, pt.Y} + } + posArray = append(posArray, []any{ + record.Marker.CaptureFrame, + coords, // [[x1,y1], [x2,y2], ...] + record.Marker.Direction, + record.Marker.Alpha, + }) + } else { + // For other shapes: pos is a single coordinate + posArray = append(posArray, []any{ + record.Marker.CaptureFrame, + []float64{record.Marker.Position.X, record.Marker.Position.Y}, + record.Marker.Direction, + record.Marker.Alpha, + }) + + // State changes + for _, state := range record.States { + posArray = append(posArray, []any{ + state.CaptureFrame, + []float64{state.Position.X, state.Position.Y}, + state.Direction, + state.Alpha, + }) + } + } + + // Strip "#" prefix from hex colors (e.g., "#800000" -> "800000") for URL compatibility + // The web UI constructs URLs like: /images/markers/${type}/${color}.png + // With "#" prefix, browsers interpret the fragment as an anchor, causing 404s + markerColor := strings.TrimPrefix(record.Marker.Color, "#") + + marker := []any{ + record.Marker.MarkerType, // [0] type + record.Marker.Text, // [1] text + record.Marker.CaptureFrame, // [2] startFrame + -1, // [3] endFrame (-1 = persists until end) + record.Marker.OwnerID, // [4] playerId (entity ID of creating player, -1 for system markers) + markerColor, // [5] color (# prefix stripped for URL compatibility) + sideToIndex(record.Marker.Side), // [6] sideIndex + posArray, // [7] positions + parseMarkerSize(record.Marker.Size), // [8] size + record.Marker.Shape, // [9] shape + record.Marker.Brush, // [10] brush + } + + export.Markers = append(export.Markers, marker) + } + + return export +} + +// parseMarkerSize converts size string "[w,h]" to []float64{w, h} +// Falls back to [1.0, 1.0] if parsing fails +func parseMarkerSize(sizeStr string) []float64 { + var size []float64 + if err := json.Unmarshal([]byte(sizeStr), &size); err != nil || len(size) != 2 { + return []float64{1.0, 1.0} + } + return size +} + +// sideToIndex converts side string to numeric index for markers +// Input: result of "str side" from SQF (EAST, WEST, GUER, CIV, EMPTY, LOGIC, UNKNOWN) +// Returns: -1=GLOBAL, 0=EAST, 1=WEST, 2=GUER, 3=CIV +func sideToIndex(side string) int { + switch strings.ToUpper(side) { + case "EAST", "OPFOR": + return 0 + case "WEST", "BLUFOR": + return 1 + case "GUER", "INDEPENDENT": + return 2 + case "CIV", "CIVILIAN": + return 3 + default: + return -1 // GLOBAL (includes EMPTY, LOGIC, UNKNOWN) + } +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/internal/storage/memory/export/v1/builder_test.go b/internal/storage/memory/export/v1/builder_test.go new file mode 100644 index 0000000..1effe12 --- /dev/null +++ b/internal/storage/memory/export/v1/builder_test.go @@ -0,0 +1,695 @@ +package v1 + +import ( + "testing" + + "github.com/OCAP2/extension/v5/internal/model/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBoolToInt(t *testing.T) { + assert.Equal(t, 1, boolToInt(true)) + assert.Equal(t, 0, boolToInt(false)) +} + +func TestSideToIndex(t *testing.T) { + tests := []struct { + side string + expected int + }{ + {"EAST", 0}, + {"east", 0}, + {"OPFOR", 0}, + {"opfor", 0}, + {"WEST", 1}, + {"west", 1}, + {"BLUFOR", 1}, + {"blufor", 1}, + {"GUER", 2}, + {"guer", 2}, + {"INDEPENDENT", 2}, + {"independent", 2}, + {"CIV", 3}, + {"civ", 3}, + {"CIVILIAN", 3}, + {"civilian", 3}, + {"EMPTY", -1}, + {"LOGIC", -1}, + {"UNKNOWN", -1}, + {"", -1}, + {"GLOBAL", -1}, + } + + for _, tt := range tests { + t.Run(tt.side, func(t *testing.T) { + assert.Equal(t, tt.expected, sideToIndex(tt.side)) + }) + } +} + +func TestParseMarkerSize(t *testing.T) { + tests := []struct { + name string + input string + expected []float64 + }{ + {"valid size", "[2.5,3.0]", []float64{2.5, 3.0}}, + {"integer size", "[1,2]", []float64{1, 2}}, + {"empty string", "", []float64{1.0, 1.0}}, + {"invalid json", "[1,2,3", []float64{1.0, 1.0}}, + {"wrong length", "[1]", []float64{1.0, 1.0}}, + {"too many elements", "[1,2,3]", []float64{1.0, 1.0}}, + {"not an array", "1.5", []float64{1.0, 1.0}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, parseMarkerSize(tt.input)) + }) + } +} + +func TestBuildEmptyMission(t *testing.T) { + data := &MissionData{ + Mission: &core.Mission{MissionName: "Empty", Author: "Test"}, + World: &core.World{WorldName: "Altis"}, + Soldiers: make(map[uint16]*SoldierRecord), + Vehicles: make(map[uint16]*VehicleRecord), + Markers: make(map[string]*MarkerRecord), + } + + export := Build(data) + + assert.Equal(t, "Empty", export.MissionName) + assert.Equal(t, "Test", export.MissionAuthor) + assert.Equal(t, "Altis", export.WorldName) + assert.Empty(t, export.Entities) + assert.Empty(t, export.Events) + assert.Empty(t, export.Markers) + assert.Empty(t, export.Times) + assert.Equal(t, uint(0), export.EndFrame) +} + +func TestBuildWithMissionMetadata(t *testing.T) { + data := &MissionData{ + Mission: &core.Mission{ + MissionName: "Test Mission", + Author: "Test Author", + AddonVersion: "1.0.0", + ExtensionVersion: "2.0.0", + ExtensionBuild: "Build 123", + CaptureDelay: 1.5, + Tag: "TvT", + }, + World: &core.World{WorldName: "Tanoa"}, + Soldiers: make(map[uint16]*SoldierRecord), + Vehicles: make(map[uint16]*VehicleRecord), + Markers: make(map[string]*MarkerRecord), + } + + export := Build(data) + + assert.Equal(t, "Test Mission", export.MissionName) + assert.Equal(t, "Test Author", export.MissionAuthor) + assert.Equal(t, "Tanoa", export.WorldName) + assert.Equal(t, "1.0.0", export.AddonVersion) + assert.Equal(t, "2.0.0", export.ExtensionVersion) + assert.Equal(t, "Build 123", export.ExtensionBuild) + assert.Equal(t, float32(1.5), export.CaptureDelay) + assert.Equal(t, "TvT", export.Tags) +} + +func TestBuildWithTimeStates(t *testing.T) { + data := &MissionData{ + Mission: &core.Mission{MissionName: "Test"}, + World: &core.World{WorldName: "Altis"}, + Soldiers: make(map[uint16]*SoldierRecord), + Vehicles: make(map[uint16]*VehicleRecord), + Markers: make(map[string]*MarkerRecord), + TimeStates: []core.TimeState{ + {CaptureFrame: 0, MissionDate: "2035-06-24", SystemTimeUTC: "2024-01-01T10:00:00", MissionTime: 0, TimeMultiplier: 1.0}, + {CaptureFrame: 100, MissionDate: "2035-06-24", SystemTimeUTC: "2024-01-01T10:01:00", MissionTime: 60, TimeMultiplier: 2.0}, + }, + } + + export := Build(data) + + require.Len(t, export.Times, 2) + assert.Equal(t, uint(0), export.Times[0].FrameNum) + assert.Equal(t, "2035-06-24", export.Times[0].Date) + assert.Equal(t, "2024-01-01T10:00:00", export.Times[0].SystemTimeUTC) + assert.Equal(t, float32(0), export.Times[0].Time) + assert.Equal(t, float32(1.0), export.Times[0].TimeMultiplier) + + assert.Equal(t, uint(100), export.Times[1].FrameNum) + assert.Equal(t, float32(60), export.Times[1].Time) + assert.Equal(t, float32(2.0), export.Times[1].TimeMultiplier) +} + +func TestBuildWithSoldier(t *testing.T) { + data := &MissionData{ + Mission: &core.Mission{MissionName: "Test"}, + World: &core.World{WorldName: "Altis"}, + Soldiers: map[uint16]*SoldierRecord{ + 5: { + Soldier: core.Soldier{ + ID: 5, UnitName: "Player1", GroupID: "Alpha", Side: "WEST", IsPlayer: true, JoinFrame: 10, + }, + States: []core.SoldierState{ + {SoldierID: 5, CaptureFrame: 10, Position: core.Position3D{X: 1000, Y: 2000}, Bearing: 90, Lifestate: 1, UnitName: "Player1", IsPlayer: true, CurrentRole: "Rifleman"}, + {SoldierID: 5, CaptureFrame: 20, Position: core.Position3D{X: 1100, Y: 2100}, Bearing: 95, Lifestate: 1, UnitName: "Player1", IsPlayer: true, CurrentRole: "Rifleman"}, + }, + FiredEvents: []core.FiredEvent{ + {SoldierID: 5, CaptureFrame: 15, Weapon: "arifle_MX_F", Magazine: "30Rnd_65x39", FiringMode: "Single", StartPos: core.Position3D{X: 1050, Y: 2050}, EndPos: core.Position3D{X: 1200, Y: 2200}}, + }, + }, + }, + Vehicles: make(map[uint16]*VehicleRecord), + Markers: make(map[string]*MarkerRecord), + } + + export := Build(data) + + // Sparse array: entity at index 5 + require.Len(t, export.Entities, 6) + entity := export.Entities[5] + + assert.Equal(t, uint16(5), entity.ID) + assert.Equal(t, "Player1", entity.Name) + assert.Equal(t, "Alpha", entity.Group) + assert.Equal(t, "WEST", entity.Side) + assert.Equal(t, 1, entity.IsPlayer) + assert.Equal(t, "unit", entity.Type) + assert.Equal(t, uint(10), entity.StartFrameNum) + + // Check positions + require.Len(t, entity.Positions, 2) + pos := entity.Positions[0] + coords := pos[0].([]float64) + assert.Equal(t, 1000.0, coords[0]) + assert.Equal(t, 2000.0, coords[1]) + assert.Equal(t, uint16(90), pos[1]) // bearing + assert.Equal(t, uint8(1), pos[2]) // lifestate + assert.Equal(t, 0, pos[3]) // inVehicleID (nil -> 0) + assert.Equal(t, "Player1", pos[4]) // unitName + assert.Equal(t, 1, pos[5]) // isPlayer + assert.Equal(t, "Rifleman", pos[6]) // currentRole + + // Check fired events + require.Len(t, entity.FramesFired, 1) + ff := entity.FramesFired[0] + 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 + + // EndFrame should be max state frame + assert.Equal(t, uint(20), export.EndFrame) +} + +func TestBuildWithSoldierInVehicle(t *testing.T) { + inVehicleID := uint16(100) + data := &MissionData{ + Mission: &core.Mission{MissionName: "Test"}, + World: &core.World{WorldName: "Altis"}, + Soldiers: map[uint16]*SoldierRecord{ + 1: { + Soldier: core.Soldier{ID: 1, UnitName: "Driver"}, + States: []core.SoldierState{ + {SoldierID: 1, CaptureFrame: 0, InVehicleObjectID: &inVehicleID}, + }, + }, + }, + Vehicles: make(map[uint16]*VehicleRecord), + Markers: make(map[string]*MarkerRecord), + } + + export := Build(data) + + require.Len(t, export.Entities, 2) + pos := export.Entities[1].Positions[0] + assert.Equal(t, uint16(100), pos[3]) // inVehicleID should be set +} + +func TestBuildWithVehicle(t *testing.T) { + data := &MissionData{ + Mission: &core.Mission{MissionName: "Test"}, + World: &core.World{WorldName: "Altis"}, + Soldiers: make(map[uint16]*SoldierRecord), + Vehicles: map[uint16]*VehicleRecord{ + 10: { + Vehicle: core.Vehicle{ + ID: 10, DisplayName: "Hunter", ClassName: "B_MRAP_01_F", OcapType: "car", JoinFrame: 5, + }, + States: []core.VehicleState{ + {VehicleID: 10, CaptureFrame: 5, Position: core.Position3D{X: 3000, Y: 4000}, Bearing: 180, IsAlive: true, Crew: "[[1,\"driver\"]]"}, + {VehicleID: 10, CaptureFrame: 15, Position: core.Position3D{X: 3100, Y: 4100}, Bearing: 185, IsAlive: true, Crew: "[[1,\"driver\"],[2,\"gunner\"]]"}, + }, + }, + }, + Markers: make(map[string]*MarkerRecord), + } + + export := Build(data) + + require.Len(t, export.Entities, 11) // indices 0-10 + entity := export.Entities[10] + + assert.Equal(t, uint16(10), entity.ID) + assert.Equal(t, "Hunter", entity.Name) + assert.Equal(t, "B_MRAP_01_F", entity.Class) + assert.Equal(t, "car", entity.Type) + assert.Equal(t, "UNKNOWN", entity.Side) + assert.Equal(t, 0, entity.IsPlayer) + assert.Equal(t, uint(5), entity.StartFrameNum) + assert.Empty(t, entity.FramesFired) + + // Check positions + require.Len(t, entity.Positions, 2) + pos := entity.Positions[0] + coords := pos[0].([]float64) + assert.Equal(t, 3000.0, coords[0]) + assert.Equal(t, 4000.0, coords[1]) + assert.Equal(t, uint16(180), pos[1]) // bearing + assert.Equal(t, 1, pos[2]) // isAlive + + // Crew should be parsed as array + crew := pos[3].([]any) + require.Len(t, crew, 1) + crewEntry := crew[0].([]any) + assert.Equal(t, float64(1), crewEntry[0]) + + assert.Equal(t, uint(15), export.EndFrame) +} + +func TestBuildWithVehicleEmptyCrew(t *testing.T) { + data := &MissionData{ + Mission: &core.Mission{MissionName: "Test"}, + World: &core.World{WorldName: "Altis"}, + Soldiers: make(map[uint16]*SoldierRecord), + Vehicles: map[uint16]*VehicleRecord{ + 1: { + Vehicle: core.Vehicle{ID: 1, OcapType: "car"}, + States: []core.VehicleState{ + {VehicleID: 1, CaptureFrame: 0, Crew: ""}, + }, + }, + }, + Markers: make(map[string]*MarkerRecord), + } + + export := Build(data) + + pos := export.Entities[1].Positions[0] + crew := pos[3].([]any) + assert.Empty(t, crew) +} + +func TestBuildWithVehicleInvalidCrewJSON(t *testing.T) { + data := &MissionData{ + Mission: &core.Mission{MissionName: "Test"}, + World: &core.World{WorldName: "Altis"}, + Soldiers: make(map[uint16]*SoldierRecord), + Vehicles: map[uint16]*VehicleRecord{ + 1: { + Vehicle: core.Vehicle{ID: 1, OcapType: "car"}, + States: []core.VehicleState{ + {VehicleID: 1, CaptureFrame: 0, Crew: "invalid json"}, + }, + }, + }, + Markers: make(map[string]*MarkerRecord), + } + + export := Build(data) + + pos := export.Entities[1].Positions[0] + crew := pos[3].([]any) + assert.Empty(t, crew) // Falls back to empty array +} + +func TestBuildWithDeadVehicle(t *testing.T) { + data := &MissionData{ + Mission: &core.Mission{MissionName: "Test"}, + World: &core.World{WorldName: "Altis"}, + Soldiers: make(map[uint16]*SoldierRecord), + Vehicles: map[uint16]*VehicleRecord{ + 1: { + Vehicle: core.Vehicle{ID: 1, OcapType: "tank"}, + States: []core.VehicleState{ + {VehicleID: 1, CaptureFrame: 50, IsAlive: false}, + }, + }, + }, + Markers: make(map[string]*MarkerRecord), + } + + export := Build(data) + + pos := export.Entities[1].Positions[0] + assert.Equal(t, 0, pos[2]) // isAlive = false -> 0 +} + +func TestBuildWithGeneralEvents(t *testing.T) { + data := &MissionData{ + Mission: &core.Mission{MissionName: "Test"}, + World: &core.World{WorldName: "Altis"}, + Soldiers: make(map[uint16]*SoldierRecord), + Vehicles: make(map[uint16]*VehicleRecord), + Markers: make(map[string]*MarkerRecord), + GeneralEvents: []core.GeneralEvent{ + {CaptureFrame: 10, Name: "connected", Message: "Player joined"}, + {CaptureFrame: 20, Name: "custom", Message: "[-1,-1,-1,-1]"}, // JSON array + {CaptureFrame: 30, Name: "data", Message: `{"key":"value"}`}, // JSON object + {CaptureFrame: 40, Name: "invalid", Message: "[1,2,3"}, // Invalid JSON + }, + } + + export := Build(data) + + require.Len(t, export.Events, 4) + + // Plain string message + assert.Equal(t, uint(10), export.Events[0][0]) + assert.Equal(t, "connected", export.Events[0][1]) + assert.Equal(t, "Player joined", export.Events[0][2]) + + // JSON array should be parsed + assert.Equal(t, uint(20), export.Events[1][0]) + parsedArray := export.Events[1][2].([]any) + assert.Len(t, parsedArray, 4) + + // JSON object should be parsed + parsedObj := export.Events[2][2].(map[string]any) + assert.Equal(t, "value", parsedObj["key"]) + + // Invalid JSON stays as string + assert.Equal(t, "[1,2,3", export.Events[3][2]) +} + +func TestBuildWithHitEvents(t *testing.T) { + soldierVictim := uint(5) + soldierShooter := uint(10) + vehicleVictim := uint(20) + vehicleShooter := uint(25) + + data := &MissionData{ + Mission: &core.Mission{MissionName: "Test"}, + World: &core.World{WorldName: "Altis"}, + Soldiers: make(map[uint16]*SoldierRecord), + Vehicles: make(map[uint16]*VehicleRecord), + Markers: make(map[string]*MarkerRecord), + HitEvents: []core.HitEvent{ + // Soldier hits soldier + {CaptureFrame: 10, VictimSoldierID: &soldierVictim, ShooterSoldierID: &soldierShooter, EventText: "rifle", Distance: 50}, + // Vehicle hits vehicle + {CaptureFrame: 20, VictimVehicleID: &vehicleVictim, ShooterVehicleID: &vehicleShooter, EventText: "cannon", Distance: 200}, + }, + } + + export := Build(data) + + require.Len(t, export.Events, 2) + + // Soldier hit + evt1 := export.Events[0] + assert.Equal(t, uint(10), evt1[0]) + assert.Equal(t, "hit", evt1[1]) + assert.Equal(t, uint(5), evt1[2]) // victimID + causedBy1 := evt1[3].([]any) + assert.Equal(t, uint(10), causedBy1[0]) // shooterID + assert.Equal(t, "rifle", causedBy1[1]) + assert.Equal(t, float32(50), evt1[4]) + + // Vehicle hit + evt2 := export.Events[1] + assert.Equal(t, uint(20), evt2[2]) // vehicleVictim takes precedence + causedBy2 := evt2[3].([]any) + assert.Equal(t, uint(25), causedBy2[0]) +} + +func TestBuildWithKillEvents(t *testing.T) { + soldierVictim := uint(5) + soldierKiller := uint(10) + + data := &MissionData{ + Mission: &core.Mission{MissionName: "Test"}, + World: &core.World{WorldName: "Altis"}, + Soldiers: make(map[uint16]*SoldierRecord), + Vehicles: make(map[uint16]*VehicleRecord), + Markers: make(map[string]*MarkerRecord), + KillEvents: []core.KillEvent{ + {CaptureFrame: 100, VictimSoldierID: &soldierVictim, KillerSoldierID: &soldierKiller, EventText: "explosion", Distance: 10}, + }, + } + + export := Build(data) + + require.Len(t, export.Events, 1) + evt := export.Events[0] + assert.Equal(t, uint(100), evt[0]) + assert.Equal(t, "killed", evt[1]) + assert.Equal(t, uint(5), evt[2]) + causedBy := evt[3].([]any) + assert.Equal(t, uint(10), causedBy[0]) + assert.Equal(t, "explosion", causedBy[1]) + assert.Equal(t, float32(10), evt[4]) +} + +func TestBuildWithMarker(t *testing.T) { + data := &MissionData{ + Mission: &core.Mission{MissionName: "Test"}, + World: &core.World{WorldName: "Altis"}, + Soldiers: make(map[uint16]*SoldierRecord), + Vehicles: make(map[uint16]*VehicleRecord), + Markers: map[string]*MarkerRecord{ + "obj_alpha": { + Marker: core.Marker{ + ID: 1, MarkerName: "obj_alpha", Text: "Objective", MarkerType: "mil_objective", + Color: "#800000", Side: "WEST", Shape: "ICON", OwnerID: 42, Size: "[2.0,3.0]", Brush: "Solid", + CaptureFrame: 0, Position: core.Position3D{X: 5000, Y: 6000}, Direction: 45, Alpha: 1.0, + }, + States: []core.MarkerState{ + {MarkerID: 1, CaptureFrame: 50, Position: core.Position3D{X: 5100, Y: 6100}, Direction: 90, Alpha: 0.8}, + }, + }, + }, + } + + export := Build(data) + + require.Len(t, export.Markers, 1) + marker := export.Markers[0] + + assert.Equal(t, "mil_objective", marker[0]) // type + assert.Equal(t, "Objective", marker[1]) // text + assert.Equal(t, uint(0), marker[2]) // startFrame + assert.Equal(t, -1, marker[3]) // endFrame + assert.Equal(t, 42, marker[4]) // playerId + assert.Equal(t, "800000", marker[5]) // color (# stripped) + assert.Equal(t, 1, marker[6]) // sideIndex (WEST = 1) + + // Positions + positions := marker[7].([][]any) + require.Len(t, positions, 2) + assert.Equal(t, uint(0), positions[0][0]) // initial frame + assert.Equal(t, uint(50), positions[1][0]) // state change frame + + assert.Equal(t, []float64{2.0, 3.0}, marker[8]) // size + assert.Equal(t, "ICON", marker[9]) // shape + assert.Equal(t, "Solid", marker[10]) // brush +} + +func TestBuildWithPolylineMarker(t *testing.T) { + data := &MissionData{ + Mission: &core.Mission{MissionName: "Test"}, + World: &core.World{WorldName: "Altis"}, + Soldiers: make(map[uint16]*SoldierRecord), + Vehicles: make(map[uint16]*VehicleRecord), + Markers: map[string]*MarkerRecord{ + "route_1": { + Marker: core.Marker{ + MarkerName: "route_1", Shape: "POLYLINE", CaptureFrame: 10, + Polyline: core.Polyline{ + {X: 100, Y: 200}, + {X: 300, Y: 400}, + {X: 500, Y: 600}, + }, + Direction: 0, Alpha: 1.0, + }, + }, + }, + } + + export := Build(data) + + require.Len(t, export.Markers, 1) + marker := export.Markers[0] + + assert.Equal(t, "POLYLINE", marker[9]) // shape + + positions := marker[7].([][]any) + require.Len(t, positions, 1) // Polylines have single frame entry + + frameEntry := positions[0] + assert.Equal(t, uint(10), frameEntry[0]) // frameNum + + // Coordinates array + coords := frameEntry[1].([][]float64) + require.Len(t, coords, 3) + assert.Equal(t, []float64{100, 200}, coords[0]) + assert.Equal(t, []float64{300, 400}, coords[1]) + assert.Equal(t, []float64{500, 600}, coords[2]) +} + +func TestBuildWithNamedColor(t *testing.T) { + data := &MissionData{ + Mission: &core.Mission{MissionName: "Test"}, + World: &core.World{WorldName: "Altis"}, + Soldiers: make(map[uint16]*SoldierRecord), + Vehicles: make(map[uint16]*VehicleRecord), + Markers: map[string]*MarkerRecord{ + "marker1": { + Marker: core.Marker{MarkerName: "marker1", Color: "ColorRed", Shape: "ICON"}, + }, + }, + } + + export := Build(data) + + marker := export.Markers[0] + assert.Equal(t, "ColorRed", marker[5]) // Named colors unchanged +} + +func TestBuildEntitySparseArray(t *testing.T) { + // Test that entity array is sparse with correct indexing + data := &MissionData{ + Mission: &core.Mission{MissionName: "Test"}, + World: &core.World{WorldName: "Altis"}, + Soldiers: map[uint16]*SoldierRecord{ + 3: {Soldier: core.Soldier{ID: 3, UnitName: "Soldier3"}}, + 7: {Soldier: core.Soldier{ID: 7, UnitName: "Soldier7"}}, + 15: {Soldier: core.Soldier{ID: 15, UnitName: "Soldier15"}}, + }, + Vehicles: map[uint16]*VehicleRecord{ + 10: {Vehicle: core.Vehicle{ID: 10, DisplayName: "Vehicle10", OcapType: "car"}}, + }, + Markers: make(map[string]*MarkerRecord), + } + + export := Build(data) + + // Max ID is 15, so array length should be 16 + require.Len(t, export.Entities, 16) + + // Check entities at their correct indices + assert.Equal(t, "Soldier3", export.Entities[3].Name) + assert.Equal(t, "Soldier7", export.Entities[7].Name) + assert.Equal(t, "Vehicle10", export.Entities[10].Name) + assert.Equal(t, "Soldier15", export.Entities[15].Name) + + // Placeholder entries should be empty + assert.Equal(t, "", export.Entities[0].Name) + assert.Equal(t, "", export.Entities[5].Name) +} + +func TestBuildMaxFrameFromMultipleSources(t *testing.T) { + data := &MissionData{ + Mission: &core.Mission{MissionName: "Test"}, + World: &core.World{WorldName: "Altis"}, + Soldiers: map[uint16]*SoldierRecord{ + 1: { + Soldier: core.Soldier{ID: 1}, + States: []core.SoldierState{ + {SoldierID: 1, CaptureFrame: 50}, + {SoldierID: 1, CaptureFrame: 100}, + }, + }, + }, + Vehicles: map[uint16]*VehicleRecord{ + 2: { + Vehicle: core.Vehicle{ID: 2, OcapType: "car"}, + States: []core.VehicleState{ + {VehicleID: 2, CaptureFrame: 75}, + {VehicleID: 2, CaptureFrame: 150}, // Max frame + }, + }, + }, + Markers: make(map[string]*MarkerRecord), + } + + export := Build(data) + + assert.Equal(t, uint(150), export.EndFrame) +} + +func TestBuildWithNoEntitiesButEvents(t *testing.T) { + data := &MissionData{ + Mission: &core.Mission{MissionName: "Test"}, + World: &core.World{WorldName: "Altis"}, + Soldiers: make(map[uint16]*SoldierRecord), + Vehicles: make(map[uint16]*VehicleRecord), + Markers: make(map[string]*MarkerRecord), + GeneralEvents: []core.GeneralEvent{ + {CaptureFrame: 10, Name: "test", Message: "msg"}, + }, + } + + export := Build(data) + + assert.Empty(t, export.Entities) + require.Len(t, export.Events, 1) +} + +func TestBuildKillEventWithVehicleIDs(t *testing.T) { + vehicleVictim := uint(20) + vehicleKiller := uint(30) + + data := &MissionData{ + Mission: &core.Mission{MissionName: "Test"}, + World: &core.World{WorldName: "Altis"}, + Soldiers: make(map[uint16]*SoldierRecord), + Vehicles: make(map[uint16]*VehicleRecord), + Markers: make(map[string]*MarkerRecord), + KillEvents: []core.KillEvent{ + {CaptureFrame: 100, VictimVehicleID: &vehicleVictim, KillerVehicleID: &vehicleKiller, EventText: "missile", Distance: 500}, + }, + } + + export := Build(data) + + require.Len(t, export.Events, 1) + evt := export.Events[0] + assert.Equal(t, uint(20), evt[2]) // victimID (vehicle) + causedBy := evt[3].([]any) + assert.Equal(t, uint(30), causedBy[0]) // killerID (vehicle) +} + +func TestBuildHitEventPrioritizesVehicleOverSoldier(t *testing.T) { + soldierID := uint(5) + vehicleID := uint(10) + + data := &MissionData{ + Mission: &core.Mission{MissionName: "Test"}, + World: &core.World{WorldName: "Altis"}, + Soldiers: make(map[uint16]*SoldierRecord), + Vehicles: make(map[uint16]*VehicleRecord), + Markers: make(map[string]*MarkerRecord), + HitEvents: []core.HitEvent{ + // Both soldier and vehicle IDs set - vehicle should take precedence + {CaptureFrame: 10, VictimSoldierID: &soldierID, VictimVehicleID: &vehicleID, ShooterSoldierID: &soldierID, ShooterVehicleID: &vehicleID, EventText: "test", Distance: 10}, + }, + } + + export := Build(data) + + evt := export.Events[0] + assert.Equal(t, uint(10), evt[2]) // Vehicle victim ID takes precedence + causedBy := evt[3].([]any) + assert.Equal(t, uint(10), causedBy[0]) // Vehicle shooter ID takes precedence +} diff --git a/internal/storage/memory/export/v1/types.go b/internal/storage/memory/export/v1/types.go new file mode 100644 index 0000000..fb28295 --- /dev/null +++ b/internal/storage/memory/export/v1/types.go @@ -0,0 +1,44 @@ +// Package v1 contains the v1 export format for OCAP2 mission data. +// This format is compatible with the ocap2-web frontend. +package v1 + +// Export is the root JSON structure for v1 format +// Note: Markers uses capital M for compatibility with ocap2-web +type Export struct { + AddonVersion string `json:"addonVersion"` + ExtensionVersion string `json:"extensionVersion"` + ExtensionBuild string `json:"extensionBuild"` + MissionName string `json:"missionName"` + MissionAuthor string `json:"missionAuthor"` + WorldName string `json:"worldName"` + EndFrame uint `json:"endFrame"` + CaptureDelay float32 `json:"captureDelay"` + Tags string `json:"tags"` + Times []Time `json:"times"` + Entities []Entity `json:"entities"` + Events [][]any `json:"events"` + Markers [][]any `json:"Markers"` // Capital M for ocap2-web compatibility +} + +// Time represents time synchronization data for a frame +type Time struct { + Date string `json:"date"` + FrameNum uint `json:"frameNum"` + SystemTimeUTC string `json:"systemTimeUTC"` + Time float32 `json:"time"` + TimeMultiplier float32 `json:"timeMultiplier"` +} + +// Entity represents a soldier or vehicle +type Entity struct { + ID uint16 `json:"id"` + Name string `json:"name"` + Group string `json:"group,omitempty"` + Side string `json:"side"` + IsPlayer int `json:"isPlayer"` + Type string `json:"type"` + Class string `json:"class,omitempty"` + StartFrameNum uint `json:"startFrameNum"` + Positions [][]any `json:"positions"` + FramesFired [][]any `json:"framesFired"` +} diff --git a/internal/storage/memory/export_test.go b/internal/storage/memory/export_test.go index 7dc03b3..d37fbd1 100644 --- a/internal/storage/memory/export_test.go +++ b/internal/storage/memory/export_test.go @@ -11,15 +11,11 @@ import ( "github.com/OCAP2/extension/v5/internal/config" "github.com/OCAP2/extension/v5/internal/model/core" + v1 "github.com/OCAP2/extension/v5/internal/storage/memory/export/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestBoolToInt(t *testing.T) { - assert.Equal(t, 1, boolToInt(true)) - assert.Equal(t, 0, boolToInt(false)) -} - // TestIntegrationFullExport is a comprehensive integration test that verifies the full flow: // start mission -> add entities (soldier, vehicle, marker) -> record states/events -> export JSON -> verify output func TestIntegrationFullExport(t *testing.T) { @@ -92,7 +88,7 @@ func TestIntegrationFullExport(t *testing.T) { data, err := os.ReadFile(matches[0]) require.NoError(t, err) - var export OcapExport + var export v1.Export require.NoError(t, json.Unmarshal(data, &export)) // Verify mission metadata @@ -187,7 +183,7 @@ func TestExportJSON(t *testing.T) { data, err := os.ReadFile(matches[0]) require.NoError(t, err) - var export OcapExport + var export v1.Export require.NoError(t, json.Unmarshal(data, &export)) assert.Equal(t, "Export Test", export.MissionName) } @@ -215,7 +211,7 @@ func TestExportGzipJSON(t *testing.T) { require.NoError(t, err) defer gzReader.Close() - var export OcapExport + var export v1.Export require.NoError(t, json.NewDecoder(gzReader).Decode(&export)) assert.Equal(t, "Gzip Test", export.MissionName) } @@ -290,7 +286,7 @@ func TestSoldierPositionFormat(t *testing.T) { UnitName: "Player1_InVeh", IsPlayer: true, CurrentRole: "Gunner", })) - export := b.buildExport() + export := b.BuildExport() // Sparse array: entity at index 1 (its ID) require.Len(t, export.Entities, 2) // indices 0 and 1 @@ -319,7 +315,7 @@ func TestVehiclePositionFormat(t *testing.T) { Bearing: 45, IsAlive: true, Crew: "[[1,\"driver\"],[2,\"gunner\"]]", })) - export := b.buildExport() + export := b.BuildExport() // Sparse array: entity at index 10 (its ID) require.Len(t, export.Entities, 11) // indices 0-10 @@ -346,7 +342,7 @@ func TestFiredEventFormat(t *testing.T) { FiringMode: "FullAuto", StartPos: core.Position3D{X: 100, Y: 200, Z: 1.5}, EndPos: core.Position3D{X: 300, Y: 400, Z: 1.8}, })) - export := b.buildExport() + export := b.BuildExport() // Sparse array: entity at index 1 (its ID) require.Len(t, export.Entities, 2) // indices 0 and 1 @@ -383,7 +379,7 @@ func TestMarkerPositionFormat(t *testing.T) { MarkerID: 0, CaptureFrame: 50, Position: core.Position3D{X: 1100, Y: 2100, Z: 0}, Direction: 180, Alpha: 0.5, })) - export := b.buildExport() + export := b.BuildExport() require.Len(t, export.Markers, 1) positions := export.Markers[0][7].([][]any) // positions at index 7 @@ -413,7 +409,7 @@ func TestEmptyExport(t *testing.T) { data, err := os.ReadFile(matches[0]) require.NoError(t, err) - var export OcapExport + var export v1.Export require.NoError(t, json.Unmarshal(data, &export)) assert.Empty(t, export.Entities) @@ -433,7 +429,7 @@ func TestMaxFrameCalculation(t *testing.T) { require.NoError(t, b.RecordVehicleState(&core.VehicleState{VehicleID: 10, CaptureFrame: 100})) require.NoError(t, b.RecordVehicleState(&core.VehicleState{VehicleID: 10, CaptureFrame: 75})) - assert.Equal(t, uint(100), b.buildExport().EndFrame) + assert.Equal(t, uint(100), b.BuildExport().EndFrame) } func TestSoldierWithoutVehicle(t *testing.T) { @@ -446,7 +442,7 @@ func TestSoldierWithoutVehicle(t *testing.T) { Bearing: 45, Lifestate: 1, InVehicleObjectID: nil, UnitName: "Infantry", IsPlayer: false, CurrentRole: "Rifleman", })) - export := b.buildExport() + export := b.BuildExport() // Sparse array: entity at index 1 (its ID) require.Len(t, export.Entities, 2) // indices 0 and 1 @@ -468,7 +464,7 @@ func TestDeadVehicle(t *testing.T) { Bearing: 90, IsAlive: false, Crew: "[]", })) - export := b.buildExport() + export := b.BuildExport() // Sparse array: entity at index 5 (its ID) require.Len(t, export.Entities, 6) // indices 0-5 @@ -493,7 +489,7 @@ func TestMultipleEntitiesExport(t *testing.T) { require.NoError(t, b.RecordVehicleState(&core.VehicleState{VehicleID: 10, CaptureFrame: 0, Position: core.Position3D{X: 200, Y: 200}, IsAlive: true})) require.NoError(t, b.RecordVehicleState(&core.VehicleState{VehicleID: 11, CaptureFrame: 20, Position: core.Position3D{X: 300, Y: 300}, IsAlive: true})) - export := b.buildExport() + export := b.BuildExport() // Sparse array: max ID is 11, so array has indices 0-11 require.Len(t, export.Entities, 12) @@ -518,7 +514,7 @@ func TestEventWithoutExtraData(t *testing.T) { require.NoError(t, b.StartMission(&core.Mission{MissionName: "Test", StartTime: time.Now()}, &core.World{WorldName: "Test"})) require.NoError(t, b.RecordGeneralEvent(&core.GeneralEvent{CaptureFrame: 100, Name: "endMission", Message: "Mission ended", ExtraData: nil})) - export := b.buildExport() + export := b.BuildExport() require.Len(t, export.Events, 1) assert.Equal(t, "endMission", export.Events[0][1]) // type at index 1 @@ -574,7 +570,7 @@ func TestGeneralEventJSONMessageParsing(t *testing.T) { Message: tt.message, })) - export := b.buildExport() + export := b.BuildExport() require.Len(t, export.Events, 1) assert.Equal(t, tt.expectedMessage, export.Events[0][2]) @@ -599,7 +595,7 @@ func TestMultipleMarkersExport(t *testing.T) { require.NoError(t, b.RecordMarkerState(&core.MarkerState{MarkerID: 1, CaptureFrame: 10, Position: core.Position3D{X: 1100, Y: 1100}, Direction: 90, Alpha: 0.8})) require.NoError(t, b.RecordMarkerState(&core.MarkerState{MarkerID: 1, CaptureFrame: 20, Position: core.Position3D{X: 1200, Y: 1200}, Direction: 180, Alpha: 0.6})) - export := b.buildExport() + export := b.BuildExport() require.Len(t, export.Markers, 2) @@ -643,7 +639,7 @@ func TestMultipleFiredEvents(t *testing.T) { StartPos: core.Position3D{X: 100, Y: 100}, EndPos: core.Position3D{X: 500, Y: 500}, })) - export := b.buildExport() + export := b.BuildExport() // Sparse array: entity at index 1 (its ID) require.Len(t, export.Entities, 2) @@ -670,7 +666,7 @@ func TestVehicleWithJoinFrame(t *testing.T) { JoinFrame: 500, // Spawned late in the mission })) - export := b.buildExport() + export := b.BuildExport() // Sparse array: entity at index 20 (its ID) require.Len(t, export.Entities, 21) // indices 0-20 @@ -822,7 +818,7 @@ func TestMarkerColorHashPrefixIsStripped(t *testing.T) { Side: "WEST", Shape: "ICON", CaptureFrame: 0, Position: core.Position3D{X: 2000, Y: 3000}, Direction: 0, Alpha: 1.0, })) - export := b.buildExport() + export := b.BuildExport() require.Len(t, export.Markers, 2) @@ -866,7 +862,7 @@ func TestMarkerOwnerIDExport(t *testing.T) { CaptureFrame: 10, Position: core.Position3D{X: 3000, Y: 4000}, Direction: 45, Alpha: 1.0, })) - export := b.buildExport() + export := b.BuildExport() require.Len(t, export.Markers, 2) @@ -910,7 +906,7 @@ func TestMarkerSizeAndBrushExport(t *testing.T) { CaptureFrame: 0, Position: core.Position3D{X: 3000, Y: 4000}, Direction: 0, Alpha: 1.0, })) - export := b.buildExport() + export := b.BuildExport() require.Len(t, export.Markers, 2) @@ -947,7 +943,7 @@ func TestExtensionBuildExport(t *testing.T) { ExtensionBuild: "Wed Jul 28 08:28:28 2021", }, &core.World{WorldName: "Test"})) - export := b.buildExport() + export := b.BuildExport() assert.Equal(t, "Wed Jul 28 08:28:28 2021", export.ExtensionBuild) } @@ -961,7 +957,7 @@ func TestTagsExport(t *testing.T) { Tag: "Zeus", }, &core.World{WorldName: "Test"})) - export := b.buildExport() + export := b.BuildExport() assert.Equal(t, "Zeus", export.Tags) } @@ -990,7 +986,7 @@ func TestTimesExport(t *testing.T) { MissionTime: 1627.58, })) - export := b.buildExport() + export := b.BuildExport() require.Len(t, export.Times, 2) @@ -1019,7 +1015,7 @@ func TestTimesExportEmpty(t *testing.T) { // No time states recorded - export := b.buildExport() + export := b.BuildExport() assert.Empty(t, export.Times) } @@ -1092,7 +1088,7 @@ func TestPolylineMarkerExport(t *testing.T) { Direction: 0, Alpha: 1.0, Brush: "Solid", })) - export := b.buildExport() + export := b.BuildExport() require.Len(t, export.Markers, 1) marker := export.Markers[0] @@ -1150,7 +1146,7 @@ func TestMarkerSideValues(t *testing.T) { Side: "UNKNOWN", Shape: "ICON", CaptureFrame: 0, Position: core.Position3D{X: 5000, Y: 5000}, Alpha: 1.0, })) - export := b.buildExport() + export := b.BuildExport() // Build a map of marker name to side index for easier assertions markerSides := make(map[string]int) diff --git a/internal/storage/memory/memory.go b/internal/storage/memory/memory.go index af5dff7..d1c941d 100644 --- a/internal/storage/memory/memory.go +++ b/internal/storage/memory/memory.go @@ -8,6 +8,7 @@ import ( "github.com/OCAP2/extension/v5/internal/config" "github.com/OCAP2/extension/v5/internal/model/core" "github.com/OCAP2/extension/v5/internal/storage" + v1 "github.com/OCAP2/extension/v5/internal/storage/memory/export/v1" ) // SoldierRecord groups a soldier with all its time-series data @@ -352,3 +353,51 @@ func (b *Backend) GetExportMetadata() storage.UploadMetadata { Tag: b.mission.Tag, } } + +// BuildExport creates a v1 export from the current mission data. +// This is safe for concurrent use. +func (b *Backend) BuildExport() v1.Export { + b.mu.RLock() + defer b.mu.RUnlock() + return b.buildExportUnlocked() +} + +// buildExportUnlocked creates a v1 export from the current mission data. +// Caller must hold at least b.mu.RLock. +func (b *Backend) buildExportUnlocked() v1.Export { + data := &v1.MissionData{ + Mission: b.mission, + World: b.world, + Soldiers: make(map[uint16]*v1.SoldierRecord), + Vehicles: make(map[uint16]*v1.VehicleRecord), + Markers: make(map[string]*v1.MarkerRecord), + GeneralEvents: b.generalEvents, + HitEvents: b.hitEvents, + KillEvents: b.killEvents, + TimeStates: b.timeStates, + } + + for id, record := range b.soldiers { + data.Soldiers[id] = &v1.SoldierRecord{ + Soldier: record.Soldier, + States: record.States, + FiredEvents: record.FiredEvents, + } + } + + for id, record := range b.vehicles { + data.Vehicles[id] = &v1.VehicleRecord{ + Vehicle: record.Vehicle, + States: record.States, + } + } + + for name, record := range b.markers { + data.Markers[name] = &v1.MarkerRecord{ + Marker: record.Marker, + States: record.States, + } + } + + return v1.Build(data) +}