From 9ed7841b1e4b6838365962c5bf028829595ebaf0 Mon Sep 17 00:00:00 2001 From: Yethe Samartaka Date: Sun, 23 Aug 2026 23:16:03 +0200 Subject: [PATCH 01/39] new data --- internal/frontend/dist/.gitkeep | 0 internal/maptool/dem_playback.go | 141 +++ internal/maptool/dem_playback_test.go | 84 ++ internal/maptool/gradmeh_pipeline.go | 1 + internal/maptool/metadata.go | 32 +- internal/maptool/pipeline.go | 1 + internal/maptool/scanner.go | 1 + internal/storage/parser_v1.go | 19 +- internal/storage/player_snapshot_test.go | 132 +++ ui/src/assets/medical/NOTICE.txt | 17 + ui/src/assets/medical/body/arm_left.png | Bin 0 -> 3351 bytes ui/src/assets/medical/body/arm_left_b.png | Bin 0 -> 3865 bytes ui/src/assets/medical/body/arm_left_s.png | Bin 0 -> 5827 bytes ui/src/assets/medical/body/arm_left_t.png | Bin 0 -> 1568 bytes ui/src/assets/medical/body/arm_right.png | Bin 0 -> 3380 bytes ui/src/assets/medical/body/arm_right_b.png | Bin 0 -> 3859 bytes ui/src/assets/medical/body/arm_right_s.png | Bin 0 -> 5846 bytes ui/src/assets/medical/body/arm_right_t.png | Bin 0 -> 1605 bytes ui/src/assets/medical/body/background.png | Bin 0 -> 49333 bytes ui/src/assets/medical/body/head.png | Bin 0 -> 2701 bytes .../assets/medical/body/head_guedeltube.png | Bin 0 -> 1186 bytes ui/src/assets/medical/body/head_kinglt.png | Bin 0 -> 1557 bytes .../assets/medical/body/head_nasalcannula.png | Bin 0 -> 3136 bytes ui/src/assets/medical/body/head_s.png | Bin 0 -> 4142 bytes .../body/leftarm_aed_vitals_monitor.png | Bin 0 -> 3047 bytes ui/src/assets/medical/body/leftarm_iv.png | Bin 0 -> 1697 bytes .../medical/body/leftarm_pulseoximeter.png | Bin 0 -> 1489 bytes ui/src/assets/medical/body/leftleg_iv.png | Bin 0 -> 1860 bytes ui/src/assets/medical/body/leg_left.png | Bin 0 -> 3527 bytes ui/src/assets/medical/body/leg_left_b.png | Bin 0 -> 3849 bytes ui/src/assets/medical/body/leg_left_s.png | Bin 0 -> 6630 bytes ui/src/assets/medical/body/leg_left_t.png | Bin 0 -> 1893 bytes ui/src/assets/medical/body/leg_right.png | Bin 0 -> 3540 bytes ui/src/assets/medical/body/leg_right_b.png | Bin 0 -> 3781 bytes ui/src/assets/medical/body/leg_right_s.png | Bin 0 -> 6585 bytes ui/src/assets/medical/body/leg_right_t.png | Bin 0 -> 1944 bytes .../body/rightarm_aed_vitals_monitor.png | Bin 0 -> 3085 bytes ui/src/assets/medical/body/rightarm_iv.png | Bin 0 -> 1697 bytes .../medical/body/rightarm_pulseoximeter.png | Bin 0 -> 1490 bytes ui/src/assets/medical/body/rightleg_iv.png | Bin 0 -> 1861 bytes ui/src/assets/medical/body/torso.png | Bin 0 -> 3132 bytes ui/src/assets/medical/body/torso_aed_pads.png | Bin 0 -> 3419 bytes .../assets/medical/body/torso_chestseal.png | Bin 0 -> 5945 bytes ui/src/assets/medical/body/torso_i.png | Bin 0 -> 7682 bytes ui/src/assets/medical/body/torso_io.png | Bin 0 -> 2166 bytes .../medical/body/torso_pneumothorax.png | Bin 0 -> 1808 bytes ui/src/assets/medical/body/torso_s.png | Bin 0 -> 5631 bytes ui/src/cssModules.d.ts | 5 + .../data/__tests__/radioPropagation.test.ts | 127 +++ ui/src/data/__tests__/snapshotDiff.test.ts | 96 ++ .../decoders/__tests__/jsonDecoder.test.ts | 99 ++ .../__tests__/protobufDecoder.test.ts | 159 +++ ui/src/data/decoders/jsonDecoder.ts | 37 +- ui/src/data/decoders/protobufDecoder.ts | 44 +- ui/src/data/radioPropagation.ts | 158 +++ ui/src/data/snapshotDiff.ts | 60 ++ ui/src/data/types.ts | 149 ++- ui/src/i18n/__tests__/i18n.test.ts | 11 + ui/src/i18n/i18n.ts | 15 +- ui/src/i18n/locales.ts | 121 +++ ui/src/pages/map-manager/constants.ts | 1 + .../__tests__/PlayerProfileCard.test.tsx | 491 +++++++++ .../__tests__/UnitsTab.test.tsx | 17 +- .../__tests__/loadRecording.test.ts | 1 + .../components/EventsTab.tsx | 4 + .../components/MedicalBodyImage.module.css | 41 + .../components/MedicalBodyImage.tsx | 95 ++ .../components/PlayerProfileCard.module.css | 428 ++++++++ .../components/PlayerProfileCard.tsx | 983 ++++++++++++++++++ .../components/UnitsTab.tsx | 142 +-- .../pages/recording-playback/loadRecording.ts | 1 + .../medical/__tests__/bodyImage.test.ts | 102 ++ .../recording-playback/medical/bodyAssets.ts | 74 ++ .../recording-playback/medical/bodyImage.ts | 235 +++++ ui/src/playback/__tests__/engine.test.ts | 66 ++ .../playback/__tests__/eventManager.test.ts | 128 +++ ui/src/playback/engine.ts | 34 +- ui/src/playback/eventManager.ts | 62 ++ ui/src/playback/events/playerSnapshotEvent.ts | 34 + .../radioRange/__tests__/acreCoverage.test.ts | 210 ++++ .../radioRange/__tests__/demGrid.test.ts | 91 ++ .../radioRange/__tests__/tfarCoverage.test.ts | 170 +++ ui/src/playback/radioRange/acreCoverage.ts | 175 ++++ ui/src/playback/radioRange/demGrid.ts | 378 +++++++ ui/src/playback/radioRange/tfarCoverage.ts | 163 +++ .../leaflet/__tests__/zoomSuppression.test.ts | 51 + ui/src/renderers/leaflet/leafletRenderer.ts | 20 +- ui/src/renderers/renderer.types.ts | 4 +- 88 files changed, 5561 insertions(+), 149 deletions(-) delete mode 100644 internal/frontend/dist/.gitkeep create mode 100644 internal/maptool/dem_playback.go create mode 100644 internal/maptool/dem_playback_test.go create mode 100644 internal/storage/player_snapshot_test.go create mode 100644 ui/src/assets/medical/NOTICE.txt create mode 100644 ui/src/assets/medical/body/arm_left.png create mode 100644 ui/src/assets/medical/body/arm_left_b.png create mode 100644 ui/src/assets/medical/body/arm_left_s.png create mode 100644 ui/src/assets/medical/body/arm_left_t.png create mode 100644 ui/src/assets/medical/body/arm_right.png create mode 100644 ui/src/assets/medical/body/arm_right_b.png create mode 100644 ui/src/assets/medical/body/arm_right_s.png create mode 100644 ui/src/assets/medical/body/arm_right_t.png create mode 100644 ui/src/assets/medical/body/background.png create mode 100644 ui/src/assets/medical/body/head.png create mode 100644 ui/src/assets/medical/body/head_guedeltube.png create mode 100644 ui/src/assets/medical/body/head_kinglt.png create mode 100644 ui/src/assets/medical/body/head_nasalcannula.png create mode 100644 ui/src/assets/medical/body/head_s.png create mode 100644 ui/src/assets/medical/body/leftarm_aed_vitals_monitor.png create mode 100644 ui/src/assets/medical/body/leftarm_iv.png create mode 100644 ui/src/assets/medical/body/leftarm_pulseoximeter.png create mode 100644 ui/src/assets/medical/body/leftleg_iv.png create mode 100644 ui/src/assets/medical/body/leg_left.png create mode 100644 ui/src/assets/medical/body/leg_left_b.png create mode 100644 ui/src/assets/medical/body/leg_left_s.png create mode 100644 ui/src/assets/medical/body/leg_left_t.png create mode 100644 ui/src/assets/medical/body/leg_right.png create mode 100644 ui/src/assets/medical/body/leg_right_b.png create mode 100644 ui/src/assets/medical/body/leg_right_s.png create mode 100644 ui/src/assets/medical/body/leg_right_t.png create mode 100644 ui/src/assets/medical/body/rightarm_aed_vitals_monitor.png create mode 100644 ui/src/assets/medical/body/rightarm_iv.png create mode 100644 ui/src/assets/medical/body/rightarm_pulseoximeter.png create mode 100644 ui/src/assets/medical/body/rightleg_iv.png create mode 100644 ui/src/assets/medical/body/torso.png create mode 100644 ui/src/assets/medical/body/torso_aed_pads.png create mode 100644 ui/src/assets/medical/body/torso_chestseal.png create mode 100644 ui/src/assets/medical/body/torso_i.png create mode 100644 ui/src/assets/medical/body/torso_io.png create mode 100644 ui/src/assets/medical/body/torso_pneumothorax.png create mode 100644 ui/src/assets/medical/body/torso_s.png create mode 100644 ui/src/data/__tests__/radioPropagation.test.ts create mode 100644 ui/src/data/__tests__/snapshotDiff.test.ts create mode 100644 ui/src/data/radioPropagation.ts create mode 100644 ui/src/data/snapshotDiff.ts create mode 100644 ui/src/pages/recording-playback/__tests__/PlayerProfileCard.test.tsx create mode 100644 ui/src/pages/recording-playback/components/MedicalBodyImage.module.css create mode 100644 ui/src/pages/recording-playback/components/MedicalBodyImage.tsx create mode 100644 ui/src/pages/recording-playback/components/PlayerProfileCard.module.css create mode 100644 ui/src/pages/recording-playback/components/PlayerProfileCard.tsx create mode 100644 ui/src/pages/recording-playback/medical/__tests__/bodyImage.test.ts create mode 100644 ui/src/pages/recording-playback/medical/bodyAssets.ts create mode 100644 ui/src/pages/recording-playback/medical/bodyImage.ts create mode 100644 ui/src/playback/events/playerSnapshotEvent.ts create mode 100644 ui/src/playback/radioRange/__tests__/acreCoverage.test.ts create mode 100644 ui/src/playback/radioRange/__tests__/demGrid.test.ts create mode 100644 ui/src/playback/radioRange/__tests__/tfarCoverage.test.ts create mode 100644 ui/src/playback/radioRange/acreCoverage.ts create mode 100644 ui/src/playback/radioRange/demGrid.ts create mode 100644 ui/src/playback/radioRange/tfarCoverage.ts diff --git a/internal/frontend/dist/.gitkeep b/internal/frontend/dist/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/internal/maptool/dem_playback.go b/internal/maptool/dem_playback.go new file mode 100644 index 000000000..1882d8f49 --- /dev/null +++ b/internal/maptool/dem_playback.go @@ -0,0 +1,141 @@ +package maptool + +import ( + "compress/gzip" + "context" + "encoding/binary" + "fmt" + "io" + "log" + "math" + "os" + "path/filepath" +) + +const playbackDEMMagic = "OCAPDEM\x01" +const playbackDEMMaxDim = 1024 + +// NewWritePlaybackDEMStage writes a compact gzipped height grid the web UI +// samples for TFAR approximate radio range. Optional: skipped when no DEM. +func NewWritePlaybackDEMStage() Stage { + return Stage{ + Name: "write_playback_dem", + Optional: true, + Run: func(ctx context.Context, job *Job) error { + if err := ctx.Err(); err != nil { + return err + } + return writePlaybackDEMJob(job) + }, + } +} + +func writePlaybackDEMJob(job *Job) error { + if job.DEMGrid == nil { + return fmt.Errorf("DEM not available") + } + grid := downsampleDEM(job.DEMGrid, playbackDEMMaxDim) + outPath := filepath.Join(job.TilesOutputDir(), "dem.bin.gz") + if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { + return err + } + worldSize := job.WorldSize + if worldSize <= 0 { + worldSize = int(math.Round(float64(grid.Cols-1) * grid.CellSize)) + } + if err := encodePlaybackDEMFile(outPath, grid, worldSize); err != nil { + return err + } + job.HasDem = true + log.Printf("Wrote playback DEM %s (%dx%d, cell %.1fm)", outPath, grid.Cols, grid.Rows, grid.CellSize) + return nil +} + +func downsampleDEM(src *DEMGrid, maxDim int) *DEMGrid { + if src.Cols <= maxDim && src.Rows <= maxDim { + return src + } + scale := 1 + if src.Cols > maxDim { + scale = (src.Cols + maxDim - 1) / maxDim + } + if src.Rows > maxDim { + rowScale := (src.Rows + maxDim - 1) / maxDim + if rowScale > scale { + scale = rowScale + } + } + cols := (src.Cols + scale - 1) / scale + rows := (src.Rows + scale - 1) / scale + out := &DEMGrid{ + Cols: cols, + Rows: rows, + XllCorner: src.XllCorner, + YllCorner: src.YllCorner, + CellSize: src.CellSize * float64(scale), + NoData: src.NoData, + Data: make([]float32, cols*rows), + } + for r := 0; r < rows; r++ { + srcR := r * scale + if srcR >= src.Rows { + srcR = src.Rows - 1 + } + for c := 0; c < cols; c++ { + srcC := c * scale + if srcC >= src.Cols { + srcC = src.Cols - 1 + } + out.Data[r*cols+c] = src.Data[srcR*src.Cols+srcC] + } + } + return out +} + +func encodePlaybackDEMFile(path string, grid *DEMGrid, worldSize int) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + gz := gzip.NewWriter(f) + defer gz.Close() + return encodePlaybackDEM(gz, grid, worldSize) +} + +func encodePlaybackDEM(w io.Writer, grid *DEMGrid, worldSize int) error { + if _, err := io.WriteString(w, playbackDEMMagic); err != nil { + return err + } + if err := binary.Write(w, binary.LittleEndian, float32(worldSize)); err != nil { + return err + } + if err := binary.Write(w, binary.LittleEndian, float32(grid.XllCorner)); err != nil { + return err + } + if err := binary.Write(w, binary.LittleEndian, float32(grid.YllCorner)); err != nil { + return err + } + if err := binary.Write(w, binary.LittleEndian, float32(grid.CellSize)); err != nil { + return err + } + if err := binary.Write(w, binary.LittleEndian, uint32(grid.Cols)); err != nil { + return err + } + if err := binary.Write(w, binary.LittleEndian, uint32(grid.Rows)); err != nil { + return err + } + for _, h := range grid.Data { + dm := int(math.Round(float64(h) * 10)) + if dm < -32767 { + dm = -32767 + } + if dm > 32767 { + dm = 32767 + } + if err := binary.Write(w, binary.LittleEndian, int16(dm)); err != nil { + return err + } + } + return nil +} diff --git a/internal/maptool/dem_playback_test.go b/internal/maptool/dem_playback_test.go new file mode 100644 index 000000000..c84c0e3d8 --- /dev/null +++ b/internal/maptool/dem_playback_test.go @@ -0,0 +1,84 @@ +package maptool + +import ( + "bytes" + "compress/gzip" + "encoding/binary" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDownsampleDEMCapsMaxDimension(t *testing.T) { + src := &DEMGrid{ + Cols: 20, + Rows: 10, + CellSize: 1, + Data: make([]float32, 20*10), + } + src.Data[19] = 42 + out := downsampleDEM(src, 8) + assert.LessOrEqual(t, out.Cols, 8) + assert.LessOrEqual(t, out.Rows, 8) + assert.Greater(t, out.CellSize, src.CellSize) +} + +func TestDownsampleDEMLeavesSmallGridsAlone(t *testing.T) { + src := &DEMGrid{Cols: 4, Rows: 4, CellSize: 10, Data: make([]float32, 16)} + out := downsampleDEM(src, 1024) + assert.Equal(t, src, out) +} + +func TestWritePlaybackDEMJob(t *testing.T) { + dir := t.TempDir() + data := make([]float32, 4*4) + data[0] = 12.3 + job := &Job{ + OutputDir: dir, + SubDirs: true, + WorldSize: 1000, + DEMGrid: &DEMGrid{ + Cols: 4, + Rows: 4, + XllCorner: 0, + YllCorner: 0, + CellSize: 250, + Data: data, + }, + } + + require.NoError(t, writePlaybackDEMJob(job)) + assert.True(t, job.HasDem) + + raw, err := os.ReadFile(filepath.Join(dir, "tiles", "dem.bin.gz")) + require.NoError(t, err) + + gz, err := gzip.NewReader(bytes.NewReader(raw)) + require.NoError(t, err) + defer gz.Close() + + var buf bytes.Buffer + _, err = buf.ReadFrom(gz) + require.NoError(t, err) + decoded := buf.Bytes() + require.GreaterOrEqual(t, len(decoded), 32) + assert.Equal(t, []byte("OCAPDEM\x01"), decoded[:8]) + + var worldSizeF, cellSize float32 + require.NoError(t, binary.Read(bytes.NewReader(decoded[8:12]), binary.LittleEndian, &worldSizeF)) + require.NoError(t, binary.Read(bytes.NewReader(decoded[20:24]), binary.LittleEndian, &cellSize)) + assert.Equal(t, float32(1000), worldSizeF) + assert.Equal(t, float32(250), cellSize) + assert.Equal(t, uint32(4), binary.LittleEndian.Uint32(decoded[24:28])) + assert.Equal(t, uint32(4), binary.LittleEndian.Uint32(decoded[28:32])) + height := int16(binary.LittleEndian.Uint16(decoded[32:34])) + assert.Equal(t, int16(123), height) +} + +func TestWritePlaybackDEMJobRequiresGrid(t *testing.T) { + err := writePlaybackDEMJob(&Job{OutputDir: t.TempDir()}) + require.Error(t, err) +} diff --git a/internal/maptool/gradmeh_pipeline.go b/internal/maptool/gradmeh_pipeline.go index ecb87d11d..7a07b6baa 100644 --- a/internal/maptool/gradmeh_pipeline.go +++ b/internal/maptool/gradmeh_pipeline.go @@ -17,6 +17,7 @@ func BuildGradMehPipeline(tools ToolSet) *Pipeline { ParallelStages("render", NewGenerateSatellitePMTilesStage(tools), NewGenerateHeightmapStage(tools), + NewWritePlaybackDEMStage(), NewGenerateHillshadeStage(tools), NewGenerateBathymetryStage(tools), NewGenerateColorReliefStage(tools), diff --git a/internal/maptool/metadata.go b/internal/maptool/metadata.go index d9c598341..9e38c969e 100644 --- a/internal/maptool/metadata.go +++ b/internal/maptool/metadata.go @@ -11,13 +11,15 @@ import ( // mapJSON is the structure written to map.json. type mapJSON struct { - Name string `json:"name"` - WorldSize int `json:"worldSize"` - ImageSize int `json:"imageSize"` - Multiplier int `json:"multiplier"` - MaxZoom int `json:"maxZoom"` - MinZoom int `json:"minZoom"` - Maplibre bool `json:"maplibre,omitempty"` + Name string `json:"name"` + WorldSize int `json:"worldSize"` + ImageSize int `json:"imageSize"` + Multiplier int `json:"multiplier"` + MaxZoom int `json:"maxZoom"` + MinZoom int `json:"minZoom"` + Maplibre bool `json:"maplibre,omitempty"` + HasHeightmap bool `json:"hasHeightmap,omitempty"` + HasDem bool `json:"hasDem,omitempty"` } // assetPath joins a URL prefix with a filename. If prefix is empty, returns filename as-is. @@ -165,13 +167,15 @@ func NewGenerateGradMehMetadataStage() Stage { } doc := mapJSON{ - Name: worldName, - WorldSize: job.WorldSize, - ImageSize: job.ImageSize, - Multiplier: 1, - MaxZoom: maxZoom, - MinZoom: job.MinZoom, - Maplibre: job.HasMaplibre, + Name: worldName, + WorldSize: job.WorldSize, + ImageSize: job.ImageSize, + Multiplier: 1, + MaxZoom: maxZoom, + MinZoom: job.MinZoom, + Maplibre: job.HasMaplibre, + HasHeightmap: job.HasHeightmap, + HasDem: job.HasDem, } if err := writeJSON(filepath.Join(job.OutputDir, "map.json"), doc); err != nil { return fmt.Errorf("write map.json: %w", err) diff --git a/internal/maptool/pipeline.go b/internal/maptool/pipeline.go index 23025503f..ad85bddc6 100644 --- a/internal/maptool/pipeline.go +++ b/internal/maptool/pipeline.go @@ -55,6 +55,7 @@ type Job struct { ContourFiles map[string]string `json:"-"` // interval suffix ("05","10","50","100") → GeoJSON path SeaFile string `json:"-"` // path to generated sea polygon GeoJSON (from DEM) HasHeightmap bool `json:"-"` + HasDem bool `json:"-"` HasHillshade bool `json:"-"` HasBathymetry bool `json:"-"` HasColorRelief bool `json:"-"` diff --git a/internal/maptool/scanner.go b/internal/maptool/scanner.go index 27a1abe6e..bc5b42248 100644 --- a/internal/maptool/scanner.go +++ b/internal/maptool/scanner.go @@ -70,6 +70,7 @@ func ScanMaps(mapsDir string) ([]MapInfo, error) { // Additional files to report sizes for. extraFiles := []fileCheck{ {"heightmap.pmtiles", "tiles"}, + {"dem.bin.gz", "tiles"}, {"hillshade.pmtiles", "tiles"}, {"color-relief.pmtiles", "tiles"}, } diff --git a/internal/storage/parser_v1.go b/internal/storage/parser_v1.go index 2f83ff49d..34c59336a 100644 --- a/internal/storage/parser_v1.go +++ b/internal/storage/parser_v1.go @@ -1,7 +1,10 @@ // Package storage provides versioned parsers for JSON input formats. package storage -import "strings" +import ( + "encoding/json" + "strings" +) func init() { RegisterParser(&ParserV1{}) @@ -164,6 +167,20 @@ func parseEventArray(evtArr []interface{}) *Event { return event } + // Additive player detail events keep their structured payload as JSON in + // the generic protobuf message field. Older readers safely ignore these + // unknown event names, while newer readers can reconstruct the object. + if event.Type == "inventorySnapshot" || event.Type == "medicalSnapshot" || + event.Type == "staminaSnapshot" || event.Type == "radioSnapshot" || + event.Type == "tfarSettings" || event.Type == "acreSettings" { + if len(evtArr) > 2 { + if encoded, err := json.Marshal(evtArr[2]); err == nil { + event.Message = string(encoded) + } + } + return event + } + // End mission: [frameNum, "endMission", [side, message]] or [frameNum, "endMission", "message"] if event.Type == "endMission" { if len(evtArr) > 2 { diff --git a/internal/storage/player_snapshot_test.go b/internal/storage/player_snapshot_test.go new file mode 100644 index 000000000..17b4c6a8d --- /dev/null +++ b/internal/storage/player_snapshot_test.go @@ -0,0 +1,132 @@ +package storage + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParsePlayerSnapshotEventPreservesPayload(t *testing.T) { + event := parseEventArray([]interface{}{ + float64(42), + "inventorySnapshot", + map[string]interface{}{"unitId": float64(7), "massUnits": 695.0}, + }) + + require.NotNil(t, event) + require.Equal(t, "inventorySnapshot", event.Type) + var payload map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(event.Message), &payload)) + require.Equal(t, float64(7), payload["unitId"]) + require.Equal(t, 695.0, payload["massUnits"]) +} + +// Follow-up snapshots are recorded as diffs against an earlier frame. The server +// only carries them, so the nested "set" object and the "unset" paths must survive +// the round trip through the generic message field untouched. +func TestParsePlayerSnapshotEventPreservesDiff(t *testing.T) { + for _, eventType := range []string{ + "inventorySnapshot", "medicalSnapshot", "staminaSnapshot", "radioSnapshot", + } { + event := parseEventArray([]interface{}{ + float64(120), + eventType, + map[string]interface{}{ + "unitId": float64(7), + "diffOf": float64(60), + "set": map[string]interface{}{ + "ace": map[string]interface{}{"heartRate": float64(133)}, + }, + "unset": []interface{}{"kat.spo2"}, + }, + }) + + require.NotNil(t, event, eventType) + require.Equal(t, eventType, event.Type) + require.Equal(t, uint32(120), event.FrameNum) + + var payload map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(event.Message), &payload)) + require.Equal(t, float64(60), payload["diffOf"]) + require.Equal(t, []interface{}{"kat.spo2"}, payload["unset"]) + + set := payload["set"].(map[string]interface{}) + ace := set["ace"].(map[string]interface{}) + require.Equal(t, float64(133), ace["heartRate"]) + } +} + +func TestParsePlayerSnapshotEventPreservesMedicalLogs(t *testing.T) { + event := parseEventArray([]interface{}{ + float64(80), + "medicalSnapshot", + map[string]interface{}{ + "unitId": float64(7), + "activity": []interface{}{ + map[string]interface{}{"time": "10:02", "text": "Matthew Allen has bandaged patient"}, + }, + "quickView": []interface{}{ + map[string]interface{}{"time": "10:02", "text": "Matthew Allen checked Heart Rate: None"}, + }, + }, + }) + + require.NotNil(t, event) + var payload map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(event.Message), &payload)) + activity := payload["activity"].([]interface{}) + entry := activity[0].(map[string]interface{}) + require.Equal(t, "10:02", entry["time"]) + require.Equal(t, "Matthew Allen has bandaged patient", entry["text"]) +} + +func TestParseTfarSettingsEventPreservesPayload(t *testing.T) { + event := parseEventArray([]interface{}{ + float64(1), + "tfarSettings", + map[string]interface{}{ + "terrainInterceptionCoefficient": 12.0, + "globalRadioRangeCoef": 0.5, + "tfarLoaded": true, + "source": "cba", + }, + }) + + require.NotNil(t, event) + require.Equal(t, "tfarSettings", event.Type) + require.Equal(t, uint32(1), event.FrameNum) + + var payload map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(event.Message), &payload)) + require.Equal(t, 12.0, payload["terrainInterceptionCoefficient"]) + require.Equal(t, 0.5, payload["globalRadioRangeCoef"]) + require.Equal(t, true, payload["tfarLoaded"]) + require.Equal(t, "cba", payload["source"]) +} + +func TestParseAcreSettingsEventPreservesPayload(t *testing.T) { + event := parseEventArray([]interface{}{ + float64(1), + "acreSettings", + map[string]interface{}{ + "terrainLoss": 0.4, + "signalModel": 1.0, + "ignoreAntennaDirection": true, + "acreLoaded": true, + "source": "cba", + }, + }) + + require.NotNil(t, event) + require.Equal(t, "acreSettings", event.Type) + require.Equal(t, uint32(1), event.FrameNum) + + var payload map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(event.Message), &payload)) + require.Equal(t, 0.4, payload["terrainLoss"]) + require.Equal(t, 1.0, payload["signalModel"]) + require.Equal(t, true, payload["ignoreAntennaDirection"]) + require.Equal(t, true, payload["acreLoaded"]) + require.Equal(t, "cba", payload["source"]) +} diff --git a/ui/src/assets/medical/NOTICE.txt b/ui/src/assets/medical/NOTICE.txt new file mode 100644 index 000000000..9cf8d5d40 --- /dev/null +++ b/ui/src/assets/medical/NOTICE.txt @@ -0,0 +1,17 @@ +ACE Medical and KAT Advanced Medical body-image artwork +====================================================== + +PNG layers in `body/` were converted from the upstream `.paa` textures: + +- ACE3 `addons/medical_gui/data/body_image/*.paa` + https://github.com/acemod/ACE3 + GNU General Public License v2.0 + +- KAT Medical (KAM) `addons/gui/data/body_image/*.paa` + https://github.com/Tomcat-SG/KAM + GNU General Public License v3.0 + +These are the same stacked pictures ACE/KAT draw in the medical menu +(background silhouette, per-part fills, tourniquet/bone overlays, and KAT +treatment icons). Colouring is applied at runtime the way the games do: +`colorText` on each layer. diff --git a/ui/src/assets/medical/body/arm_left.png b/ui/src/assets/medical/body/arm_left.png new file mode 100644 index 0000000000000000000000000000000000000000..f69af7a4b0a3320aacac786b203812832bcd2141 GIT binary patch literal 3351 zcmeHJX;@Qd7Ctuwh!BCu7DSj-5G*RPsw_%W48#=|a0N5kK#K?}4u(ST7RH@zg2N(f z2X{fJQkAj~6+{bB>WVBf1W`sdQ8ppzxoMv|PoF-c{Wm}6*ZuDK&UfDTyyyMybB}qg z_L!_ORRaJf^F7_y0nl(s1BMzt-iYpx0We$n?u$2wntEF5L-Ze+)V3GwTFQ)yQDeIE zb*FwhyK&W-U@PwlMM>bL4ZFEId!lu1g8L&x{9@nct#wPP%IDg5W<+FpT%mn$f1>SQ zV;%PP0S0O@i-Ej0ewzVYB^u}ekhr1)aPUC@FyI4Qe=wbD>lcuZss117?>W6M@jmuJ zo>ZkjnEqZ22AF-E8Wc)uy@UmQRROt*EORGQF+qdWP-A|S{n+5Zg#t@{Se*LUbsIKw zB{6ca)!Ip&00yWY*hl^o%)}eZ;u}>lO~PZ*Co`b(ct{k z&t)b!a}m=e|*Sf6{g9qv7!OG-_$c}TMZ~==N=2E)bW|z<-r1QeGfe~q(4jmG$vKgs2^F_09?UmWZ z9|~|iVoBoy=#TjT3D7h+v!(P4s# zn}NGKvsYQG;L)j{Z57~ajJ5e@M>Sk*O_t;tt1uvE&WQmBv}jJ9bbbmT)Z{4?Ctki0 zfkCYhXJ_e6^ORU!I^0eEyU3n{!Ha)to{^e{uT{H$xP6 zYLtc{SyU$c);M|{$t;XFT}_i6wxt%KEetNe?#r}1BZ}9ykVdHs5w*Jr*<3leHQ^3L z%i--%c{xpBUnewEL(F=T1JGL0VJa<4m0e`T0NgHW>nL`!^}`O+w7y)Va5Uoq6a}=P z6!1m&6z&c;O0q2_EE~R092@ET!y6-F7k^%?5di{yWXUDm#uFM9@iAChbY|a0xQmvE^9d zblL8&76a&Y|9MT9cy|#4NIkJpPC^6`Ww!+(pImcZ)OIjd5BZc^z8vy{`G9q;2F_Q9 z&S6_(U9GnLH#7=A&%lFUj^Az9sJyrr6V}z2fv;HJ;;aB19Ii_xBON-XXbEgNHufrOGi1W)=leJ?tx6e!W0_wM4sP0>@7N!^9%Ir8@jk zoB;ue&#hG=gYqysL=%|RIp!`y5suZ9uOF_6Ml6RSS|am+8l8ZA zL~m+5JN-^)dk{(OS(JwBdZR#GIL8Kw2s{>M9&nncx&+CZfHn%7kcN!M#bZ_1 zA~Ya^K)lB1N>^!wRh*O7&ElN^@VpnI9yu+677cJ}@LQ#6x2Y~TS_WKz`tZjC?+Al9 z<>*3O7PT!i!%pwF`yJ^Tt6YH6zRjb((xjS-kUzK=9u;pwVDNz`Ie195e+dVXh2J%? zJ?RKMb(<=7lQ`DO+GAHepvg~;WnptE)y(aNuKAs!Db(hcHr5{Y&rGfg@enEUsHFSE zYGY!7IuEB^s9|$ilXnF0D>rnKcxHzo&R^P?BNj=0s+1Xa;K%dqNNJIg4!-P5uo#!m z$WB9r#!M{39oxIk7*q5vDC{bSb))>OryAq&RlaJQjz?e%kalhb(Q$XQ&jQ-6* zYJWvXG^{P9#-OA8dN{D4gIBplvVwP*GK;=ItVdvP&nutPv(wUR4q>qCh~j160ecP- z_y#tyDCc~)>RolSkh**~4bC!2WBelF?v@|rIu#kRr5#K03hM)ZU+oBz=qb*S>F(rW zXxM1v5DTwCcjz}U-ef`?%5~QV%zIUF^YnuVB9Dg1R#)!d3s4 zHfxo;ve$-{u41)b%i?UAw`r>HhuNp2TXQhhqyFwuEk-XnVR*7~WF zqNXz4hrsZK?nUQQoD^AK9kTTylBtX0m5>|i12y0JpF~VMaeq84=u7RiRT11gij(Nv zoHZSi^Qn7aw=sm~dTVqTV&BDx7gnb;7vnAPpD?5e<_5Sq7G^#1VPoHV25_V+Qt_5K ze~duCjNVXx#;Fb%myY!)!qkV^B!Eb2DiqZ#gp&M>vMOoZ%BY>$=iq1xL7eQWda-L- z`udEvPFfzoh9KeXw&y8>z1JtB@o$Zn2R;s%jRLN#HEK0Wj1M!$%jVkFe1j2n4(p*{t3FfL_21n}jy8u?&-p3x8k~QVtX}_|RIl z$QJXEs866&UkA56Kx2ujk-KrQ`Dp|8;LTp=CqnDr^ ztq)dCBZe*!r!#<|{P+*)R&&>a6{IM#&n{|`k+4rnb(L&QhBQTLX_m*2+F z9@OYQy!BB(O21d>_hLxq!@wL&`jz{K(@9MKxjQbdKDI3m=Kcyo;>-U9`Y+o3&j}=| akZW2c8*n%Bzut$#1OBqr?w6McqJIKiG(EFVq!;peR%jP+EbaaHSQcMW6u# z3P}1a>Qgq609FXG77@{qs-OarRJK;YV9*8(ByVng???N}v-Te{%$%9?ec$=!%uV^w zXPtrGOg#WFV0pN+0Z3>_0$pwN^CI^CD*!_o%YF4nv2BBoo71=Nn%(fcc|Juyi=D-r zt9_>4BlX?TM>%FQ-c8a^v?*L~D8`B%0oz%Cobn}|(=#b?4%&2Ga!sgL`>?y|JM$bDu z015yNp}_!j0hnlnAQ}VUX!Pc#zZp(L2fj}7R;N#dGttJ^m?oRU(dwIVqFqcB^tQB6 z_=FfZlu_PZp8n_yRuG`Q+*>{VfUe>WRMKgH|62QAqagEv7i&)bSab~6wJ>Ylp5jf^ zfHn1+U{RCoDP1XcaXi7^6yW^9>O3+yb$vsR?;U$&kr`eVjJE&eoY7sfRa(@P*3!e! zMA{b4vM>C0sTu6h2eXnZM$Aer5d}gU7WFzD5uX#XP!Tx~BesV6OGcXlDL6oKznWeb zP=r05Iu%J^R#`}AY#)kQ*84*=xyTX) z=~pjba2gml20rqF;92RqC9IBDMo*9fVjXTPer7~yO+iPelUc&6X|_ox83+$QfkR~? zzQaSo(~R2*nV)a*R2+RRnV7s)`i_>LH+D<~$AUORG1^7_wYK=qdDZ~ehld4j^2~!j zE?M27s~ftBpxU8z89bAvw*A>;PI z|B;px^tl&lHR}5ZnEr;k2w0D)%zU{vDsXD^Ngg0G?Q@+ItC%sCxO=Rvu4tAn@VC$K zrY7%Y8P*f^rWK#okE&L-268*HgVS4(T~ZtRxgEUj`8*N=bEkd|=%*F1kRDX|_xyDAVb=-XKC9LMjyZ-|=;m)rF@xVh|ch8VdE9HSTN# z>j9q{>m~0m=24K{)t|^h<;Cl`{*P4p8vcpzrvtOyhIBTIeYQ44A5al1Yozxu#H%aU zu;a1ntAur2yU*l<CVWmb^19M=^@+hb(tBP?7S+QazJAdvxSvM(Ur8O;O*Q2 zdBKb2@-;onPpxl1#@Jpxm!)53` zL08)6?Zt+~;EJ70$ICY6rF1!lsPSD3p4F%?^`If=3twCu2dd2QrNjztP-=F*^|Su& zwVEg5czIb8%IV~!bzYEgK%wZ}Sfafmz;k6i(ds0UWunvuazRuIhkMVY~yb%+mb>Brt|73 z^#aB#=Zi-GJbe2|2g`@YWA2Q!H1*Y1!9(N&@g`A)1?dni-u_BO7?c zZ_smzxfQ>B%VMGh2v27gm{vcri_lpgj@oV2I}*k(rBzxocjkK3x<2JAvASk1kx)&c zvA)x}K799zYZ;ZO&ByX!k-9U%wx{eUX&!1KK^ywWnyOm4Yk1r-d%&%$ux0eP11AdX zDuHm{B1)PtBUkq@>T)P=+p3_UoQ>$7R{V1P;_O9X#RVI}$;LJq$!Uy3vmx&wfn&0f zMrcN`O;wGi5bAZuliG(q_tXN<`jpLtRVUA;(68 zO+>>=mSY*gA>8ecRamu)F-njC>#y2m0P0$hJUBvPTy?c6Fh!S}gPR=ooj&rs^jBeL zkQTCz=U8F9xigRfESWG7T=)IRb(5xuNOY;xj47__hnSIO7-UP|F*Bf}A zwtkz^j_1c~-51;AanF;$tQeVdS2s;BkJ%c!+69ek!^Un4GL2rf+s_~Ye|G$X5p~$*gMoOzza|~vrc9|}ERBnc zO@o}?oQ>{Ct>$m&=~gStZZy|kGBJ>UvhaO?V2KSSfzhPWD0ja+s;LnUzbxgBH7*0f z&(Ad3>^XUgPOIUL&ET)CJEVy>mla;N&yQYok(!K85KdQh)D~5m*w`} zXui^?(>Y!-Xj4QdRJw8@OL2MQSr81L=b(;pJhnWme3v39k=}SCf~-q?67l1zoSz$E z;AklQxkmW$Ev0m43Sq$Byp5hKxpYD69oFenHJU5zTiL7nFf-z%mSoJ&XYhoQI1PI6 zQPIEdaOOwfmGaoPxUa3PO27oI=~i1$QLeH3+8c_E2`E=B8(r#o8 z^Z$cs;)u5j|DAFe2R%Iqz+Y-PS>#-tMG77MvO;Cxd+$#j^4RBVkKh68eINId IHJqe>1FR6XXaE2J literal 0 HcmV?d00001 diff --git a/ui/src/assets/medical/body/arm_left_s.png b/ui/src/assets/medical/body/arm_left_s.png new file mode 100644 index 0000000000000000000000000000000000000000..f56fda3a52e44528690c77fee9b68200f21ae222 GIT binary patch literal 5827 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 JP5Wcd{1;+(ig^G4 literal 0 HcmV?d00001 diff --git a/ui/src/assets/medical/body/arm_left_t.png b/ui/src/assets/medical/body/arm_left_t.png new file mode 100644 index 0000000000000000000000000000000000000000..3736c04a3c842be8200ffcb992e84ac4b75c851d GIT binary patch literal 1568 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&zE~)R&4YzZe);pL@DEhE&{od)F{KEM4OG z!~2?f%dQ%7=z6T|4&ZiTYvt41CA!f0Ab-FFzenXS6zZD$-UiH3<>2Fs>S~VOE_QK; zMZ^^=(UY$yOs?uMF-q{)Ml4eYG%@QJ5iN=i&OTR`IowwXuJL*M?vBVN5u!sLC)c=g$79 z*mZh7W|s2(^O>a>5@znZzdmeL@Z)p;59w{MH@x@R>!|}X!aRMDN%1$Zo)7>+&@IngyF!SL;p7${;|LNcABkRUR{W7 znCl5chlFY8_Nf0UpOCQ~dgw-tK?#tJwP(n%Ed-wEL_1 z|5_h27%zioBx z`(gGqk<(ZIJ|qt^we|nUS3zITvj6GaeMlbYAe-9wxwGH=h?g(e`)m8J$e$oTo$owZ z|E_xP@u;naam(GAuCKO#`_GY?LFc~I*1*4Ee|LE^e>iTybABo>NWbkIAI5F!jOSNb zC;zy=TB>c~(WH;}Z~uMs2dLxB^~^y_7*7BI literal 0 HcmV?d00001 diff --git a/ui/src/assets/medical/body/arm_right.png b/ui/src/assets/medical/body/arm_right.png new file mode 100644 index 0000000000000000000000000000000000000000..0632169ccf963cc2d26040a700a9c476bb6e3243 GIT binary patch literal 3380 zcmeHJc~sL^7Jk1Z5LN?ITR^BVM>J|&Ko}9RB#w?62ZRAeEJTCi6f9y81Q}2gb+lF4 z8dS7Y(c%JAL90Opfdm%>L_krHHKHhVSi(-o_M7|A9;WBa8TJ49D=+Weckg$<@4GKK z?7eQasnIkegpetB%}O7HD6pg;1AX`yj(w^^$VAOux!fnEu<| z`*Fgp$rK+HE<(SO!9kY9hp}OX99~yj5e^d986xuN{oN%~mMY1LZ-LFa_AO##?)tW< zelKycz0i3s2XWMNZH;(diZuCUU@(=2B)<=sEMm|^rQ6sm%zviaP zoxqdOX_Py(v|{5c8V#L4{E)mrd2x6r?zOdB$VmLe4AmZbeJoxf4--ON$&gV{4mM`$ z;yyC-NQy>}hIUz?sDt&Y$4ek__>#c>n)2`Q{U$?17XsTMbkc_i>SV)m#)4U==1c1T zJW=YlKO$(zc}uAQkWK;1bBYM?id~7i`~(7x9+2VGnFwXgZoV@|P-TxL z0nsr84aC>pNP0UgqA9$^6iCH)=SJ39LgKvzTWElL*Vr(5#1V2nX&sfq9Y9^y$cFpc zUUDIxy3h4x-sxm#k_FItX^5J)w<#90S?K{b+IkXab9L)iS+6oNKwT`v9Cd{d zcYIUa!7CkEpEMPiBOA#h*Krru$sZ!rd~*!)`MABm@s)ybY}>+(Yp>a11%K&lB@KXg z4*N5wrtvGz)`*}WpRv2b6*d~a^?ayz1wCIgcqbGyF*-<^5m$q8pMF}E+-#E-4%n2* zkmS)34v1It39wFA`6X(unum2z8H-#7^kMI8fQ5cQLn)q;oC}GY2%G(02pc`Kn3SK< z8e;y@i>8Rh72D6{>n0{WHP78JB6?yTzGI;|l5Y74JZh?T z{0!8dtYc~vam=iE5*T<=&k_wWm0Dq0-h{5(XBLE;Ko6r;%>xV$s?GF$peY`)-<`;S zn->d$b#1;`@E8t9>jg2hz93=pc1=YSFt@1**(n=A0~^M~G*jyprx!Fo3y6n^j_=HI zDa~S88jpA$J<2dZCzH|1jEH6s1lAO%7#-CN4gW3X4tdcf$pq#o=ayc?Qx@`Cr0TbY zgh}c#8e5&U6NtQccgrkw7aul#gEsF!AjLlM*vI0oPb2_u`s>6tO1Zh<(|OaZ?2z3f zzDnxeuDBHdWkOqg#6zbv` z@&FWk(s7%7XbyG}w+Thoz_l?zZ(5$HYigEZ?bJ{zT!BW zigcZ;-RR_f!-NFaZv-Zi_^MmLPCwf@W9zp4nWQpR5gtMw5Epc8#q{%?U)%K(+3}Ien$5i9L_a~-r9bS7D>*Ke*-Ll4{>P^3CLSIqT%opd( zsRNriwR=!ev@TVV`$o#3mN>%$DI2EA2I(aV8ROo3ls)!B*n&2=xFdfQT9;!}a-H8c zWGKww_F3DC&{@xr_A`10u*g_SXIPL5giz9K6d z*`R71uS&=A`-yudnM{b+=;5Ha;_|LE4VoSFl|WE4HTziuH?JhBU%0~_)I^{hnBmNcIsOe`NS186v1d uLoj~7V&}l>Lks_(p!ab1F$B#Tv3+$y0suy)8`_&hEr2`dZW7 zkLFniI-NQ+OW$K%W_vSZQpTBsHHM8pEPedL((~*0ZXj`k6JMV)e2iT-vHs@pyv5!b zmcM7TrA=F|``eOShvWI3=G89V5>Uw_jaM=??H$m=gf|I?g6jvsUSch5eZC-V9; z#9uQ1XP%T7#$)|;d5+dhbqCVWIsNoWS&yVaIM;#z;%J7KS76I=*$M8eY}vR(SSq<1 zcXt*>gT;##`r`?Au1*B=LW~#+_J~kgilUqnoN|v_CM_9QpSqku$j8S`%B- zn|DpYFz9{t=A?yHk!nUlhAiheg8^6eD}0_6aBurr9C*QEHO~KT*e9}&TQDH*R7-tVZ?HMjb~dAxl+)UYQbZ|gFQClQ zbunbv&D0KyF+_5EZJUZzRonfI3Cyk3rZ zCgjMS;k24ggV8X*nt@}+COF{No1s&YI9zQzgTVr$*8OkGUaVuK`~<#hY-gSAnaifq zIH^vClz8Cn-47WvgPm7}$<|WEot{4N50~5AixgZJ-yY@YD1u2cHYfPpK}A3S|v|FTJ?si zZ!D2Sr#Ek1HG&r?<=pHWFH86zGA$d{xNTgu$X6o3e=do)$LC=9FIP3e@yVeah5@8~ zPY$`QK&pr<7ieN1y+p$|w1R>h^ION_FQbkDoW4N;-as@1DJ{+egq`{70>Q8orIe()Fg>%fAujchuUP zZMFhH6Eb{JyllhzFyVU2Wlv7)B4s@{T_iVh8Kyrak)Yzvb7Yl3O=sJqLQJC6WB^I| zgXPM;<659p5Z6PKunv~b@2yl@D44?3-IpZ{2+C%M zi>NQe%BvjOpfu503(!7!lbvs_Qc9~@lC{O3LHv8Hxbx)bL9d6?KVpYyoEq0UJy**d z(Eh?3MW85Nj}8KU8v{wC!^0&{o@4u%&OT2M!SAB!ew%0x6!n@kM z2}HtkQn)f=JoPNGqF)x(V6~YB_H>-TK5V=`JibDP^J4{*)0<77l8A=Rw={7@dXPtd ziPyvXrqT4E)9s<-A)aMAu4#VNY-(g1uSP}?qVnhaLN)QTr5EXACz5qg^Mrc@agLHB zPqoQq{v!*tXb*P(G(Bfo>2tY>2B)JlS5TG<6jJwtPRxitB z?a?zAUC8Kgn!I3>z&7%D8}nD zwJo(rk0g;>Eo=GZK_Q2POY~Got$=|Ze#|+y?jEw5n-Fv!fj5mQmPJRm8LA$8J+w}T zg=sRaUgK{iGg{|8+w{jyJavf z$HHO9NWvbsOAlgmj(zaq>U|l0(of8<-96|k=ks5U_FyEcYt=V0+E>>;`C`-m-2LI9 z=d$hp_seo(q&BBVyGxO~w&JmA=)C{*(C5{yZpwO=k@cl>nx^ng`rY%b>5}&OTff2W z|0_Sy`^5)WHK`kBUIuQ#oW}}`s^yl*)TONp`;ZTjraz8Mb(FW4D)yA~&p#Emfxe5k z*}t=CmPoZcj*DCmo$DwND@ckcT15I^!d%|kd|pQfW*kx%$nx#e9sQ6D>q)JqT;a7G zlxwlH%3-wha4j!~XG{0UwafSkfiLs~41tqX_vIe07&SJBm-RH|>Ark0-}`e@3MJpF z(Vd87Ac#YVQTT_imn+8MOJgNyY|4wRMtwdn_|uC|r)39ZTl|sN-q|4PbP8Os?Gub6 zVA6QyzP`(Wow6*kEYYE7Ny!wM=A-|-rnZ>fw~q9BH`cz}i2!wTmZk*(C3BjD4RvJC zav{~ie@LPu^^fk{W^IX7JZb?R{EpmV^gv3KPIh=s((V2iINJKC9IWF>glzv8Oa5L# zRia1l&Vf|jwZq2X7#E(Ezg8JxIy8nq_Sb?CorUZ^KakI$N0nnI0&Ywc{`u6$z}M&Z zkjp2ZpCCZgRck;cKdN92fUnz@sL&@Z=#X!{x7RVUtx7Et_Z1A{i z4E#2X{+h{VrkqM)NN@eXx#-YD<5X_W7EX71HApUl9&cC z{A~w%MWdfQrU3<%tB5s#Y?Qvn)JsbAU_y;jm5i~?#Lcw-2KEL^KET^O(< z7V#6EdZxpu#?89Be`RwqlwyToYi* zY*#F9(WsX&!lxDBVL07XnWS_PqX+?^jgPA}bd?9?UxxP|aKKtB?=&}dYF|L`YImcp zCDM5&3kaR)u5V4~x8d~#vqr#9sb7R7t7p$HjK&1KPfXH)fs#2e$^yD10u;xkVUU+J z@Zg#lWDcVTbzxR5z<~{*TE~)tq-Tvhn%=|bYLx%p^k8W(kt0y?cThb}r^8crzc+&H zBvLjI2iCSyWw8ah?2zpV+^DI5Y@FPMTQ!G_ zk^#}q4qDxr8RL#Jr*$o0GX)-?!Z(&w_JUmIb;wbk9XbDG3Xo(01>N(gWI?hBs!TTz zNR%;ceCSnFWv@&Ole%1TZ8b1RebJT>lZ;~+f33WJ$QaK(aqjM%vTx?+p-p1ECc3%` z`4PwPZ;;y3s9Pt_@iyan5}p@Wal8K_%iRs&-L}b_umY!9A0c~w)8kko>A8ZKFi>4} ze#Q0#uX*90M);lA|3p5k$svkM@DUU+Fpk>xi%6xrPJi z)cP1raL+!t$^Gg|*uSsGdy2X~^_wMR9Fd#L819AUhf?Hd={FrfpD(y~8LcU;(4%FHB`c9=pV_uZSEhse*ICaiIcx5T` zN^gORyBQnkd7lL|f21qiEe2=Q+ZxySK?&4BrE_FIa?RkeU+uu?9%V@FiPb0pfqz@O z6VF9Q8p}+ulAbBz`*jWPHs&pjB&#&3F(f}xyN#JM3

7 z!WFg6-`(BYBXSq2yVcbnuO2nYNLG5**HYZ5@9ROkTJQgTW7_J`zU!h{AirnA7-8Q$ z!_{EM0VvJqWw$MeIvPPzD7%yAY$3e(w~icLw>g-Bfx7n~v21_iy`A>xw;N6#4&fV@s1H{9M|HnZ^!Cm#Gszu>7eWqt*A)VIOE4p4TE)d;b_UC zzYG&+<%Dy3ONU_6gf|S1eu4sol@P#0!e_OTwrWD+gIU0+U4%Z%s z|6qr8F(y6U9!ca`ARPeqXwX0GGGN=vD#x0(V$App5G$?DZ0ebP(~Vs|04^F>a4+sd3ruQSk%yV$@d!*JD>(s8ExTqlnd?ENcy*zn}NiO=tl_y ziqileiEZl%A^!Qs1Q%rEJCo9LJ6?yuCx>r!B!mL+=dWyVgD!dLt{TMDJKXSeQL!_< z|NVsx{bcCPi!Z-@o)`3-leA0>#`sDH!eIAdqeQqkIZTfX?>qaY2HH-v$%dib?6;QYylzvm+M99}ICZ^(MH`zi zMZJ38ZPEyQ`$WXSL%Phbu_e%V2Ko@*E@D$lr4pOBUjwWJ`P*hN5Th`7EJ9VFl302$ z)wU|-1wwwEtX8H{3??-FC!_`6t1U>d{iYaYU10+b_Yx*dkRO}sUQ9y-#pfJTr_m$m zVOIJ??RI|RADF0a{~|!QTZ-;rV}TyC%^&{zlzZDtt4U=`S-07fACWcvY9>FQ)+f;H z^3nGg@TgE>mr+=$*B71Cfb28+b>^NSX=QZ>-S9Yl{XA4E-+s$HgF|w~5s?jO zQ~<9}S)K{E2ujlk${mD^^7L~=o;F;1n@m0pn-@)Kyk`!12oLC|_c<=a*Vuh1t10>7~r#s3hv50ZbjJ(jO{>6SN{MK33r?=$HzW8@0=jK&bB?#TU z@N-tUw+#`hpQ`{0b&0G#pbJAQhtvA)e|R!NnI=?w6z({8iH6sEV=Sr72wt(!iiI<} zi!VqVp%Tl`B0{M&x z!`K{nul})80eCZ?^kI#1%p{yL=ug@p(Pl`ux%TEwsUA-fzhP}zgV(#Nd>z@A z%#3wycG)035hbjDla(OM)I0~KMgQGI*W+^rvl ztBH9#MJO|co>GNC;ya(yk%rWtGA1aT8FHvkD$#j7kquFn%b29~8dwdj(cT$j&xl(n`R^7DVu4jne?JsiD_s>Y?TxU_1ssWL0V z;N&*Xqo*7BgLz457ww7aw=ZG5>YzvQ6ZLJ7ztX4DlIW^~Mlr57_?W6;U_BdQ(&kSAYXoc<3_gjZF3s$pG6oJ!`k4dIaP!`PnZyfP>xJZF(Zb`OZ}OuNn%)N>UYCc#^F*3bfxIgRc z+!QWpplYZx33m;Ulw%cfeSAgtCc}t!0rp4IP{+lj z2Xn4-h(A1VKJnF##O0zn#?0ZxonBcJY$jk9T4@Hq_y3>&E(W5dCiJ-c&S0JMvj!ab N!2>~Gl<$lF;Xn8MmYx6r literal 0 HcmV?d00001 diff --git a/ui/src/assets/medical/body/arm_right_t.png b/ui/src/assets/medical/body/arm_right_t.png new file mode 100644 index 0000000000000000000000000000000000000000..4e7788eeac296ab9d0738b8054630de909bd2db2 GIT binary patch literal 1605 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&zE~)R&4YzZe+UcsyMkLn>~)y}NO>OsdGS zhxd<6>1yqD%vb1mdD){wN>^vi8j+3SJ`>d*{a46XGo_)oH6nsznozgNv1d(-ntC-B zIeDy1lDhaiGR2_!y7Z2&vp)0K+w#k%XY%pvseV?on-yr)D8Lp1I=7dw6r7fLeC@2^ zPTPjBR*P6RobPXDiDAw4 zzgKNP|GfF?+$Ckd@b)2bzf#G#=`PUDO@9k~ZPd_dZAGSZ$kKu%X!j?(y4E$m5 z^GX<2ZEI(+Te0t6#ow9d7!()|Y+^njeD(F;;()Romd3NCtJZ~zul8?cP}t3oFtKh; z-45RXJEQgGXOCa3yt^`PW#4T>1`dV|KKH}d=e0KMbuKwmw#xVvJ5b1>nEU@{OO~2V zf-K**H63s~_kxKbjbTCPAGHJBS9jJI-W0$3`|3Nl1wTSf85tR-i8A$t>WB1)9^YQZ zcIVwa$zp~d8*a^A=E=ao!Vr<6{$S#(ONsh7E$Ua#zRi|@Ly;kcjX}&@d_#2XU;gRy z*XxC-pJ4+gVV%p>SKqh4S@qX)eehSGuaiCjgGFaE_lMlC7q7Y7pLJ*dbL5-+=Zk;% zo7F>wPSi6Vc;s~8d~%QZYVByo1+RYQpu6{1-oD!M< DqIn}% literal 0 HcmV?d00001 diff --git a/ui/src/assets/medical/body/background.png b/ui/src/assets/medical/body/background.png new file mode 100644 index 0000000000000000000000000000000000000000..abf0ffc0dc087d61e3eacecdf22f5d5f2210dbd9 GIT binary patch literal 49333 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 G4gNp6aXyUz literal 0 HcmV?d00001 diff --git a/ui/src/assets/medical/body/head.png b/ui/src/assets/medical/body/head.png new file mode 100644 index 0000000000000000000000000000000000000000..237bc1d06f6bc61d05d90f4e5614912167b47015 GIT binary patch literal 2701 zcmeHHTTqi%5Z;7fLJ&-~trtXT8$?AxAj%~bf+9AcpbUZ+E)i2>$8agbB`pztuoFcG zioaD$g&-m$T17?0G65k&Qw*RurigOU7>o=Wj6gyFgXxKVaQaYjd~?SC<(&WQx4Ykd zyXWL?2w7`GokIlx8}>H=VE{z5B?4s%T6BWH#{sC}>;V68LFYhESK_CeplL9+)N&g& z@5CnOAQmeiC-?f8a=unFX!(h2DXm2o&!|pyj%gmhxNmcA?ov%_QNPevF7h1+>HFzc zdSCLbZmzo(EL+l~Ap#8mLs>`w4rn0&PykqHhZwxwG&!8Xf8c`odtcS+Yd4Oi^F6Zx z#qgGUnS=K2LQG6?zNNW3VtD#F(2`t*A|kN&eJj0UW;zOpP| zdHd{ot(Ux*K5ZZlokicpg$3L*(zaQSHR?Nv?+M#uEz)|M-syy|B=#h@U3{GFa>ZEq zCB)5x6U;~-uTdvOhK+Bt&dLH+w)962`PGz{r=;r}QIWek^wjF@HN6x938pre8x36F zXo0JXB?ho{aQm99)%iypkm>dd&*$M@WNB3?fdGs?;nU*%6hy@$CNw$Ftd&R|CF@E% zsq#WtU1!kKn3!$_H~_}*u!udM&-3x7p`Y14$GN5CE(!%4E*Gb21?sL{sctCBPCrBD zfQc8%v__F`v}&tLF5CUv2``m+gkv`|%(RD!2w~fQyJqdMr_gwM+GR~%i*-eJH&=DW zzT%$Vm@!y#_(-BQ2H!aLgNJ<2YRWBueZzrx@Jh+BNq}D)iO&jvKUV_FRxF#y=29#Lg~Ttq~z6wkjikt{LbnmJTtkR+s!-rwf<+C$@yUpTz3*?FnU$Z&b0 z3AOCGu;g(_3)$p)Zj^lKb12w)m-2_0>vpqJXE?vt&&vW%o&8qeX_uMx(C82FS?9s~ zuTbjN+d0uloeITC^TirH>SB&gRF)TA$v`N5jd^+vu|Y%mJyQ|tnKH{;E^Oq@tULhw zb1`gjRB&>+y3M#iVU>;iQk1>)#B|+#yTVGcdDz#KIBw||*j3}ggoUWwo6KX66$h$$ z&hkihraJ?1-Wjznx$I87)V>Byee{Kh zwC^}8~)y|tV7kby|s z#q6{bNt!n79+Ib5GjuDY{kYs9!1~4WoMvoY?|S)ApuWKc4s27IIXUKYp|_DXGsEw@ zf6o7WyGTIBeKG@|DTB?bEUDz0wcqcpUNvv+R!>!nw#h6BFUl1d8fxz|=+w_+5Nl;f x*zfF6U;C$exBAOdgUe^5t{Dx1;S&NIDz$G#7Cw*q5_t~fDNk2Fmvv4FO#mp2MtlGO literal 0 HcmV?d00001 diff --git a/ui/src/assets/medical/body/head_kinglt.png b/ui/src/assets/medical/body/head_kinglt.png new file mode 100644 index 0000000000000000000000000000000000000000..b5991e13b2c2a2d94e815f8faf9cf7d70dfe1fb9 GIT binary patch literal 1557 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&zE~)R&4YzZe);?|8a6hE&{odvjyAOsT~2 zhv&1VY~IMR>lIg1>(r>#t!%SJ6d6SYKeKa0MMSKcuwv@c&?wgy2Zif%Zr$_{(B3E# z%n_dRz-OcDjM#S*HWoCyOC6kX`lthG5?16nqCKtr=Z-(oce_Gfas z4bGc?#>*W4*rUL}Q0~w0qu|eN{_R>UJclnB@H}rzydm9|I4A#n`SaVKi$A~J>B^AL z&~V$}ul()ss)e^CkAK`5{b2|9RqLx0&z?QI>i4zd;%itq7!EMb@=*&tzS{r$RQYvv zK*Mj;f1UI5+@4ogSABhU|JwEF>;C(bR2>)?@|-zl_^7?t|2|E9diCk2Mfo>@XzKm- zHAeSmp3f_LJpcU}|M30m)~)SZvu>?b;UAaXtMB^FwJH6(Nq03v`OW`*Vh0`?z2Wo! zc3u1Da`En?%vZ0zI(xI^+ui*$e)xyWhx>=-Zu#iFHP|-(?*6E?Wvlg9Z%^|-VOy1vT&`qR@_Z}ysDaA2-Hi`?pa5&!3{l#6L^xW8)t>ipH`eSb52`2XK! m@T87WmyCwMFbRP>YwQ_|(zY8i$L!GndCAk&&t;ucLK6UQDiy^5 literal 0 HcmV?d00001 diff --git a/ui/src/assets/medical/body/head_nasalcannula.png b/ui/src/assets/medical/body/head_nasalcannula.png new file mode 100644 index 0000000000000000000000000000000000000000..92ceddaac45c25441eb7254a84d80ad83a6de63e GIT binary patch literal 3136 zcmeHIX;)Lp7A?l8i1Dc{G=r#s=)(b}2}H(L5z`{L!~g=32tklA2*^wVqCzVmb`X*I z(R4dpB;ddx!~~TF)1XlyfrKF-V1$5)8JGgxzV8>jZ|{?Pt+T4us#B--+2>T%^^+cM zx@!#9004Ax$Bvu=KpmN?gH_tdph)Un1Yj)+cjV{ONrIW7=-l&M0!k_&XcrgXYMAbC zk5D^T998}8T9C~j^M@i&xnPbocHO~XKFsHD^)0~U+4Spa{tzT+CRT6Tzd3VL!o6Fs zWVW@Fv#uIfaTjwkyAIlv>*g5Fx$GznJvz(b=%wmq{XjY=5|(f*u*IrrdAGIgoLMSU zG#JOhA$0z4@E#;u&6CJ8jocI}KAwOtzkd%9V>H_MwGDlKWJB+BW0g?KBZybC%g*sp z%<2(^)xm=)R%{9mQ}?Pzp@pw@yy+?)0nD{u`6z5M)^(X5+a*@EHHB%K(%aIeHEjBxYM{R9Ln2XKIpmJHE zHQ_l1Afr%6&9;g6^=wB6jQBmQ)Bx>9fEbd7Wv4&mecjT%jv9O2VtMGc8KG||=#vQ` z@w0^c9zi;CmhGCq5gB92GcS39PCs(aa^k*+kP4c*2`yevT)n#Og3HzH9!s#~D%5~` z7|Symp5_Zdu#CBO!cfek50sik&em2DhXnCIuGPk#dtJ5BP`-zP-NzQS(o?65--$T| zM((nrquPl4;~Uy8h{lpdof?wp+M&weRym<`oXH%mbeEl`g+ zqAgttr0!s&7lq|%MOhRItsK(M1>m@~y7U{B`vP6z?%5hWzHhSYjv1?2x;$DnYOTje zyFen5d}w+%i|FDCd$xQr+swX|u4?g9HwA5~4sbd(386iiy5iZe z9GRLWL*c0A>}$9K&d#hx_zTNoA%Vs+Fg9MUn@c$r7Z+EJi_q3QiqzZ6;it0ahvW9J zD|5@Rv7%P3jHY7KTV{HSZCkNrCSqZU#{tK*S*hYxGO!?9=^GuBeL!FWnFHTUa1uXI z6RwiJmqK>;S5{xuu<+Qa8DtEI-_qh_rHMQER*7Lz4P_rC=FyZ)@GBM}UCc>KSni@% z#dW+}STM6C?M#>7K^wh$B3Wg(9~&bwgiDRQ-H4jNcZ01kRz9{o+BhTZXZ|iaupj%l z)p^O(MnfIcw+;`b59`Tx088GJd1l?dDhJ`7Rri$#>J6ZGgL@ zb$l|-ZT7{J=o7R7TSCKR9Nh`Iu4}Y2%$|$BrqW0QA57h%r-g!MB(u4)aBw%}e7FNU zaPGy*bZUsOu#_%PG+UyPgl;Gtm7R-4O@VgQQ4y~$5bjXPL{uQ6;wz7vNfT{1;GFG=qi+b5J=0(UDam`-dZ zt|O2#!xa@ov?z-9Qp|C~;$aQoGMsGl0%~DEjd-q!H&@{0HxRe;xsvDKdB37q!1J?@ z?IR?<==bZAN~JiPm7bm+Ibs}7PtWwAq%b=j9Z=t_HX?+k!tif>xaAUi%`&Mzkvt>g z34UYdvW}0zhLqQE?e6}xV*qJV9e|oT;Z`#>3<++zHk;Q|f>aB5<)Oe~jOH^wX88QT zZ!g7H^iXwZYCtV$lWAtuG0ioN9#+Tyk$~q)e7L)`GFDVxcosihm1jW>EseAnMA-k1 z0mOwXZ5(ADwcN@;s)LYN`{GCS#mX^!Lo2k!(aGTbd4EAf>f^VKhP~?GQTMU7EU2aF zX72|rwGoMj4cHxE&*bg+&9}-@=!J5 zKF%912IFTECbsd^fH(E0|0J@9i{_|J&MS-k*x*1TW8-o{E_iQJQAmx1>Eh`r)MLY~ z^)xm^{4t(DTaqKrpC=#Q=cJ@eO$vXJ@m~__F*zIe?mftCj~)(iT#vlH+>t@0XeEXQ zd&KfI&<@fP(o|$l%(kUu3M+zVZQy$l)NtySv@-+$v~MWqVZJgE41(lp6iNN)>0{#(`z9z0T1l*4!H>D`!XjeEI)jaGH+=> zxwH?0>UR4QzkK!*kioC_k*^A^W*T~PDV%0V%e7dzNIQ(1RhZ+bmtNJC7X|0Tc^1jr z*_H8xrY^1~a#Joe1&wr=70pd#GGjRQ4*E%HpClV`%sb<3Dgn3v;L)?Sej7Uk=X9Gy zf#Vcc^N}UKnLW+@!qU$GtiW2CN}R`_OV}+^WtIu}j_(S5SKxmr;JXFZcophXRpL6E P^Iun-tH%-gA!6n~6L*5< literal 0 HcmV?d00001 diff --git a/ui/src/assets/medical/body/head_s.png b/ui/src/assets/medical/body/head_s.png new file mode 100644 index 0000000000000000000000000000000000000000..a9f5a88feb125375e543a87d479150f4209d683c GIT binary patch literal 4142 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 GmwpFdHY<(* literal 0 HcmV?d00001 diff --git a/ui/src/assets/medical/body/leftarm_iv.png b/ui/src/assets/medical/body/leftarm_iv.png new file mode 100644 index 0000000000000000000000000000000000000000..261314898afdb63a68e4b16add27af5b5bc6e39f GIT binary patch literal 1697 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&zE~)R&4YzZe+UGCW-zLn>~)y=%K#CX|8g zLHWDAhj0c{)w^a_E0{=FOx znp#W${A{^y@$}%OHjakQ_&?U24T1~{F72*YW=LRR2;gyG6krVKKAJRf-+NX@E(QZv z-^+Jw@BGi1#IC|zp3}*&;y|%yp32Y4or~Z9Zf{GhFgzJI>GbW}w}YG*8D`9Qoy_^e z^yJEY@5_&!_s&=9*(meUF{^#Y# zmz3u*F+@bC|2urt-g8|CpE3i(>+JvQ_uaVj=kb^N-BpyG3^=}zF zAGfqHESMKmkf*~i--DUql_rB5BQRWrK0J$HST`+*g^6LRNJI0|DStPzyzyWG$}?Ig zGv3f&Dl>tD;mYTh1J9E=EBuuh0{l5GHf~b*pxMdbV5|CI?nZ{1NUxVFObn$W4cn#^ z$TDXtPwcp0yyfS~K!ZoYXb4~nfeqYVnwm>BSQt21ofvm#`g1bqEnA#_+st#F zYUU&v6W`AUCnu};PnMY)6cjo&NfDSr_g?n@q;oQH(r(Xp{};Y|9CT`87f5)|@@eB z^nQ8ywar$KKi=R0W`P3?f48lRKmY3M_lp^OjS40_nN|cen&uT{xpVHR+IOh+{RE~TgVI4?2upT_VM`L$G^kBZJ%3q8EoE$nwYw2Dl^Y5 zyM4>Yx?G@vf#E{(anE~m7vBw8DZs#BG-GLl*~#Zu*X=jizdQ+K?oPf1zMuZdEZaj({sMuf+frf`a Y8UNoaW_fJ!d{p00i_>zopr09$?NjsO4v literal 0 HcmV?d00001 diff --git a/ui/src/assets/medical/body/leftleg_iv.png b/ui/src/assets/medical/body/leftleg_iv.png new file mode 100644 index 0000000000000000000000000000000000000000..94a83eaed70496bd50ac20562fd95833d3df25d2 GIT binary patch literal 1860 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&zE~)R&4YzZe+UxjkJRLn>~)y=&MV6Dq;> zpuFipn%JRtB7w(FZSY*g(0i!!J=jz^jG_QQW@pnIvy`x|>1Rx>sq2X00 z!wz{b&HCC&FD=g=XnxVa(eVF`eNWu`bw?Qj7#f}ye|W!)sX>=P;k5O??YsLPgpa?_4MX`UC+S=NZJZzHv$7g#Q&Ggwe$;Q6A)@rVtXOZej zRaS<7@%%u?x%*!~F6`NNUGspq=RBWJb(2X8*J}*}j>z{`>a3b(8YGxv?_%Ff<(98d@*s zTeSB6`{l29l~3L?_sQ{p3;(sg`*m+t{zrCJhKQ8U+!b@#V!S7VF(89U#uGEuN&;V=ni2+IE*y-nSbNOc{CUtoLL`e1~NFz z+9~v)V6VuB?^U4;3>Q2&Z>&^(&|#vmjiDi}CRF}>7Zby+nGAbk)ETTVF*LNjnK==B#3U;HH`Cti*7^kMqVl)d$Ag+1VP*-g9rzg~)5vSH*%Q zD^l07>`?;??y(AIx;GJMTQJkP^HUr6YYYS!ZfP>6KleJ|r}=b72*ZM2p$|TqPhUqd z)#)zXlPSf(@HR$iN9}Qw2dpO<9Hdzopr0JE@{ AE&u=k literal 0 HcmV?d00001 diff --git a/ui/src/assets/medical/body/leg_left.png b/ui/src/assets/medical/body/leg_left.png new file mode 100644 index 0000000000000000000000000000000000000000..21cc45e8a0c48bfe99f3d5185495394b506859ff GIT binary patch literal 3527 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 A!2kdN literal 0 HcmV?d00001 diff --git a/ui/src/assets/medical/body/leg_left_b.png b/ui/src/assets/medical/body/leg_left_b.png new file mode 100644 index 0000000000000000000000000000000000000000..9085fc7056af83230f335dbc28ed423388154fe4 GIT binary patch literal 3849 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 B;OPJW literal 0 HcmV?d00001 diff --git a/ui/src/assets/medical/body/leg_right.png b/ui/src/assets/medical/body/leg_right.png new file mode 100644 index 0000000000000000000000000000000000000000..e9e23a77fb832bef94d476bcffd9bf7151e2f7bf GIT binary patch literal 3540 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 literal 0 HcmV?d00001 diff --git a/ui/src/assets/medical/body/leg_right_b.png b/ui/src/assets/medical/body/leg_right_b.png new file mode 100644 index 0000000000000000000000000000000000000000..1b73515ee258938a93668d873d384a2d05513422 GIT binary patch literal 3781 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 G+5Z6!XYcO- literal 0 HcmV?d00001 diff --git a/ui/src/assets/medical/body/leg_right_t.png b/ui/src/assets/medical/body/leg_right_t.png new file mode 100644 index 0000000000000000000000000000000000000000..f0620c12acc6cddf88dc191788bb935fbeed15b9 GIT binary patch literal 1944 zcmeH{>raz+6vuy0eF_z2g9zxPn~)^~Wka@As1OjOhA}Q8loqhH1r`ig26YRwm10{* z2@$H(jW`K~U4YOVO<2LQwU)~u8`Mek;Zi6Un~nHDEnvB{{+c z?IqNE@&jq1#6!q|;rFI9jgJ*g$lFM(!T}T2CYxH;T;=9fh4gWWwon2AXy9##!H8v8 zXsS_dm)1PJXsYm0I*X6l(?s+1f0e9WH;$uY==cfq%y;Jb%A0*M)Kt>r1F&Xd@Vuy~ z(3KPjXfH0WqY?YJk?>o|LQTsv{vktYNzh5nklC#{y zw(iKlOmd0|Cjl+3-xBZZdgW8KqL}v1^plclJyTEKKk!9K07il*Hg==Wj}kcY^k~U> zn@XlOG%gjtWVBaov>=Iy&TXaHX`QrYB54`iQVH|9O*EIFP7ku9QvXyG#rPUxq*gj& z7X$2`1?CA(=6toMYTLaOYW_=PtvTKquK2XmpxUUjZWXOx4+5VMK;Dn}!Y)Nf^iCY4 z;@*F>u-q}*apBY;3daK=%_2-PVj~aYq(7xeleRX)koK&OXzi44&s7BrhJ1j~TKIw# zywck-^9UO?_gSf91RX_xc?MOHXnC$tkp($F5V7-=g*~wHN;;m(WLk1uWWjEiQ)6aX zGGlX?pWpS|Y0JhLkC@&Ge0Xh>yUx+lk7K}%1=S*V-Nf069{MJ2Q{@ZB^Kw^Qeqi<> zz=Sn~1aUgmWfhJUX;I_8H%qSCze!|V>*l8c0D+u`GI2_qkLHId+Q6c6ZR5e^jQ9u` zlb}fu8*>qVM4L60*EEMF!bhlQ`CEyRI9!8suuqQpuZk)l2@Ea0JoJq%zLUqsEnF;l z&4Jl<=*#{<#m{Xu?v&Zx1OU=C5%UYLzuFJtzqy@}|F>tV|Kfqwgtj{`2_&CMr>IY} G#s2_+W8Jg> literal 0 HcmV?d00001 diff --git a/ui/src/assets/medical/body/rightarm_aed_vitals_monitor.png b/ui/src/assets/medical/body/rightarm_aed_vitals_monitor.png new file mode 100644 index 0000000000000000000000000000000000000000..da57af06dbc321ee530745b818ce8529d631cc7b GIT binary patch literal 3085 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 literal 0 HcmV?d00001 diff --git a/ui/src/assets/medical/body/rightarm_iv.png b/ui/src/assets/medical/body/rightarm_iv.png new file mode 100644 index 0000000000000000000000000000000000000000..a8771e33442abfec711ed5719ca264e46298da76 GIT binary patch literal 1697 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&zE~)R&4YzZe+UGCW-zLn>~)y}Q3#rc{FM zL;9wPw^J1+Ty`jX*fn8^01LC&#Y6J-4a+(^rm|dG@*$sN+186{8ch;9R}SedXj1S9 z;s{d|xYTiIqG`vQMxn9;N7lCr=U-G|@!Wj>XHCM6$7$Ou6Dsdjm$!KW?HmR43W16# zQi2E81)Zw5SLyBN447}L)KERkCPRZ^17pJ}bB1jU4ZI9I8~!lzFa)eTnly3lTvRL8mf184eh*HI)DQ^#9h>e+&japX$?|9@_s{h_T_<>G~kApX>Dh z7XvwoJPhxFy0=a-n#RF!1!&^0Pw%}nkE<{-e4YM(GuSA}P6mg+Pyc^8TTLdDbi>#R@t8AR>xsPE?gS-D>Szqu+i!&kej@8>W6e>;2W zl;ul++-d(0PGwA(Uuu8g@NH$LYd6v+nfOj#mRXlQ%MIxA-{1ezE6tADGi*bk|3Spp3&c_i8J`ZZ(126GE;hIF5QKxMM4816*mTkk0Qu-e(rER>%?fZ;~(>i;T| z>$dZM7vJq?U1yL5RCd?M#(nd;Rdshswxz z@g8;0!qG&J-!7i?Y6~t)mwH}4J|XltQI)Gzzwf_Ko89f1sLyO>F!MPl-8;Vvi1x=$ zyNt{P1kK;|N)UuebtXhrKU-*ew&v9FBy)j;;!f>2@Sg)o`m3JW;f&nqqOb1aRrQL{Y;qfkMW z^QK}TzI|RZWV1z>n^qsO_g(}97bWA{& z$ntr~f;(+z<@ATR9zPDBiPoxym@m{qb%U`B^`0i>icP80!EiB?Y7K4)K}fG zKi#%*>*lciYg2Yy{Eb?cPBLt+p-N<4&G8qtWP<`^secBPwk71t-LIj@;5$ryl6Mt9~tZlD=Dv` zYkB2L%VM&jy!Ac}BJU{Ma=wVW?SsTp87YxCn=Ac=I`jjxCkhd1Cj;>Efqm}Kz-n4t z55j;7qTb0ayh}j*Fpp`Ox9_(^i^WQ^~N0GHN?G{47{WuI= zHJSfu6aW$v0cbgGy7??1!Vx@raSTZL??814<`-|KAs{-vDZ8T#Z;61=hYWb^0f+T- zc>BBzL`9pp&<%nQlA)L?ztV1zp_{v*I1>Oy1JM3>1D~IKV!tNnzmQ3ie_CUm zI)shiQ{Zm|{icHycwX!*TJbTr8W-sB{G7A(tq-mc^z#VnUvznIGFPS)Oa^1I4FM1~ zZFc%**-m&(zqtMhxkILq75;w6g|KI*{(lG7ZaFBUs_8Qy;7|8%Ps9H(HL>MrUe&)0 Cnz?=e literal 0 HcmV?d00001 diff --git a/ui/src/assets/medical/body/torso.png b/ui/src/assets/medical/body/torso.png new file mode 100644 index 0000000000000000000000000000000000000000..636de65fb74ac7a7eabc60df1d2a7fc4fe053afe GIT binary patch literal 3132 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 literal 0 HcmV?d00001 diff --git a/ui/src/assets/medical/body/torso_chestseal.png b/ui/src/assets/medical/body/torso_chestseal.png new file mode 100644 index 0000000000000000000000000000000000000000..cd13325ba2ae8aac71cea43ef49ba56c0a24a937 GIT binary patch literal 5945 zcmeHL=T}o})<1wFj;P$ZdW}-V83PCiD4-|^QL%8ON|mm3B>@bE4#8p27g4yJNN*Pv zFjN5vhLXTlkbnfF1qcukLMK!c0x9od{)2a|`SPwc&ssa@e8{u2&p!M4l_XkQ-a5En zZa)A32SL;8HUJJylBucHz}rgcEoq@1MBk2K~RDMYgz?`!|G2kdFE@?rS%yDC+oW ze!j`x}*`-rm55v9s-aJ=~!nvMIy`2g>-eiaO==MAdZUo zP5kZ6EdtUc<5}Ogk<|Ib`2?@qxPP`{=`B< z6|?>!XWvSpdOffOvIH9H>NZ*#2tOb@<%Cf~QH?A46xcPEjx+%+k(VyjyMd6ZXfrXI z>u0?9^qQUo5EjKoxzRW9A^}Dm;O}n=<$+w45kn)x@icE7bPb~AuyfivJ3Es=E#-)s zhMH{`>4M7_x8|*440z3AWBuRu04?M0e4gl0kY!WEKfv`m{6^hnUi@sfyqugdH0^=p z+d<|%maNc;?)C0ArK$nDLBUl>{JXa`0DO}MJ{tB5+4Jky*eqdf{nHJ_$OUVPNZx~$ zH(j90@8)-+z|std3`9`EwZEsO$)sczBU6<552191N7T5*(>W<%%qI8$4mOIZ)_fx$a*I4W$nu!Nu*NGi5I6NHsfz&0S|xbx7GP-J1p} zhal)Aa>2!{=_qjTIOZ1s2m{+Z?%YWg?(j-<(#R#k{7pf$6=gfED)vW5>M8Nt>cTZ$ zq2IR4ApuY7_;E!w{qyIGh(xur9EXzTW+SkVT&D}xRQk9@tg#IZ4Alu}v-LKlpQ@^= z^y0$8Sor@K11(WwpQt4zs!7kbV}5FQESuKw5)kfNY|rYgme;d4$ym^*(v@5j96eRj zo*?XYxlmF}Q33mKct_}MQIXG@?(CX!nwba(ZR~KDZGn?gKog^(v9Z6uzt)-CL{j7F$h*qY_NFP$LK6VlSCM*he1hebIvr1hZy{5)w9s#U5 zN2{Dnog|T>Mk|rDClgXp&P->syu7?1mE%?rkw_erLhKznaNtlm2u%ek;Qhb(>6q%LYBONrY&QeR$G6=W6=uy}>JO`0ToEm-CmooBtN;?FJK zVjS=|F%dq^J|&|ob=2>DVf~??ohN8I@;MySNEXO zzZi?}Lf$K?+)!l1W6tlXNEtQZxYC61nwQCx!0c0vZA=O2fUAQ40~Z!I9(f5r8SMsj zx$$s5qLqQK1KE!5kv_)2u_lA)x}R_jsEhb+jZcma-OD00G@G`6_bFQw3T60&6%j2O zc)wmR5(eSUlnQ41MT81Wg({QtzzpImh_*^l8}q8!aygLq`l{Kpdf5Yq(8FsY#aZY~ z{-j?pL#pv;`?kkr{94~PMoQ&ML)9W1hNV^$E5*^==?QX`{6?Pt-ncN@ul0avGE~qQ z%(-@9qV^nu?$$B4I!8*g(8zIdc4_wwq?>ybe59Z^7v{429?!QYD;jEX3l=;=U$1;N zvfo_^r8HjiN{nMh)m`@!$Bz>=EHct9tt_-h-rxV!J3M@sB932KQIMB+IcI3(;@TLA zAzC}VJ8gGV-G<&*TwGiq+JwI)<}Nd}tzKB^jb%2Sqws8_JSG}&iz7h-k-o~SU9dHr zX5fVk0klHS3t-@$vAb zmtIO{Hm-FK2iwX{IWk^)C|N<}=)0HJR&Oa8yEVU!M5Ck8^DS*p=!;W~ja3s_*`Ij} z0SyM&I`%@xU=gkk#|T{>!&O&TKWd;-@gsv}j*f2f%??aTb@aDW33<1{pE}V#;b>g{LKf22!os3xyJ6cC+AQEyEe;Pn3Pod=OlN24x*&Fy7O&** zI&^mju0|9KC5HI*_}maaN76X5OZh>2k6HEo`zwOY5@R72Dhn~$_l58_#%pIjC0cL_ z)WMR(XfG)HM)!d@0Qea1pbQVjpha0y2;L9+3O7R;G`N5>SII&y7XJuhTT|x{=|+w|C@#b(*BZdzNYpA=aE<41;c`eQ>E<(+!e|fhld|6Z^?hj z33wO~c=UEc=Va^TL9l%>VBrg+(y39fIp3e5j~RXY$H^H9fP)5tg90rC8~Tqz)}9d; zZRK}=D95FLPmtEpq%@F@yj1JSey)2^Lq7H*7KeOUCZ`Ku+HBTH${l`YwS&@PMk`)V zzx4Z{PgjPb*?J8Cz^G{G!e{hgP}PNng%zh9Z;2j2Zb3T>qUr!Io+|%N1j*Ikbuj$h zBl{_9Xr|RHo*cH*JOyH5ZQzqcce=pP{$njsHQ(R-fb^c@z<0~NK){R+@Dl~@z31ua zx!Bv=t8LE8oXA4X?xhzs*BQ0_sF>M-bJxF4YS`A7my;I-+s+o7B&lzI{R=QZJbdRi zMA6?_Fsy%U8NHFiLYuDJK>Z$~^^D(+ja^L(ln_Wg-_lTzb=~{N>S0&U3$&$Hbe9L=Ig9|=(2L~cz^vTdCj4Aq?^7~Mtp}sOG9J5v z#$Yf$aI^_msNzTrX1RN2W`+pIh<41p*?yVVe+53)mg1rKFYWD|)Kd%k-dn?EW+-C`T9p+6~?? zUb-Y>CZjffI;||jp~TwlwDx|$8SxhYB~r7?dQP<{*mGqs@KLuDKaNQFv%=wDBUvL@ z2yPzz^?_Xy9J9YoG*{D2B8`!njeyfWp`nr7n-;k41kdX5n8?V6f9TR`in?szx*F~{ zcSs_MruI`B#2^eiPTU9S>t4z(iHx?kzI zH`SfE5@R8ZW>Z(WtfpfAO~^LH#dG>^*AD}$`3oG5CA68hV8$X+>_LaRI?_wMss~!i zYUlO!GgK=@cr)GTk`Z!(|mO?9?ALQ8Ur^5f3gYW6Q)q`V?{helo~nzy+D9VwLo z`xs+m(eKIROaDGlVP?SDj2RSe!OjmVURYSjAh_nEd_A$Ri({K8cGPkT)-(EbjrD20 zWLJ3*PTks`_LA!HsP>0a%8Qi8KHs)P*KmJNC`w3NC>m6C(65MXAd~s88V^=0oL!x0 zSfT7-mximy!dNl$C0A~Aww zZNGKj#f2FnX1u31O?i9Uf^?WX@BXsSQ{~D@)c6Px6%|E5;x{W$oe1#=g10nG_KJb@ z^z_Vmu<9`0+$@w;0UaF~iQHsvAwML^tF82D+Cfl9HxdIInEYX#vloN$0cMN6CbE|w z&%@Yd5B7yxG3CLhh6V;I;-@`-@MHHnLIP7*eC`1Ro88g?Rag*f$l9K%*Fri`y=Ie< zL^CM)rJ^6}i!Ezh_5c!Zp*0rTJQ3Rw;u`i6#4Cy_D6w3vh+a+O*)Er`(Wb`B7DZ-61!HQV^Qav$E_lcRZTZ#xL$j zOixcQL-{s%UI75XhkwS+=Hs;tQSWteI9!Ba?TfbJ#o&AVTS|QYTkw2;YiGZ#pZL=c zTPw`x78Zzq^oUbnAg|Ko~Kq3)cyxnsGg+0TiGM;b7Tb;w)w zfy`|ODEkyX;;jZ48hdZ{TfOjeIIQTt4Gj$?B99+CHg`5dS#{#_H{9!$>l-u*p&OQ_ z_*A4&DC{yYFsQ*WNsHjbC<(({1RXYcJbffFkLoKP5!DI(b{L`PxsLKDG-j;g6(h_=}0e4O-(lZ zhLyBB)gN;UmRVzP1(#`ClL*-`BnY?UIvl;6gN@SLmOp(0TMK-^g>1$JRa*6%DnSm?C5-r( z8vRI;lBo9?LNYF|F*M&&=hgIjzERYAXR6~U!J9#M`vOg`COu;;i+VXo@>v9MKxLl! z0oNY~#raDD6XSoA*f_x-J{2)Nl_EGz!^g}J{Vu5Rdku3(Gm+X&Ti3b-sCSVpD=llO=od9oG0~~-~pIJR}x^us=Y@7_HHHaDDHMW;e+Z(W6Tcs$d{mQ z*~eSjhg8Aa`nMzI*%?Ra1x#&mCgR1D?i_t_50YhxCDqqEjw-=grx9a!1sl*D2WM6= zj9o%Ng4>X0h(ybuSKEGgSTMzXX4*c{*yzcT_0fPTpRLYdNZ64zW+<`($=5T(bRNrk4H@(eMIk(Y%=hNCnuz>W3JLE1p9pm>Em$+ zfZ&YogR5g3JwE&zHrY7Vs4GL$!=verfQ&4#s422jucI!1Fv|ixAV)%Uz~T;xLMo2v z9vC|lKT;p$!ChgYSgpNG6g*oS&rt zt~*0S6e!mc|NKtrAx)>Xh++Ea!g{{kH@EhYqZyi>2z!b#$M22Tkp6 O18-Pfue^5epZ^O`e}c~d literal 0 HcmV?d00001 diff --git a/ui/src/assets/medical/body/torso_i.png b/ui/src/assets/medical/body/torso_i.png new file mode 100644 index 0000000000000000000000000000000000000000..14bfed2f6b9608c6e0c74533f2f438d365891e45 GIT binary patch literal 7682 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 M&z~DF-~9c51N`oTjsO4v literal 0 HcmV?d00001 diff --git a/ui/src/assets/medical/body/torso_io.png b/ui/src/assets/medical/body/torso_io.png new file mode 100644 index 0000000000000000000000000000000000000000..5cfa37a7da6b584b6d2c5f01291a2caa030f7566 GIT binary patch literal 2166 zcmeHH{a4a=6#nQII*Oc|Cy!crN}$|n*NbIHQ%;!8peDHQcugA z+P?UfB;?SnOrn(XHEek*G@&wdhGaO%7NYreN(g=#?!VYs&oB4>aG!IZ``mkOad_B1 z9M%~N0Kh>(0aO62EvYrI#aP1Mj2DXl*!n>MdmqiENT7pb3l@us z4RZAA_?&$aPn6=lJ#Us6e%v(DfT?FBBZgiTH}{QS@@(&ux$c?VS6N?JH2hc5{#PZH zO0l;E?GTLG1zLmS0=2lgf?A9LV|a-x@OWgGS6=H6Ogz}UmA!U<=9LPh!XJPt@rZfy zwua%J#Cr^S5u%w^O+|gHO}ybb8uI47TWF z%&Lw)bD+B>;#Vuyil@44aqt9FSdp1SGPsf&Zxj>3mhjs%*gu{G#tgCVuRpE{7?js}f3ijO0^{DHxy02lB z_wufY$DQIqnI00FGmh+b8Gvkm#C#AQCmJGPSWSkcM8jabN4vM=7T!ibtu!8mFYWp% zlLkM4R!26=bD=n%gI(@We+r#GKOho`jBL}%%pNz0o3hU?*$CODE}YRcA{0xCAdVLqMmnPlgkc9V!Z8~eGw3yJypeg_Zc^C!u2 z?fjc2+-~iBPIZx!Mfz&$2p#T*XK_r=tk3eUv^KNRMwB6Ll`ClC{CuyeabKkZ^%0BZ zbfsfzH~_w~0f>8Xjg{if(#_}S9$#Iy@m)f+ggn{_eF%UbZHrq6HUrj_P-JFDu=cWx zmY&VOT#)LI8JE!Z1LQjFTc%ugnOw7gj)x*f{MHZw?%#5ER;qpY$ov3=(tGX{4isK8 zz8hmOJ=zyrg9{T0rEM_�&aTr`iq+;;qiJivMhhN z0ZF|$=`}WEl(4n$z5Shp24V#i_09}in$-T6;xmi z4sSn%eBa!bBd1iRHBtnLcS>vdiJq-LBqX?H>7*J;S&h-g(&QW;bZJk)ySNS2_(6Hg zP1EZs##J%%8n1N7Wv;0%82MS!@oo1i+sFQ-l_B~_{e65nj`M&=kYr?;@>mg0gWOd< zyD#Y3yLjrLp6D4@%L2YzW7X7ok%&xRo8EeMP-QvcCf?nL=}Z0UyRVhMAPrn{p98oM z$g{5bdgYB%BQtHaEr*T_MqP=ng=$kn(0iK=g%^XxO)j6wdz7eVHm_?-$aXt^p533%?XLbIpH^r5`J6Q;(7q7?{@<7&#L)lx z($1Ot*Phqa(cPlZ~(@NJwwfuCr;`w&CdpV27NP{wj|@$uET4uMNPWxxzDqxw!Z%T z!G-&8?Wu6qU|?jB*dp*DRek3LnGfA6(y8nDnA({R)Cn_$O-cc(3!5~X{q#}k?%At2 z{oSKE8`M|4*ydiByN+Rx%|*GD($%4pQYthwfBl{8dw=S)1NF8ixxLt#7z`@>)GaUP z_KV#8b|U-kw~VfPd2Pm4Q&fP=Nq+*ry?V&t%Mp=TR~`HlZ<DW z+4=YPwRrabDm!<537+=;j-=Nm%N?>4o~%}h*Oug9a9}HAc#tz`tNYj7JXV#jj~hKd zE#hioVBpzzSH-gO%}@8Q(O#eOUL9=G``-){{jhII?fU!wm)_6%YBkrd^xxN+KI$|5 zioV@QS*ET&dC4u`w$mxw)~uJRNq17{aBMj6U}kj0nWe?XpFQtstg^=SU>y;)l$=bt~kvY8PW)DH8_=F5wPPF=LhYXpw6_HUB=T5o!? zXmd>x55o~%hJ*xP)6-8?uFu+*?OEsXx31x`@9j-NjoJ(cHk*ncsF?J-{rn62^|$?H xSQ<9ndoI(`kedE~MC86vPmYEFC0eVQs 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 X3;P{2!u`7>=f)p(v9JEw|LT7L_3kJA literal 0 HcmV?d00001 diff --git a/ui/src/cssModules.d.ts b/ui/src/cssModules.d.ts index 96ac9c7e8..f7d8ba42c 100644 --- a/ui/src/cssModules.d.ts +++ b/ui/src/cssModules.d.ts @@ -2,3 +2,8 @@ declare module "*.module.css" { const classes: { readonly [key: string]: string }; export default classes; } + +declare module "*.png" { + const src: string; + export default src; +} diff --git a/ui/src/data/__tests__/radioPropagation.test.ts b/ui/src/data/__tests__/radioPropagation.test.ts new file mode 100644 index 000000000..4a480e8b0 --- /dev/null +++ b/ui/src/data/__tests__/radioPropagation.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import { + ACRE_DEFAULT_SIGNAL_MODEL, + ACRE_DEFAULT_TERRAIN_LOSS, + TFAR_DEFAULT_GLOBAL_RANGE_COEF, + TFAR_DEFAULT_TERRAIN_COEFFICIENT, + extractRadioPropagation, + parseAcreSettingsPayload, + parseTfarSettingsPayload, + resolveAcrePropagation, + resolveTfarPropagation, +} from "../radioPropagation"; + +describe("resolveTfarPropagation", () => { + it("bakes the TFAR repo defaults", () => { + const resolved = resolveTfarPropagation(); + expect(resolved.terrainInterceptionCoefficient).toBe(TFAR_DEFAULT_TERRAIN_COEFFICIENT); + expect(resolved.globalRadioRangeCoef).toBe(TFAR_DEFAULT_GLOBAL_RANGE_COEF); + expect(resolved.source).toBe("default"); + }); + + it("keeps live CBA values from a recording", () => { + const resolved = resolveTfarPropagation({ + terrainInterceptionCoefficient: 12, + globalRadioRangeCoef: 0.5, + tfarLoaded: true, + source: "cba", + }); + expect(resolved.terrainInterceptionCoefficient).toBe(12); + expect(resolved.globalRadioRangeCoef).toBe(0.5); + expect(resolved.tfarLoaded).toBe(true); + expect(resolved.source).toBe("cba"); + }); +}); + +describe("parseTfarSettingsPayload", () => { + it("parses a JSON string the same way as an object", () => { + const fromObject = parseTfarSettingsPayload({ + terrainInterceptionCoefficient: 9, + globalRadioRangeCoef: 1.5, + }); + const fromString = parseTfarSettingsPayload( + '{"terrainInterceptionCoefficient":9,"globalRadioRangeCoef":1.5}', + ); + expect(fromObject).toEqual(fromString); + }); +}); + +describe("extractRadioPropagation", () => { + it("strips tfarSettings from the event feed and stamps the manifest values", () => { + const extracted = extractRadioPropagation([ + { type: "killed", payload: { unitId: 1 } }, + { + type: "tfarSettings", + payload: { terrainInterceptionCoefficient: 4, globalRadioRangeCoef: 2, source: "cba" }, + }, + { type: "radioSnapshot", payload: { unitId: 1 } }, + ]); + expect(extracted.events.map((event) => event.type)).toEqual(["killed", "radioSnapshot"]); + expect(extracted.radioPropagation).toMatchObject({ + terrainInterceptionCoefficient: 4, + globalRadioRangeCoef: 2, + source: "cba", + }); + expect(extracted.acrePropagation).toBeUndefined(); + }); + + it("strips acreSettings separately so ACRE does not inherit TFAR coefficients", () => { + const extracted = extractRadioPropagation([ + { + type: "tfarSettings", + payload: { terrainInterceptionCoefficient: 12, globalRadioRangeCoef: 0.5, source: "cba" }, + }, + { + type: "acreSettings", + payload: { terrainLoss: 0.4, signalModel: 1, acreLoaded: true, source: "cba" }, + }, + { type: "radioSnapshot", payload: { unitId: 1 } }, + ]); + expect(extracted.events.map((event) => event.type)).toEqual(["radioSnapshot"]); + expect(extracted.radioPropagation).toMatchObject({ + terrainInterceptionCoefficient: 12, + globalRadioRangeCoef: 0.5, + }); + expect(extracted.acrePropagation).toMatchObject({ + terrainLoss: 0.4, + signalModel: 1, + acreLoaded: true, + source: "cba", + }); + }); +}); + +describe("resolveAcrePropagation", () => { + it("bakes the ACRE repo defaults", () => { + const resolved = resolveAcrePropagation(); + expect(resolved.terrainLoss).toBe(ACRE_DEFAULT_TERRAIN_LOSS); + expect(resolved.signalModel).toBe(ACRE_DEFAULT_SIGNAL_MODEL); + expect(resolved.source).toBe("default"); + }); + + it("keeps live CBA values from a recording", () => { + const resolved = resolveAcrePropagation({ + terrainLoss: 0.4, + signalModel: 1, + ignoreAntennaDirection: true, + acreLoaded: true, + source: "cba", + }); + expect(resolved.terrainLoss).toBe(0.4); + expect(resolved.signalModel).toBe(1); + expect(resolved.ignoreAntennaDirection).toBe(true); + expect(resolved.acreLoaded).toBe(true); + expect(resolved.source).toBe("cba"); + }); +}); + +describe("parseAcreSettingsPayload", () => { + it("parses a JSON string the same way as an object", () => { + const fromObject = parseAcreSettingsPayload({ + terrainLoss: 0.25, + signalModel: 0, + }); + const fromString = parseAcreSettingsPayload('{"terrainLoss":0.25,"signalModel":0}'); + expect(fromObject).toEqual(fromString); + }); +}); diff --git a/ui/src/data/__tests__/snapshotDiff.test.ts b/ui/src/data/__tests__/snapshotDiff.test.ts new file mode 100644 index 000000000..92ea9ca0d --- /dev/null +++ b/ui/src/data/__tests__/snapshotDiff.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { applySnapshotDiff, isSnapshotDiff } from "../snapshotDiff"; +import type { MedicalSnapshot, PlayerSnapshotDiff } from "../types"; + +const base = (): MedicalSnapshot => ({ + unitId: 3, + playerUid: "76561198000000000", + reason: "periodic", + vanilla: { damage: 0, lifeState: "HEALTHY" }, + bodyParts: [{ part: "head", damage: 0, items: [] }], + ace: { bloodVolume: 6, heartRate: 80, pain: 0 }, +}); + +describe("isSnapshotDiff", () => { + it("recognises a diff by its base frame", () => { + expect(isSnapshotDiff({ unitId: 3, diffOf: 40, set: {} })).toBe(true); + }); + + it("treats a full snapshot as a full snapshot", () => { + expect(isSnapshotDiff(base())).toBe(false); + }); +}); + +describe("applySnapshotDiff", () => { + it("merges nested changes without touching untouched keys", () => { + const diff: PlayerSnapshotDiff = { + unitId: 3, + diffOf: 40, + set: { ace: { heartRate: 120 } }, + }; + + const result = applySnapshotDiff(base(), diff) as MedicalSnapshot; + + expect(result.ace).toEqual({ bloodVolume: 6, heartRate: 120, pain: 0 }); + expect(result.vanilla).toEqual({ damage: 0, lifeState: "HEALTHY" }); + expect(result.playerUid).toBe("76561198000000000"); + }); + + it("replaces arrays wholesale instead of merging them", () => { + const diff: PlayerSnapshotDiff = { + unitId: 3, + diffOf: 40, + set: { + bodyParts: [ + { part: "head", damage: 0, items: [] }, + { part: "body", damage: 0.4, items: [{ kind: "bandage", name: "Bandaged wound", count: 2 }] }, + ], + }, + }; + + const result = applySnapshotDiff(base(), diff) as MedicalSnapshot; + + expect(result.bodyParts).toHaveLength(2); + expect(result.bodyParts?.[1].items[0].name).toBe("Bandaged wound"); + }); + + it("removes keys listed in unset, including nested paths", () => { + const diff: PlayerSnapshotDiff = { + unitId: 3, + diffOf: 40, + set: {}, + unset: ["ace.pain", "reason"], + }; + + const result = applySnapshotDiff(base(), diff) as MedicalSnapshot; + + expect(result.ace).toEqual({ bloodVolume: 6, heartRate: 80 }); + expect("reason" in result).toBe(false); + }); + + it("never mutates the snapshot it builds on", () => { + const previous = base(); + const diff: PlayerSnapshotDiff = { + unitId: 3, + diffOf: 40, + set: { ace: { pain: 0.8 } }, + unset: ["vanilla.lifeState"], + }; + + applySnapshotDiff(previous, diff); + + expect(previous.ace).toEqual({ bloodVolume: 6, heartRate: 80, pain: 0 }); + expect(previous.vanilla.lifeState).toBe("HEALTHY"); + }); + + it("ignores unset paths that do not exist", () => { + const result = applySnapshotDiff(base(), { + unitId: 3, + diffOf: 40, + set: {}, + unset: ["kat.spo2", "ace.missing"], + }) as MedicalSnapshot; + + expect(result.ace).toEqual({ bloodVolume: 6, heartRate: 80, pain: 0 }); + }); +}); diff --git a/ui/src/data/decoders/__tests__/jsonDecoder.test.ts b/ui/src/data/decoders/__tests__/jsonDecoder.test.ts index 14af37a27..df14ae79b 100644 --- a/ui/src/data/decoders/__tests__/jsonDecoder.test.ts +++ b/ui/src/data/decoders/__tests__/jsonDecoder.test.ts @@ -10,6 +10,105 @@ function toBuffer(obj: unknown): ArrayBuffer { describe("JsonDecoder.decodeManifest", () => { const decoder = new JsonDecoder(); + it("decodes additive player snapshot events", () => { + const manifest = decoder.decodeManifest(toBuffer({ + worldName: "Altis", + missionName: "Snapshots", + endFrame: 100, + captureDelay: 1, + events: [[10, "inventorySnapshot", { unitId: 7, weightKg: 31.5, weapons: [] }]], + })); + + expect(manifest.events).toEqual([{ + frameNum: 10, + type: "inventorySnapshot", + payload: { unitId: 7, weightKg: 31.5, weapons: [] }, + }]); + }); + + it("passes diff-encoded snapshots through untouched", () => { + const diff = { unitId: 7, diffOf: 10, set: { ace: { heartRate: 133 } }, unset: ["kat.spo2"] }; + const manifest = decoder.decodeManifest(toBuffer({ + worldName: "Altis", + missionName: "Snapshots", + endFrame: 100, + captureDelay: 1, + events: [[40, "medicalSnapshot", diff]], + })); + + expect(manifest.events).toEqual([{ frameNum: 40, type: "medicalSnapshot", payload: diff }]); + }); + + it("skips snapshots that carry no unit id to index them by", () => { + const manifest = decoder.decodeManifest(toBuffer({ + worldName: "Altis", + missionName: "Snapshots", + endFrame: 100, + captureDelay: 1, + events: [ + [10, "radioSnapshot", { radios: [] }], + [20, "radioSnapshot", "not an object"], + ], + })); + + expect(manifest.events).toEqual([]); + }); + + it("stamps tfarSettings onto the manifest and keeps them out of the event feed", () => { + const manifest = decoder.decodeManifest(toBuffer({ + worldName: "Altis", + missionName: "Snapshots", + endFrame: 100, + captureDelay: 1, + events: [ + [1, "tfarSettings", { terrainInterceptionCoefficient: 12, globalRadioRangeCoef: 0.5, tfarLoaded: true, source: "cba" }], + [10, "radioSnapshot", { unitId: 7, radios: [] }], + ], + })); + + expect(manifest.radioPropagation).toMatchObject({ + terrainInterceptionCoefficient: 12, + globalRadioRangeCoef: 0.5, + tfarLoaded: true, + source: "cba", + }); + expect(manifest.events).toEqual([{ + frameNum: 10, + type: "radioSnapshot", + payload: { unitId: 7, radios: [] }, + }]); + }); + + it("stamps acreSettings onto the manifest without mixing them into TFAR settings", () => { + const manifest = decoder.decodeManifest(toBuffer({ + worldName: "Altis", + missionName: "Snapshots", + endFrame: 100, + captureDelay: 1, + events: [ + [1, "tfarSettings", { terrainInterceptionCoefficient: 12, globalRadioRangeCoef: 0.5, tfarLoaded: true, source: "cba" }], + [1, "acreSettings", { terrainLoss: 0.4, signalModel: 1, acreLoaded: true, source: "cba" }], + [10, "radioSnapshot", { unitId: 7, radios: [] }], + ], + })); + + expect(manifest.radioPropagation).toMatchObject({ + terrainInterceptionCoefficient: 12, + globalRadioRangeCoef: 0.5, + }); + expect(manifest.acrePropagation).toMatchObject({ + terrainLoss: 0.4, + signalModel: 1, + acreLoaded: true, + source: "cba", + }); + expect(manifest.events).toEqual([{ + frameNum: 10, + type: "radioSnapshot", + payload: { unitId: 7, radios: [] }, + }]); + }); + it("decodes a minimal operation", () => { const data = { worldName: "Altis", diff --git a/ui/src/data/decoders/__tests__/protobufDecoder.test.ts b/ui/src/data/decoders/__tests__/protobufDecoder.test.ts index 66b90996b..498a0827d 100644 --- a/ui/src/data/decoders/__tests__/protobufDecoder.test.ts +++ b/ui/src/data/decoders/__tests__/protobufDecoder.test.ts @@ -20,6 +20,165 @@ function encodePb(msg: { encode: (m: T) => { finish: () => Uint8Array }; from describe("ProtobufDecoder.decodeManifest", () => { const decoder = new ProtobufDecoder(); + it("decodes additive player snapshot payloads from the generic event message", () => { + const buffer = encodePb(PbManifest, { + version: 1, + worldName: "Altis", + missionName: "Snapshots", + endFrame: 100, + chunkSize: 300, + captureDelayMs: 1000, + chunkCount: 1, + events: [{ + frameNum: 10, + type: "radioSnapshot", + message: JSON.stringify({ unitId: 7, radios: [{ frequency: 52, rangeMeters: 5000 }] }), + }], + }); + + expect(decoder.decodeManifest(buffer).events).toEqual([{ + frameNum: 10, + type: "radioSnapshot", + payload: { unitId: 7, radios: [{ frequency: 52, rangeMeters: 5000 }] }, + }]); + }); + + it("passes diff-encoded snapshots through untouched", () => { + const diff = { unitId: 7, diffOf: 10, set: { vanilla: { load: 0.61 } } }; + const buffer = encodePb(PbManifest, { + version: 1, + worldName: "Altis", + missionName: "Snapshots", + endFrame: 100, + chunkSize: 300, + captureDelayMs: 1000, + chunkCount: 1, + events: [{ frameNum: 40, type: "staminaSnapshot", message: JSON.stringify(diff) }], + }); + + expect(decoder.decodeManifest(buffer).events).toEqual([{ + frameNum: 40, + type: "staminaSnapshot", + payload: diff, + }]); + }); + + it("skips snapshots that carry no unit id to index them by", () => { + const buffer = encodePb(PbManifest, { + version: 1, + worldName: "Altis", + missionName: "Snapshots", + endFrame: 100, + chunkSize: 300, + captureDelayMs: 1000, + chunkCount: 1, + events: [ + { frameNum: 10, type: "medicalSnapshot", message: JSON.stringify({ vanilla: {} }) }, + { frameNum: 20, type: "medicalSnapshot", message: "not json" }, + ], + }); + + expect(decoder.decodeManifest(buffer).events).toEqual([]); + }); + + it("stamps tfarSettings onto the manifest and keeps them out of the event feed", () => { + const buffer = encodePb(PbManifest, { + version: 1, + worldName: "Altis", + missionName: "Snapshots", + endFrame: 100, + chunkSize: 300, + captureDelayMs: 1000, + chunkCount: 1, + events: [ + { + frameNum: 1, + type: "tfarSettings", + message: JSON.stringify({ + terrainInterceptionCoefficient: 12, + globalRadioRangeCoef: 0.5, + tfarLoaded: true, + source: "cba", + }), + }, + { + frameNum: 10, + type: "radioSnapshot", + message: JSON.stringify({ unitId: 7, radios: [] }), + }, + ], + }); + + const manifest = decoder.decodeManifest(buffer); + expect(manifest.radioPropagation).toMatchObject({ + terrainInterceptionCoefficient: 12, + globalRadioRangeCoef: 0.5, + tfarLoaded: true, + source: "cba", + }); + expect(manifest.events).toEqual([{ + frameNum: 10, + type: "radioSnapshot", + payload: { unitId: 7, radios: [] }, + }]); + }); + + it("stamps acreSettings onto the manifest without mixing them into TFAR settings", () => { + const buffer = encodePb(PbManifest, { + version: 1, + worldName: "Altis", + missionName: "Snapshots", + endFrame: 100, + chunkSize: 300, + captureDelayMs: 1000, + chunkCount: 1, + events: [ + { + frameNum: 1, + type: "tfarSettings", + message: JSON.stringify({ + terrainInterceptionCoefficient: 12, + globalRadioRangeCoef: 0.5, + tfarLoaded: true, + source: "cba", + }), + }, + { + frameNum: 1, + type: "acreSettings", + message: JSON.stringify({ + terrainLoss: 0.4, + signalModel: 1, + acreLoaded: true, + source: "cba", + }), + }, + { + frameNum: 10, + type: "radioSnapshot", + message: JSON.stringify({ unitId: 7, radios: [] }), + }, + ], + }); + + const manifest = decoder.decodeManifest(buffer); + expect(manifest.radioPropagation).toMatchObject({ + terrainInterceptionCoefficient: 12, + globalRadioRangeCoef: 0.5, + }); + expect(manifest.acrePropagation).toMatchObject({ + terrainLoss: 0.4, + signalModel: 1, + acreLoaded: true, + source: "cba", + }); + expect(manifest.events).toEqual([{ + frameNum: 10, + type: "radioSnapshot", + payload: { unitId: 7, radios: [] }, + }]); + }); + it("decodes a minimal manifest", () => { const buffer = encodePb(PbManifest, { version: 2, diff --git a/ui/src/data/decoders/jsonDecoder.ts b/ui/src/data/decoders/jsonDecoder.ts index 3d85b4850..e1cc31620 100644 --- a/ui/src/data/decoders/jsonDecoder.ts +++ b/ui/src/data/decoders/jsonDecoder.ts @@ -11,6 +11,11 @@ import type { Side, } from "../types"; import type { DecoderStrategy } from "./decoder.interface"; +import { + extractRadioPropagation, + parseAcreSettingsPayload, + parseTfarSettingsPayload, +} from "../radioPropagation"; // ───────── Legacy JSON shape ───────── @@ -304,6 +309,26 @@ function convertEvent(raw: RawJsonEvent): EventDef | null { unitName: hackData?.[0] ?? "", }; } + case "inventorySnapshot": + case "medicalSnapshot": + case "staminaSnapshot": + case "radioSnapshot": { + // Either a full snapshot or a diff against an earlier frame; both are keyed by unitId. + const payload = raw[2]; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + if (typeof (payload as { unitId?: unknown }).unitId !== "number") return null; + return { frameNum, type, payload } as EventDef; + } + case "tfarSettings": { + const payload = parseTfarSettingsPayload(raw[2]); + if (!payload) return null; + return { frameNum, type, payload }; + } + case "acreSettings": { + const payload = parseAcreSettingsPayload(raw[2]); + if (!payload) return null; + return { frameNum, type, payload }; + } default: // Unknown event types: skip return null; @@ -369,9 +394,11 @@ export class JsonDecoder implements DecoderStrategy { const data: RawJsonOperation = JSON.parse(text); const entities: EntityDef[] = (data.entities ?? []).map(convertEntity); - const events: EventDef[] = (data.events ?? []) - .map(convertEvent) - .filter((e): e is EventDef => e !== null); + const extracted = extractRadioPropagation( + (data.events ?? []) + .map(convertEvent) + .filter((e): e is EventDef => e !== null), + ); const markers: MarkerDef[] = (data.Markers ?? []) .map(convertMarker) .filter((m): m is MarkerDef => m !== null); @@ -392,11 +419,13 @@ export class JsonDecoder implements DecoderStrategy { captureDelayMs: (data.captureDelay ?? 1) * 1000, chunkCount: 1, entities, - events, + events: extracted.events, markers, times, extensionVersion: data.extensionVersion, addonVersion: data.addonVersion, + radioPropagation: extracted.radioPropagation, + acrePropagation: extracted.acrePropagation, }; } diff --git a/ui/src/data/decoders/protobufDecoder.ts b/ui/src/data/decoders/protobufDecoder.ts index c6d4bb7fd..02a5bac92 100644 --- a/ui/src/data/decoders/protobufDecoder.ts +++ b/ui/src/data/decoders/protobufDecoder.ts @@ -12,6 +12,11 @@ import { type Side, } from "../types"; import type { DecoderStrategy } from "./decoder.interface"; +import { + extractRadioPropagation, + parseAcreSettingsPayload, + parseTfarSettingsPayload, +} from "../radioPropagation"; import { Manifest as PbManifest, Chunk as PbChunk, @@ -149,6 +154,38 @@ function convertEvent(pb: PbEvent): EventDef | null { }; case "generalEvent": return { frameNum, type, message: pb.message ?? "" }; + case "inventorySnapshot": + case "medicalSnapshot": + case "staminaSnapshot": + case "radioSnapshot": { + try { + // Either a full snapshot or a diff against an earlier frame; both are keyed by unitId. + const payload = JSON.parse(pb.message); + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + if (typeof payload.unitId !== "number") return null; + return { frameNum, type, payload } as EventDef; + } catch { + return null; + } + } + case "tfarSettings": { + try { + const payload = parseTfarSettingsPayload(JSON.parse(pb.message)); + if (!payload) return null; + return { frameNum, type, payload }; + } catch { + return null; + } + } + case "acreSettings": { + try { + const payload = parseAcreSettingsPayload(JSON.parse(pb.message)); + if (!payload) return null; + return { frameNum, type, payload }; + } catch { + return null; + } + } case "capturedFlag": return { frameNum, @@ -232,6 +269,9 @@ function convertMarkerDef(pb: PbMarkerDef): AppMarkerDef { export class ProtobufDecoder implements DecoderStrategy { decodeManifest(buffer: ArrayBuffer): AppManifest { const pb = PbManifest.decode(new Uint8Array(buffer)); + const extracted = extractRadioPropagation( + pb.events.map(convertEvent).filter((e): e is EventDef => e !== null), + ); return { version: pb.version, @@ -242,7 +282,7 @@ export class ProtobufDecoder implements DecoderStrategy { captureDelayMs: pb.captureDelayMs, chunkCount: pb.chunkCount, entities: pb.entities.map(convertEntityDef), - events: pb.events.map(convertEvent).filter((e): e is EventDef => e !== null), + events: extracted.events, markers: pb.markers.map(convertMarkerDef), times: pb.times.map((t) => ({ frameNum: t.frameNum, @@ -252,6 +292,8 @@ export class ProtobufDecoder implements DecoderStrategy { })), extensionVersion: pb.extensionVersion || undefined, addonVersion: pb.addonVersion || undefined, + radioPropagation: extracted.radioPropagation, + acrePropagation: extracted.acrePropagation, }; } diff --git a/ui/src/data/radioPropagation.ts b/ui/src/data/radioPropagation.ts new file mode 100644 index 000000000..5ae20ba79 --- /dev/null +++ b/ui/src/data/radioPropagation.ts @@ -0,0 +1,158 @@ +/** + * TFAR values that change how far a radio actually reaches. Baked defaults + * match task-force-arma-3-radio (`TF_terrain_interception_coefficient` = 7, + * `TFAR_globalRadioRangeCoef` = 1). A recording may stamp live CBA values + * as a `tfarSettings` event; missing fields fall back to these. + */ +export interface TfarRadioPropagation { + terrainInterceptionCoefficient: number; + globalRadioRangeCoef: number; + tfarLoaded?: boolean; + source?: "cba" | "default" | string; +} + +/** + * ACRE values that change how far a radio actually reaches. These are not + * TFAR's coefficient: ACRE scales only terrain/diffraction loss + * (`acre_sys_core_terrainLoss`, 0–1, default 1) and picks a signal model + * (`acre_sys_signal_signalModel`, default 2 = LOS Multipath). Arcade (0) + * ignores terrain entirely. A recording may stamp live CBA values as an + * `acreSettings` event; missing fields fall back to these. + */ +export interface AcreRadioPropagation { + terrainLoss: number; + signalModel: number; + ignoreAntennaDirection?: boolean; + acreLoaded?: boolean; + source?: "cba" | "default" | string; +} + +export const TFAR_DEFAULT_TERRAIN_COEFFICIENT = 7; +export const TFAR_DEFAULT_GLOBAL_RANGE_COEF = 1; +/** Head / "pilot" selection height TFAR uses instead of feet. */ +export const TFAR_EYE_HEIGHT_METERS = 1.6; +export const TFAR_T_MIN_METERS = 10; +export const TFAR_T_MAX_METERS = 250; +export const TFAR_T_SEARCH_EPSILON = 10; +export const TFAR_DISTANCE_SCALE_METERS = 2000; + +export const ACRE_DEFAULT_TERRAIN_LOSS = 1; +/** `SIGNAL_MODEL_LOS_MULTIPATH` — ACRE's CBA default. */ +export const ACRE_DEFAULT_SIGNAL_MODEL = 2; +export const ACRE_SIGNAL_MODEL_ARCADE = 0; +export const ACRE_SIGNAL_MODEL_LOS_SIMPLE = 1; +export const ACRE_SIGNAL_MODEL_LOS_MULTIPATH = 2; +export const ACRE_SIGNAL_MODEL_ITM = 3; +/** Used when a radio snapshot has no TX frequency. Typical ACRE VHF. */ +export const ACRE_DEFAULT_FREQUENCY_MHZ = 60; +/** ACRE's terrain-profile step in `los_simple::diffraction_loss`. */ +export const ACRE_PROFILE_STEP_METERS = 7.5; + +export const TFAR_DEFAULT_PROPAGATION: TfarRadioPropagation = { + terrainInterceptionCoefficient: TFAR_DEFAULT_TERRAIN_COEFFICIENT, + globalRadioRangeCoef: TFAR_DEFAULT_GLOBAL_RANGE_COEF, + source: "default", +}; + +export const ACRE_DEFAULT_PROPAGATION: AcreRadioPropagation = { + terrainLoss: ACRE_DEFAULT_TERRAIN_LOSS, + signalModel: ACRE_DEFAULT_SIGNAL_MODEL, + ignoreAntennaDirection: false, + source: "default", +}; + +export function resolveTfarPropagation( + partial?: Partial | null, +): TfarRadioPropagation { + const coef = partial?.terrainInterceptionCoefficient; + const rangeCoef = partial?.globalRadioRangeCoef; + return { + terrainInterceptionCoefficient: + typeof coef === "number" && Number.isFinite(coef) + ? coef + : TFAR_DEFAULT_TERRAIN_COEFFICIENT, + globalRadioRangeCoef: + typeof rangeCoef === "number" && Number.isFinite(rangeCoef) && rangeCoef > 0 + ? rangeCoef + : TFAR_DEFAULT_GLOBAL_RANGE_COEF, + tfarLoaded: partial?.tfarLoaded, + source: partial?.source ?? (partial ? "cba" : "default"), + }; +} + +export function resolveAcrePropagation( + partial?: Partial | null, +): AcreRadioPropagation { + const loss = partial?.terrainLoss; + const model = partial?.signalModel; + return { + terrainLoss: + typeof loss === "number" && Number.isFinite(loss) + ? Math.max(0, Math.min(1, loss)) + : ACRE_DEFAULT_TERRAIN_LOSS, + signalModel: + typeof model === "number" && Number.isFinite(model) && model >= 0 && model <= 3 + ? Math.trunc(model) + : ACRE_DEFAULT_SIGNAL_MODEL, + ignoreAntennaDirection: partial?.ignoreAntennaDirection, + acreLoaded: partial?.acreLoaded, + source: partial?.source ?? (partial ? "cba" : "default"), + }; +} + +export function parseTfarSettingsPayload(raw: unknown): TfarRadioPropagation | undefined { + let value = raw; + if (typeof value === "string") { + try { + value = JSON.parse(value) as unknown; + } catch { + return undefined; + } + } + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + return resolveTfarPropagation(value as Partial); +} + +export function parseAcreSettingsPayload(raw: unknown): AcreRadioPropagation | undefined { + let value = raw; + if (typeof value === "string") { + try { + value = JSON.parse(value) as unknown; + } catch { + return undefined; + } + } + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + return resolveAcrePropagation(value as Partial); +} + +/** Arcade (0) ignores hills; terrainLoss 0 does the same for the other models. */ +export function acreAppliesTerrain(propagation: AcreRadioPropagation): boolean { + return propagation.signalModel !== ACRE_SIGNAL_MODEL_ARCADE && propagation.terrainLoss > 0; +} + +export function extractRadioPropagation( + events: T[], +): { + events: T[]; + radioPropagation?: TfarRadioPropagation; + acrePropagation?: AcreRadioPropagation; +} { + let radioPropagation: TfarRadioPropagation | undefined; + let acrePropagation: AcreRadioPropagation | undefined; + const kept: T[] = []; + for (const event of events) { + if (event.type === "tfarSettings") { + const parsed = parseTfarSettingsPayload(event.payload); + if (parsed) radioPropagation = parsed; + continue; + } + if (event.type === "acreSettings") { + const parsed = parseAcreSettingsPayload(event.payload); + if (parsed) acrePropagation = parsed; + continue; + } + kept.push(event); + } + return { events: kept, radioPropagation, acrePropagation }; +} diff --git a/ui/src/data/snapshotDiff.ts b/ui/src/data/snapshotDiff.ts new file mode 100644 index 000000000..47f7d2291 --- /dev/null +++ b/ui/src/data/snapshotDiff.ts @@ -0,0 +1,60 @@ +import type { PlayerSnapshotDiff, PlayerSnapshotPayload, RawPlayerSnapshot } from "./types"; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * A follow-up snapshot carries only what changed since the frame named by `diffOf`, + * which is what keeps recordings small when a player's gear or radios sit unchanged + * for the whole mission. + */ +export function isSnapshotDiff(payload: RawPlayerSnapshot): payload is PlayerSnapshotDiff { + return typeof (payload as PlayerSnapshotDiff).diffOf === "number"; +} + +/** Deep merge, replacing arrays wholesale. Never mutates `target` or `patch`. */ +function merged( + target: Record, + patch: Record, +): Record { + const result = { ...target }; + for (const [key, value] of Object.entries(patch)) { + const existing = result[key]; + result[key] = + isPlainObject(value) && isPlainObject(existing) ? merged(existing, value) : value; + } + return result; +} + +/** Remove a dot-separated path, cloning every object on the way down. */ +function withoutPath( + target: Record, + path: string, +): Record { + const [key, ...rest] = path.split("."); + if (!(key in target)) return target; + if (rest.length === 0) { + const result = { ...target }; + delete result[key]; + return result; + } + const child = target[key]; + if (!isPlainObject(child)) return target; + return { ...target, [key]: withoutPath(child, rest.join(".")) }; +} + +/** Rebuild a full snapshot by applying a diff on top of the previous full snapshot. */ +export function applySnapshotDiff( + base: PlayerSnapshotPayload, + diff: PlayerSnapshotDiff, +): PlayerSnapshotPayload { + let result = merged( + base as unknown as Record, + diff.set ?? {}, + ); + for (const path of diff.unset ?? []) { + result = withoutPath(result, path); + } + return result as unknown as PlayerSnapshotPayload; +} diff --git a/ui/src/data/types.ts b/ui/src/data/types.ts index f7ecacd63..a8fa74a09 100644 --- a/ui/src/data/types.ts +++ b/ui/src/data/types.ts @@ -1,4 +1,5 @@ import type { ArmaCoord } from "../utils/coordinates"; +import type { AcreRadioPropagation, TfarRadioPropagation } from "./radioPropagation"; /** Faction side. */ export type Side = "WEST" | "EAST" | "GUER" | "CIV"; @@ -101,6 +102,143 @@ export interface TerminalHackEventDef { unitName: string; } +export type PlayerSnapshotType = + | "inventorySnapshot" + | "medicalSnapshot" + | "staminaSnapshot" + | "radioSnapshot"; + +export interface GearItem { + class: string; + name: string; + picture?: string; + count?: number; +} + +export interface GearContainer extends GearItem { + items: GearItem[]; +} + +export interface InventorySnapshot { + unitId: number; + playerUid?: string; + reason?: "periodic" | "death" | string; + /** Raw Arma mass units. */ + massUnits?: number; + load: number; + uniform: GearContainer; + vest: GearContainer; + backpack: GearContainer; + headgear: GearItem; + goggles: GearItem; + weapons: Array; + magazines: Array; + assignedItems: GearItem[]; +} + +/** + * Anything a medical mod attaches to, inserts into or injects at a body part. + * Deliberately untyped beyond a label so a new treatment recorded by the addon + * shows up without a UI change. + */ +export interface TreatmentItem { + kind: string; + name: string; + count?: number; + detail?: string; +} + +export interface MedicalBodyPart { + part: string; + damage?: number; + items: TreatmentItem[]; +} + +export interface MedicalLogEntry { + time: string; + text: string; +} + +export interface MedicalSnapshot { + unitId: number; + playerUid?: string; + reason?: "periodic" | "death" | string; + vanilla: Record; + /** In ACE body part order: head, body, leftarm, rightarm, leftleg, rightleg. */ + bodyParts?: MedicalBodyPart[]; + /** Treatments that are not tied to a body part, such as medications in the system. */ + treatments?: TreatmentItem[]; + /** ACE activity log, already localized the way the medical menu prints it. */ + activity?: MedicalLogEntry[]; + /** ACE quick view (pulse, BP, response checks). */ + quickView?: MedicalLogEntry[]; + ace?: Record; + kat?: Record; +} + +export interface StaminaSnapshot { + unitId: number; + playerUid?: string; + reason?: "periodic" | "death" | string; + vanilla: Record; + ace?: Record; +} + +export interface RadioSnapshotEntry extends GearItem { + mod: string; + type: "SW" | "LR" | "SR" | string; + channel: number; + frequency: number; + code: string; + /** Read from the radio's own config where the mod publishes one. */ + rangeMeters: number; + additional: boolean; + /** ACRE models propagation instead of a range, so it reports transmit power. */ + powerMilliwatts?: number; + active?: boolean; +} + +export interface RadioSnapshot { + unitId: number; + playerUid?: string; + reason?: "periodic" | "death" | string; + radios: RadioSnapshotEntry[]; +} + +export type PlayerSnapshotPayload = + | InventorySnapshot + | MedicalSnapshot + | StaminaSnapshot + | RadioSnapshot; + +/** + * Follow-up snapshots record only what changed since the same player's previous + * snapshot of that kind, identified by its frame number. + */ +export interface PlayerSnapshotDiff { + unitId: number; + diffOf: number; + set: Record; + unset?: string[]; +} + +export type RawPlayerSnapshot = PlayerSnapshotPayload | PlayerSnapshotDiff; + +export interface PlayerSnapshotEventDef { + type: PlayerSnapshotType; + payload: RawPlayerSnapshot; +} + +export interface TfarSettingsEventDef { + type: "tfarSettings"; + payload: TfarRadioPropagation; +} + +export interface AcreSettingsEventDef { + type: "acreSettings"; + payload: AcreRadioPropagation; +} + export type EventDef = { frameNum: number } & ( | HitKilledEventDef | ConnectEventDef @@ -109,6 +247,9 @@ export type EventDef = { frameNum: number } & ( | GeneralEventDef | CapturedEventDef | TerminalHackEventDef + | PlayerSnapshotEventDef + | TfarSettingsEventDef + | AcreSettingsEventDef ); // --------------- Markers --------------- @@ -117,7 +258,7 @@ export type EventDef = { frameNum: number } & ( export const FRAME_FOREVER = -1; export interface MarkerDef { - shape: "ICON" | "ELLIPSE" | "RECTANGLE" | "POLYLINE"; + shape: "ICON" | "ELLIPSE" | "RECTANGLE" | "POLYLINE" | "POLYGON"; type: string; text?: string; side: string; @@ -148,6 +289,10 @@ export interface Manifest { times: Array<{ frameNum: number; systemTimeUtc: string; date?: string; timeMultiplier?: number }>; extensionVersion?: string; addonVersion?: string; + /** TFAR coverage settings stamped by the recorder, or omitted on older files. */ + radioPropagation?: TfarRadioPropagation; + /** ACRE coverage settings stamped by the recorder, or omitted on older files. */ + acrePropagation?: AcreRadioPropagation; } /** A decoded chunk: entity ID -> array of states for this chunk's frames. */ @@ -194,6 +339,8 @@ export interface WorldConfig { hasTopoDark?: boolean; hasTopoRelief?: boolean; hasColorRelief?: boolean; + hasHeightmap?: boolean; + hasDem?: boolean; attribution?: string; } diff --git a/ui/src/i18n/__tests__/i18n.test.ts b/ui/src/i18n/__tests__/i18n.test.ts index 41fc1d295..c172cbcaf 100644 --- a/ui/src/i18n/__tests__/i18n.test.ts +++ b/ui/src/i18n/__tests__/i18n.test.ts @@ -123,6 +123,17 @@ describe("createI18n", () => { }); }); + it("t() interpolates named placeholders", () => { + createRoot((dispose) => { + const i18n = createI18n("en"); + expect(i18n.t("profile_title", { name: "Rifleman" })).toBe("Rifleman profile"); + expect(i18n.t("profile_first_at", { label: "gear snapshot", frame: 50 })).toBe( + "First gear snapshot is at frame 50. Scrub forward to see it.", + ); + dispose(); + }); + }); + it("t() works with hyphenated keys", () => { createRoot((dispose) => { const i18n = createI18n("en"); diff --git a/ui/src/i18n/i18n.ts b/ui/src/i18n/i18n.ts index 599f63cf1..ec471581b 100644 --- a/ui/src/i18n/i18n.ts +++ b/ui/src/i18n/i18n.ts @@ -30,10 +30,12 @@ export function detectLocale(): Locale { return "en"; } +export type TranslationVars = Record; + export interface I18n { locale: Accessor; setLocale: (locale: Locale) => void; - t: (key: string) => string; + t: (key: string, vars?: TranslationVars) => string; } /** @@ -52,12 +54,15 @@ export function createI18n(initialLocale?: Locale): I18n { } } - function t(key: string): string { + function t(key: string, vars?: TranslationVars): string { const entry = translations[key]; - if (!entry) { - return key; + let text = entry ? (entry[locale()] ?? entry["en"] ?? key) : key; + if (vars) { + text = text.replace(/\{(\w+)\}/g, (_, name: string) => + Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : `{${name}}`, + ); } - return entry[locale()] ?? entry["en"] ?? key; + return text; } return { locale, setLocale, t }; diff --git a/ui/src/i18n/locales.ts b/ui/src/i18n/locales.ts index af238a690..034f1b329 100644 --- a/ui/src/i18n/locales.ts +++ b/ui/src/i18n/locales.ts @@ -2558,4 +2558,125 @@ export const translations: Record> = { admin_top_bar_subtitle: { en: "OCAP2-WEB", ru: "OCAP2-WEB", de: "OCAP2-WEB", cs: "OCAP2-WEB", it: "OCAP2-WEB", fr: "OCAP2-WEB", fi: "OCAP2-WEB", uk: "OCAP2-WEB" }, admin_icon_button_tooltip: { en: "Admin", ru: "Админ-панель", de: "Admin", cs: "Admin", it: "Admin", fr: "Admin", fi: "Admin", uk: "Адмін" }, + + profile_open: { en: "Open player profile", ru: "Открыть профиль игрока", de: "Spielerprofil öffnen", cs: "Otevřít profil hráče", it: "Apri profilo giocatore", fr: "Ouvrir le profil", fi: "Avaa pelaajaprofiili", uk: "Відкрити профіль гравця" }, + profile_open_named: { en: "Open profile for {name}", ru: "Открыть профиль: {name}", de: "Profil öffnen: {name}", cs: "Otevřít profil: {name}", it: "Apri profilo di {name}", fr: "Ouvrir le profil de {name}", fi: "Avaa profiili: {name}", uk: "Відкрити профіль: {name}" }, + profile_aria: { en: "Unit {id} player profile", ru: "Профиль юнита {id}", de: "Profil von Einheit {id}", cs: "Profil jednotky {id}", it: "Profilo unità {id}", fr: "Profil de l'unité {id}", fi: "Yksikön {id} profiili", uk: "Профіль юніта {id}" }, + profile_aria_named: { en: "{name} player profile", ru: "Профиль игрока {name}", de: "Spielerprofil {name}", cs: "Profil hráče {name}", it: "Profilo di {name}", fr: "Profil de {name}", fi: "{name} pelaajaprofiili", uk: "Профіль гравця {name}" }, + profile_title: { en: "{name} profile", ru: "Профиль {name}", de: "Profil {name}", cs: "Profil {name}", it: "Profilo {name}", fr: "Profil {name}", fi: "{name} profiili", uk: "Профіль {name}" }, + profile_title_unit: { en: "Unit {id} profile", ru: "Профиль юнита {id}", de: "Profil Einheit {id}", cs: "Profil jednotky {id}", it: "Profilo unità {id}", fr: "Profil unité {id}", fi: "Yksikön {id} profiili", uk: "Профіль юніта {id}" }, + profile_close: { en: "Close player profile", ru: "Закрыть профиль", de: "Spielerprofil schließen", cs: "Zavřít profil hráče", it: "Chiudi profilo", fr: "Fermer le profil", fi: "Sulje pelaajaprofiili", uk: "Закрити профіль" }, + profile_role_player: { en: "Player", ru: "Игрок", de: "Spieler", cs: "Hráč", it: "Giocatore", fr: "Joueur", fi: "Pelaaja", uk: "Гравець" }, + profile_follow: { en: "Follow", ru: "Следить", de: "Folgen", cs: "Sledovat", it: "Segui", fr: "Suivre", fi: "Seuraa", uk: "Стежити" }, + profile_following: { en: "Following", ru: "Слежение", de: "Folgt", cs: "Sledováno", it: "Seguendo", fr: "Suivi", fi: "Seurataan", uk: "Стежимо" }, + profile_admin_actions: { en: "Admin actions", ru: "Действия админа", de: "Admin-Aktionen", cs: "Akce správce", it: "Azioni admin", fr: "Actions admin", fi: "Admin-toiminnot", uk: "Дії адміна" }, + profile_blacklist_toggle: { en: "Toggle marker blacklist", ru: "Чёрный список маркеров", de: "Markerliste umschalten", cs: "Přepnout blacklist značek", it: "Attiva/disattiva blacklist marker", fr: "Basculer la liste noire des marqueurs", fi: "Vaihda merkkien estolista", uk: "Перемкнути чорний список маркерів" }, + profile_blacklist: { en: "Blacklist {count} markers", ru: "Скрыть маркеры ({count})", de: "{count} Marker ausblenden", cs: "Skrýt {count} značek", it: "Nascondi {count} marker", fr: "Masquer {count} marqueurs", fi: "Piilota {count} merkkiä", uk: "Приховати {count} маркерів" }, + profile_restore_markers: { en: "Restore {count} markers", ru: "Показать маркеры ({count})", de: "{count} Marker wiederherstellen", cs: "Obnovit {count} značek", it: "Ripristina {count} marker", fr: "Restaurer {count} marqueurs", fi: "Palauta {count} merkkiä", uk: "Відновити {count} маркерів" }, + profile_kills: { en: "Kills", ru: "Убийства", de: "Abschüsse", cs: "Zabití", it: "Uccisioni", fr: "Éliminations", fi: "Tapot", uk: "Вбивства" }, + profile_deaths: { en: "Deaths", ru: "Смерти", de: "Tode", cs: "Úmrtí", it: "Morti", fr: "Morts", fi: "Kuolemat", uk: "Смерті" }, + profile_markers: { en: "Markers", ru: "Маркеры", de: "Marker", cs: "Značky", it: "Marker", fr: "Marqueurs", fi: "Merkit", uk: "Маркери" }, + profile_weight: { en: "Weight kg", ru: "Вес кг", de: "Gewicht kg", cs: "Hmotnost kg", it: "Peso kg", fr: "Poids kg", fi: "Paino kg", uk: "Вага кг" }, + profile_stamina: { en: "Stamina", ru: "Выносливость", de: "Ausdauer", cs: "Výdrž", it: "Stamina", fr: "Endurance", fi: "Kestävyys", uk: "Витривалість" }, + profile_load: { en: "Load", ru: "Нагрузка", de: "Last", cs: "Zátěž", it: "Carico", fr: "Charge", fi: "Kuorma", uk: "Навантаження" }, + profile_tab_gear: { en: "Gear", ru: "Снаряжение", de: "Ausrüstung", cs: "Výstroj", it: "Equipaggiamento", fr: "Équipement", fi: "Varusteet", uk: "Спорядження" }, + profile_tab_medical: { en: "Medical", ru: "Медицина", de: "Medizin", cs: "Medicína", it: "Medico", fr: "Médical", fi: "Lääkintä", uk: "Медицина" }, + profile_tab_stamina: { en: "Stamina", ru: "Выносливость", de: "Ausdauer", cs: "Výdrž", it: "Stamina", fr: "Endurance", fi: "Kestävyys", uk: "Витривалість" }, + profile_tab_radios: { en: "Radios", ru: "Рации", de: "Funkgeräte", cs: "Vysílačky", it: "Radio", fr: "Radios", fi: "Radiot", uk: "Рації" }, + profile_frame: { en: "Frame {frame}", ru: "Кадр {frame}", de: "Frame {frame}", cs: "Snímek {frame}", it: "Frame {frame}", fr: "Image {frame}", fi: "Ruutu {frame}", uk: "Кадр {frame}" }, + profile_reason_periodic: { en: "periodic", ru: "периодический", de: "periodisch", cs: "periodický", it: "periodico", fr: "périodique", fi: "ajastettu", uk: "періодичний" }, + profile_reason_change: { en: "change", ru: "изменение", de: "Änderung", cs: "změna", it: "cambio", fr: "changement", fi: "muutos", uk: "зміна" }, + profile_reason_death: { en: "death", ru: "смерть", de: "Tod", cs: "smrt", it: "morte", fr: "mort", fi: "kuolema", uk: "смерть" }, + profile_empty: { en: "Empty", ru: "Пусто", de: "Leer", cs: "Prázdné", it: "Vuoto", fr: "Vide", fi: "Tyhjä", uk: "Порожньо" }, + profile_none: { en: "None", ru: "Нет", de: "Keine", cs: "Žádné", it: "Nessuno", fr: "Aucun", fi: "Ei ole", uk: "Немає" }, + profile_yes: { en: "Yes", ru: "Да", de: "Ja", cs: "Ano", it: "Sì", fr: "Oui", fi: "Kyllä", uk: "Так" }, + profile_no: { en: "No", ru: "Нет", de: "Nein", cs: "Ne", it: "No", fr: "Non", fi: "Ei", uk: "Ні" }, + profile_missing: { en: "No {label} was recorded for this player.", ru: "Для этого игрока нет записи «{label}».", de: "Für diesen Spieler wurde kein {label} aufgezeichnet.", cs: "Pro tohoto hráče nebyl zaznamenán {label}.", it: "Nessun {label} registrato per questo giocatore.", fr: "Aucun {label} n'a été enregistré pour ce joueur.", fi: "Tälle pelaajalle ei tallennettu kohdetta {label}.", uk: "Для цього гравця немає запису «{label}»." }, + profile_first_at: { en: "First {label} is at frame {frame}. Scrub forward to see it.", ru: "Первый {label} на кадре {frame}. Перемотайте вперёд.", de: "Erstes {label} in Frame {frame}. Spule vor.", cs: "První {label} je na snímku {frame}. Posuňte přehrávání dopředu.", it: "Il primo {label} è al frame {frame}. Avanti nella timeline.", fr: "Premier {label} à l'image {frame}. Avancez la tête de lecture.", fi: "Ensimmäinen {label} on ruudussa {frame}. Siirry eteenpäin.", uk: "Перший {label} на кадрі {frame}. Перемотайте вперед." }, + profile_label_gear: { en: "gear snapshot", ru: "снимок снаряжения", de: "Ausrüstungs-Snapshot", cs: "snímek výstroje", it: "snapshot equipaggiamento", fr: "instantané d'équipement", fi: "varustetallenne", uk: "знімок спорядження" }, + profile_label_medical: { en: "medical snapshot", ru: "медицинский снимок", de: "Medizin-Snapshot", cs: "lékařský snímek", it: "snapshot medico", fr: "instantané médical", fi: "lääkintätallenne", uk: "медичний знімок" }, + profile_label_stamina: { en: "stamina snapshot", ru: "снимок выносливости", de: "Ausdauer-Snapshot", cs: "snímek výdrže", it: "snapshot stamina", fr: "instantané d'endurance", fi: "kestävyystallenne", uk: "знімок витривалості" }, + profile_label_radio: { en: "radio snapshot", ru: "снимок рации", de: "Funk-Snapshot", cs: "snímek vysílačky", it: "snapshot radio", fr: "instantané radio", fi: "radiotallenne", uk: "знімок рації" }, + profile_no_radio: { en: "No radio was carried at this point in the recording.", ru: "В этот момент рации не было.", de: "Zu diesem Zeitpunkt wurde kein Funkgerät getragen.", cs: "V tomto okamžiku záznamu nebyla nesena vysílačka.", it: "In questo punto della registrazione non c'era alcuna radio.", fr: "Aucune radio n'était portée à ce moment.", fi: "Tässä kohdassa tallennetta ei kannettu radiota.", uk: "У цей момент рації не було." }, + profile_head: { en: "Head", ru: "Голова", de: "Kopf", cs: "Hlava", it: "Testa", fr: "Tête", fi: "Pää", uk: "Голова" }, + profile_face: { en: "Face", ru: "Лицо", de: "Gesicht", cs: "Obličej", it: "Viso", fr: "Visage", fi: "Kasvot", uk: "Обличчя" }, + profile_weapons: { en: "Weapons", ru: "Оружие", de: "Waffen", cs: "Zbraně", it: "Armi", fr: "Armes", fi: "Aseet", uk: "Зброя" }, + profile_magazines: { en: "Magazines", ru: "Магазины", de: "Magazine", cs: "Zásobníky", it: "Caricatori", fr: "Chargeurs", fi: "Lippaat", uk: "Магазини" }, + profile_uniform: { en: "Uniform", ru: "Форма", de: "Uniform", cs: "Uniforma", it: "Uniforme", fr: "Uniforme", fi: "Puku", uk: "Уніформа" }, + profile_vest: { en: "Vest", ru: "Жилет", de: "Weste", cs: "Vesta", it: "Giubbotto", fr: "Gilet", fi: "Liivi", uk: "Жилет" }, + profile_backpack: { en: "Backpack", ru: "Рюкзак", de: "Rucksack", cs: "Batoh", it: "Zaino", fr: "Sac à dos", fi: "Reppu", uk: "Рюкзак" }, + profile_assigned: { en: "Assigned items", ru: "Назначенные предметы", de: "Zugewiesene Gegenstände", cs: "Přiřazené předměty", it: "Oggetti assegnati", fr: "Objets assignés", fi: "Määrätyt esineet", uk: "Призначені предмети" }, + profile_damage: { en: "Damage", ru: "Урон", de: "Schaden", cs: "Poškození", it: "Danno", fr: "Dégâts", fi: "Vahinko", uk: "Ушкодження" }, + profile_life_state: { en: "Life state", ru: "Состояние", de: "Lebenszustand", cs: "Stav života", it: "Stato vitale", fr: "État vital", fi: "Elinolotila", uk: "Стан життя" }, + profile_incapacitated: { en: "Incapacitated", ru: "Недееспособен", de: "Kampfunfähig", cs: "Neschopný boje", it: "Incapacitato", fr: "Incapable", fi: "Toimintakyvytön", uk: "Недієздатний" }, + profile_hit_points: { en: "Damaged hit points", ru: "Повреждённые зоны", de: "Beschädigte Trefferpunkte", cs: "Poškozené hitpointy", it: "Hit point danneggiati", fr: "Points de vie endommagés", fi: "Vahingoittuneet osumakohdat", uk: "Пошкоджені зони" }, + profile_uninjured: { en: "Uninjured", ru: "Без повреждений", de: "Unverletzt", cs: "Nezraněn", it: "Indenne", fr: "Indemne", fi: "Vahingoittumaton", uk: "Без ушкоджень" }, + profile_ace_medical: { en: "ACE Medical", ru: "ACE Medical", de: "ACE Medical", cs: "ACE Medical", it: "ACE Medical", fr: "ACE Medical", fi: "ACE Medical", uk: "ACE Medical" }, + profile_blood: { en: "Blood", ru: "Кровь", de: "Blut", cs: "Krev", it: "Sangue", fr: "Sang", fi: "Veri", uk: "Кров" }, + profile_heart_rate: { en: "Heart rate", ru: "Пульс", de: "Herzfrequenz", cs: "Tep", it: "Frequenza cardiaca", fr: "Rythme cardiaque", fi: "Syke", uk: "Пульс" }, + profile_blood_pressure: { en: "Blood pressure", ru: "Давление", de: "Blutdruck", cs: "Krevní tlak", it: "Pressione", fr: "Tension", fi: "Verenpaine", uk: "Тиск" }, + profile_no_blood_pressure: { en: "No pressure", ru: "Нет давления", de: "Kein Druck", cs: "Žádný tlak", it: "Nessuna pressione", fr: "Pas de tension", fi: "Ei painetta", uk: "Немає тиску" }, + profile_spo2: { en: "SpO₂", ru: "SpO₂", de: "SpO₂", cs: "SpO₂", it: "SpO₂", fr: "SpO₂", fi: "SpO₂", uk: "SpO₂" }, + profile_pain: { en: "Pain", ru: "Боль", de: "Schmerz", cs: "Bolest", it: "Dolore", fr: "Douleur", fi: "Kipu", uk: "Біль" }, + profile_bleeding: { en: "Bleeding", ru: "Кровотечение", de: "Blutung", cs: "Krvácení", it: "Sanguinamento", fr: "Saignement", fi: "Verenvuoto", uk: "Кровотеча" }, + profile_hemorrhage: { en: "Hemorrhage", ru: "Кровопотеря", de: "Blutungsklasse", cs: "Hemoragie", it: "Emorragia", fr: "Hémorragie", fi: "Verenhukka", uk: "Крововтрата" }, + profile_unconscious: { en: "Unconscious", ru: "Без сознания", de: "Bewusstlos", cs: "V bezvědomí", it: "Incosciente", fr: "Inconscient", fi: "Tajuton", uk: "Без свідомості" }, + profile_cardiac_arrest: { en: "Cardiac arrest", ru: "Остановка сердца", de: "Herzstillstand", cs: "Srdeční zástava", it: "Arresto cardiaco", fr: "Arrêt cardiaque", fi: "Sydänpysähdys", uk: "Зупинка серця" }, + profile_open_wound_parts: { en: "Open wound parts", ru: "Части с ранами", de: "Offene Wunden", cs: "Části s ranami", it: "Parti con ferite", fr: "Zones blessées", fi: "Avohaava-alueet", uk: "Частини з ранами" }, + profile_hemorrhage_0: { en: "None", ru: "Нет", de: "Keine", cs: "Žádná", it: "Nessuna", fr: "Aucune", fi: "Ei", uk: "Немає" }, + profile_hemorrhage_1: { en: "Class I", ru: "Класс I", de: "Klasse I", cs: "Třída I", it: "Classe I", fr: "Classe I", fi: "Luokka I", uk: "Клас I" }, + profile_hemorrhage_2: { en: "Class II", ru: "Класс II", de: "Klasse II", cs: "Třída II", it: "Classe II", fr: "Classe II", fi: "Luokka II", uk: "Клас II" }, + profile_hemorrhage_3: { en: "Class III", ru: "Класс III", de: "Klasse III", cs: "Třída III", it: "Classe III", fr: "Classe III", fi: "Luokka III", uk: "Клас III" }, + profile_hemorrhage_4: { en: "Class IV", ru: "Класс IV", de: "Klasse IV", cs: "Třída IV", it: "Classe IV", fr: "Classe IV", fi: "Luokka IV", uk: "Клас IV" }, + profile_body_diagram: { en: "Body diagram", ru: "Схема тела", de: "Körperdiagramm", cs: "Schéma těla", it: "Schema del corpo", fr: "Schéma du corps", fi: "Kehokaavio", uk: "Схема тіла" }, + profile_body_parts: { en: "Body parts", ru: "Части тела", de: "Körperteile", cs: "Části těla", it: "Parti del corpo", fr: "Parties du corps", fi: "Vartalonosat", uk: "Частини тіла" }, + profile_part_head: { en: "Head", ru: "Голова", de: "Kopf", cs: "Hlava", it: "Testa", fr: "Tête", fi: "Pää", uk: "Голова" }, + profile_part_body: { en: "Torso", ru: "Торс", de: "Rumpf", cs: "Trup", it: "Torso", fr: "Torse", fi: "Vartalo", uk: "Тулуб" }, + profile_part_leftarm: { en: "L Arm", ru: "Л. рука", de: "L. Arm", cs: "L. ruka", it: "Braccio S", fr: "Bras G", fi: "V. käsi", uk: "Л. рука" }, + profile_part_rightarm: { en: "R Arm", ru: "П. рука", de: "R. Arm", cs: "P. ruka", it: "Braccio D", fr: "Bras D", fi: "O. käsi", uk: "П. рука" }, + profile_part_leftleg: { en: "L Leg", ru: "Л. нога", de: "L. Bein", cs: "L. noha", it: "Gamba S", fr: "Jambe G", fi: "V. jalka", uk: "Л. нога" }, + profile_part_rightleg: { en: "R Leg", ru: "П. нога", de: "R. Bein", cs: "P. noha", it: "Gamba D", fr: "Jambe D", fi: "O. jalka", uk: "П. нога" }, + profile_dmg: { en: "Dmg {value}", ru: "Урон {value}", de: "Schaden {value}", cs: "Dmg {value}", it: "Danno {value}", fr: "Dégâts {value}", fi: "Vahinko {value}", uk: "Ушкодження {value}" }, + profile_untreated: { en: "Untreated", ru: "Без лечения", de: "Unbehandelt", cs: "Neošetřeno", it: "Non trattato", fr: "Non traité", fi: "Hoitamaton", uk: "Без лікування" }, + profile_in_system: { en: "In the system", ru: "В системе", de: "Im System", cs: "V systému", it: "Nel sistema", fr: "Dans le système", fi: "Järjestelmässä", uk: "У системі" }, + profile_kat_medical: { en: "KAT Advanced Medical", ru: "KAT Advanced Medical", de: "KAT Advanced Medical", cs: "KAT Advanced Medical", it: "KAT Advanced Medical", fr: "KAT Advanced Medical", fi: "KAT Advanced Medical", uk: "KAT Advanced Medical" }, + profile_activity_log: { en: "Activity log", ru: "Проведённые манипуляции", de: "Aktivitätsverlauf", cs: "Protokol", it: "Log attività", fr: "Registre d'activité", fi: "Toimintaloki", uk: "Проведені маніпуляції" }, + profile_quick_view: { en: "Quick view", ru: "Быстрый осмотр", de: "Schnellansicht", cs: "Rychlý náhled", it: "Vista rapida", fr: "Vue rapide", fi: "Pikanäkymä", uk: "Швидкий огляд" }, + profile_log_empty: { en: "No entries", ru: "Нет записей", de: "Keine Einträge", cs: "Žádné záznamy", it: "Nessuna voce", fr: "Aucune entrée", fi: "Ei merkintöjä", uk: "Немає записів" }, + profile_etco2: { en: "ETCO₂", ru: "ETCO₂", de: "ETCO₂", cs: "ETCO₂", it: "ETCO₂", fr: "ETCO₂", fi: "ETCO₂", uk: "ETCO₂" }, + profile_breath_rate: { en: "Breath rate", ru: "Частота дыхания", de: "Atemfrequenz", cs: "Dechová frekvence", it: "Frequenza respiratoria", fr: "Fréquence respiratoire", fi: "Hengitystiheys", uk: "Частота дихання" }, + profile_blood_type: { en: "Blood type", ru: "Группа крови", de: "Blutgruppe", cs: "Krevní skupina", it: "Gruppo sanguigno", fr: "Groupe sanguin", fi: "Veriryhmä", uk: "Група крові" }, + profile_airway_secured: { en: "Airway secured", ru: "Дыхательные пути обеспечены", de: "Atemweg gesichert", cs: "Dýchací cesty zajištěny", it: "Vie aeree assicurate", fr: "Voies aériennes sécurisées", fi: "Hengitystie turvattu", uk: "Дихальні шляхи забезпечені" }, + profile_airway_item: { en: "Airway item", ru: "Средство для дыхательных путей", de: "Atemwegshilfe", cs: "Pomůcka dýchacích cest", it: "Dispositivo vie aeree", fr: "Dispositif voies aériennes", fi: "Hengitystieväline", uk: "Засіб для дихальних шляхів" }, + profile_obstruction: { en: "Obstruction", ru: "Обструкция", de: "Verlegung", cs: "Obstrukce", it: "Ostruzione", fr: "Obstruction", fi: "Tukos", uk: "Обструкція" }, + profile_occluded: { en: "Occluded", ru: "Окклюзия", de: "Okkludiert", cs: "Okluze", it: "Occluso", fr: "Occlus", fi: "Tukkeutunut", uk: "Оклюзія" }, + profile_pneumothorax: { en: "Pneumothorax", ru: "Пневмоторакс", de: "Pneumothorax", cs: "Pneumotorax", it: "Pneumotorace", fr: "Pneumothorax", fi: "Ilmarinta", uk: "Пневмоторакс" }, + profile_chest_seal: { en: "Chest seal", ru: "Окклюзионная повязка", de: "Brustverband", cs: "Hrudní krytí", it: "Sigillo toracico", fr: "Pansement thoracique", fi: "Rintasuojus", uk: "Оклюзійна пов'язка" }, + profile_hemopneumothorax: { en: "Hemopneumothorax", ru: "Гемопневмоторакс", de: "Hämatopneumothorax", cs: "Hemopneumotorax", it: "Emopneumotorace", fr: "Hémopneumothorax", fi: "Veri-ilmarinta", uk: "Гемопневмоторакс" }, + profile_tension_ptx: { en: "Tension PTX", ru: "Напряжённый ПТХ", de: "Spannungs-PTX", cs: "Tenzní PNO", it: "PNX iperteso", fr: "PNO sous tension", fi: "Jänniteilmarinta", uk: "Напружений ПТХ" }, + profile_internal_bleed: { en: "Internal bleed", ru: "Внутреннее кровотечение", de: "Innere Blutung", cs: "Vnitřní krvácení", it: "Emorragia interna", fr: "Saignement interne", fi: "Sisäinen verenvuoto", uk: "Внутрішня кровотеча" }, + profile_sprint_reserve: { en: "Sprint reserve", ru: "Запас спринта", de: "Sprintreserve", cs: "Rezerva sprintu", it: "Riserva scatto", fr: "Réserve de sprint", fi: "Sprinttivaranto", uk: "Запас спринту" }, + profile_pool_not_simulated: { en: "Pool not simulated", ru: "Пул не моделируется", de: "Vorrat nicht simuliert", cs: "Zásoba se nesimuluje", it: "Pool non simulato", fr: "Réserve non simulée", fi: "Varantoa ei simuloida", uk: "Пул не моделюється" }, + profile_sprint_detail: { en: "{percent} · {current} s of {max} s", ru: "{percent} · {current} с из {max} с", de: "{percent} · {current} s von {max} s", cs: "{percent} · {current} s z {max} s", it: "{percent} · {current} s su {max} s", fr: "{percent} · {current} s sur {max} s", fi: "{percent} · {current} s / {max} s", uk: "{percent} · {current} с з {max} с" }, + profile_fatigue: { en: "Fatigue", ru: "Усталость", de: "Ermüdung", cs: "Únava", it: "Affaticamento", fr: "Fatigue", fi: "Väsymys", uk: "Втома" }, + profile_carried_weight: { en: "Carried weight", ru: "Переносимый вес", de: "Getragenes Gewicht", cs: "Nesená hmotnost", it: "Peso trasportato", fr: "Poids porté", fi: "Kannettu paino", uk: "Переносна вага" }, + profile_ace_fatigue: { en: "ACE Advanced Fatigue", ru: "ACE Advanced Fatigue", de: "ACE Advanced Fatigue", cs: "ACE Advanced Fatigue", it: "ACE Advanced Fatigue", fr: "ACE Advanced Fatigue", fi: "ACE Advanced Fatigue", uk: "ACE Advanced Fatigue" }, + profile_anaerobic: { en: "Anaerobic reserve", ru: "Анаэробный запас", de: "Anaerobe Reserve", cs: "Anaerobní rezerva", it: "Riserva anaerobica", fr: "Réserve anaérobie", fi: "Anaerobinen varanto", uk: "Анаеробний запас" }, + profile_aerobic: { en: "Aerobic reserve", ru: "Аэробный запас", de: "Aerobe Reserve", cs: "Aerobní rezerva", it: "Riserva aerobica", fr: "Réserve aérobie", fi: "Aerobinen varanto", uk: "Аеробний запас" }, + profile_muscle_damage: { en: "Muscle damage", ru: "Повреждение мышц", de: "Muskelschaden", cs: "Poškození svalů", it: "Danno muscolare", fr: "Lésion musculaire", fi: "Lihasvaurio", uk: "Пошкодження м'язів" }, + profile_performance: { en: "Performance", ru: "Производительность", de: "Leistung", cs: "Výkon", it: "Prestazione", fr: "Performance", fi: "Suorituskyky", uk: "Продуктивність" }, + profile_stamina_source_ace: { en: "ACE anaerobic reserve", ru: "Анаэробный запас ACE", de: "ACE-anaerobe Reserve", cs: "ACE anaerobní rezerva", it: "Riserva anaerobica ACE", fr: "Réserve anaérobie ACE", fi: "ACE-anaerobinen varanto", uk: "Анаеробний запас ACE" }, + profile_stamina_source_vanilla: { en: "Vanilla sprint reserve", ru: "Ванильный запас спринта", de: "Vanilla-Sprintreserve", cs: "Vanilla rezerva sprintu", it: "Riserva scatto vanilla", fr: "Réserve de sprint vanilla", fi: "Vanilja-sprinttivaranto", uk: "Ванільний запас спринту" }, + profile_radio_additional: { en: "additional", ru: "доп.", de: "zusätzlich", cs: "dodatečná", it: "aggiuntiva", fr: "additionnelle", fi: "lisä", uk: "дод." }, + profile_radio_active: { en: "active", ru: "активна", de: "aktiv", cs: "aktivní", it: "attiva", fr: "active", fi: "aktiivinen", uk: "активна" }, + profile_radio_channel: { en: "CH {channel}", ru: "КН {channel}", de: "CH {channel}", cs: "KAN {channel}", it: "CH {channel}", fr: "CAN {channel}", fi: "CH {channel}", uk: "КН {channel}" }, + profile_radio_code: { en: "Code {code}", ru: "Код {code}", de: "Code {code}", cs: "Kód {code}", it: "Codice {code}", fr: "Code {code}", fi: "Koodi {code}", uk: "Код {code}" }, + profile_show_range: { en: "Show range", ru: "Показать радиус", de: "Reichweite zeigen", cs: "Zobrazit dosah", it: "Mostra raggio", fr: "Afficher la portée", fi: "Näytä kantama", uk: "Показати радіус" }, + profile_hide_range: { en: "Hide range", ru: "Скрыть радиус", de: "Reichweite ausblenden", cs: "Skrýt dosah", it: "Nascondi raggio", fr: "Masquer la portée", fi: "Piilota kantama", uk: "Приховати радіус" }, + profile_simple_range: { en: "Simple range", ru: "Простой радиус", de: "Einfache Reichweite", cs: "Prostý dosah", it: "Raggio semplice", fr: "Portée simple", fi: "Yksinkertainen kantama", uk: "Простий радіус" }, + profile_approx_range: { en: "Approximate range", ru: "Приблизительный радиус", de: "Ungefähre Reichweite", cs: "Přibližný dosah", it: "Raggio approssimato", fr: "Portée approximative", fi: "Arvioitu kantama", uk: "Приблизний радіус" }, + profile_approx_unavailable: { en: "Map has no elevation data", ru: "На карте нет данных о высотах", de: "Karte hat keine Höhendaten", cs: "Mapa nemá výšková data", it: "La mappa non ha dati di elevazione", fr: "La carte n'a pas de données d'altitude", fi: "Kartalla ei ole korkeusaineistoa", uk: "Карта не має даних про висоту" }, + profile_approx_hint: { en: "Terrain estimate using TFAR's formula", ru: "Оценка по формуле TFAR с учётом рельефа", de: "Geländeschätzung nach TFAR-Formel", cs: "Odhad terénu podle vzorce TFAR", it: "Stima del terreno con la formula TFAR", fr: "Estimation du terrain selon la formule TFAR", fi: "Maastoarvio TFAR-kaavalla", uk: "Оцінка рельєфу за формулою TFAR" }, + profile_approx_hint_acre: { en: "Terrain estimate using ACRE's loss model", ru: "Оценка по модели потерь ACRE с учётом рельефа", de: "Geländeschätzung nach ACRE-Verlustmodell", cs: "Odhad terénu podle modelu ztrát ACRE", it: "Stima del terreno con il modello di perdita ACRE", fr: "Estimation du terrain selon le modèle de pertes ACRE", fi: "Maastoarvio ACRE-häviömallilla", uk: "Оцінка рельєфу за моделлю втрат ACRE" }, + profile_range_acre_note: { en: "Approximate range uses ACRE terrain loss", ru: "Приблизительный радиус использует потери ACRE на рельефе", de: "Ungefähre Reichweite nutzt ACRE-Geländeverluste", cs: "Přibližný dosah používá ztráty terénu ACRE", it: "Il raggio approssimato usa le perdite di terreno ACRE", fr: "La portée approximative utilise les pertes de terrain ACRE", fi: "Arvioitu kantama käyttää ACRE-maastohäviötä", uk: "Приблизний радіус використовує втрати ACRE на рельєфі" }, }; diff --git a/ui/src/pages/map-manager/constants.ts b/ui/src/pages/map-manager/constants.ts index 3109b21dd..41dd2cf59 100644 --- a/ui/src/pages/map-manager/constants.ts +++ b/ui/src/pages/map-manager/constants.ts @@ -17,6 +17,7 @@ export const PIPELINE_STAGES: PipelineStage[] = [ export const OUTPUT_FILES = [ { name: "satellite.pmtiles", label: "Satellite" }, { name: "heightmap.pmtiles", label: "Heightmap" }, + { name: "dem.bin.gz", label: "Playback DEM" }, { name: "hillshade.pmtiles", label: "Hillshade" }, { name: "color-relief.pmtiles", label: "Color Relief" }, { name: "features.pmtiles", label: "Vector Features" }, diff --git a/ui/src/pages/recording-playback/__tests__/PlayerProfileCard.test.tsx b/ui/src/pages/recording-playback/__tests__/PlayerProfileCard.test.tsx new file mode 100644 index 000000000..acacb9386 --- /dev/null +++ b/ui/src/pages/recording-playback/__tests__/PlayerProfileCard.test.tsx @@ -0,0 +1,491 @@ +import { describe, it, expect, afterEach, vi } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@solidjs/testing-library"; +import { PlayerProfileCard } from "../components/PlayerProfileCard"; +import { Unit } from "../../../playback/entities/unit"; +import type { EventDef, WorldConfig } from "../../../data/types"; +import { createTestEngine, TestProviders, unitDef, makeManifest } from "./testHelpers"; + +afterEach(cleanup); + +function snapshotEvent( + frameNum: number, + type: "inventorySnapshot" | "staminaSnapshot" | "medicalSnapshot" | "radioSnapshot", + payload: Record, +): EventDef { + return { type, frameNum, payload: { unitId: 1, ...payload } } as unknown as EventDef; +} + +function renderCard(events: EventDef[], frame = 10, world?: WorldConfig) { + const { engine, renderer } = createTestEngine(); + if (world) engine.setWorldConfig(world); + const unitPositions = Array.from({ length: frame + 1 }, () => ({ + position: [100, 200] as [number, number], + direction: 0, + alive: 1 as const, + })); + engine.loadRecording( + makeManifest([unitDef({ id: 1, name: "Rifleman", positions: unitPositions })], events), + ); + engine.seekTo(frame); + const unit = engine.entityManager.getEntity(1) as Unit; + + render(() => ( + + {}} + onToggleFollow={() => {}} + /> + + )); + return { engine, renderer }; +} + +describe("PlayerProfileCard", () => { + it("converts Arma mass units to kilograms", () => { + renderCard([ + snapshotEvent(5, "staminaSnapshot", { + vanilla: { massUnits: 545, load: 0.545, stamina: 26.6, staminaMax: 27.3 }, + }), + ]); + + expect(screen.getByText("24.7")).toBeTruthy(); + }); + + it("shows vanilla stamina as a share of the load-adjusted maximum", () => { + renderCard([ + snapshotEvent(5, "staminaSnapshot", { + vanilla: { massUnits: 545, load: 0.545, stamina: 13.65, staminaMax: 27.3 }, + }), + ]); + + expect(screen.getByText("50%")).toBeTruthy(); + }); + + it("prefers the ACE anaerobic reserve when advanced fatigue is recorded", () => { + renderCard([ + snapshotEvent(5, "staminaSnapshot", { + vanilla: { massUnits: 100, load: 0.1, stamina: 54, staminaMax: 54 }, + ace: { anaerobicReserve: 0.42, aerobicReserve: 1, muscleDamage: 0, performanceFactor: 2 }, + }), + ]); + + expect(screen.getAllByText("42%").length).toBeGreaterThan(0); + }); + + it("does not read the vanilla pool that ACE froze above its ceiling", () => { + renderCard([ + snapshotEvent(5, "staminaSnapshot", { + vanilla: { massUnits: 545, load: 0.545, stamina: 60, staminaMax: 27.3 }, + ace: { anaerobicReserve: 0.87, aerobicReserve: 1, muscleDamage: 0, performanceFactor: 2 }, + }), + ]); + + expect(screen.getAllByText("87%").length).toBeGreaterThan(0); + + fireEvent.click(screen.getByRole("tab", { name: "Stamina" })); + + expect(screen.getByText("ACE Advanced Fatigue")).toBeTruthy(); + expect(screen.queryByText("Sprint reserve")).toBeNull(); + expect(screen.queryByText("Fatigue")).toBeNull(); + expect(screen.queryByText(/60\.0 s of/)).toBeNull(); + }); + + it("prints ACE blood pressure as systolic/diastolic", () => { + renderCard([ + snapshotEvent(5, "medicalSnapshot", { + vanilla: { damage: 0 }, + ace: { bloodVolume: 6, heartRate: 80, bloodPressure: [80, 120] }, + }), + ]); + + fireEvent.click(screen.getByRole("tab", { name: "Medical" })); + + expect(screen.getByText("120/80")).toBeTruthy(); + expect(screen.queryByText("80/120")).toBeNull(); + }); + + it("shows no pressure instead of 0/0 during cardiac arrest", () => { + renderCard([ + snapshotEvent(5, "medicalSnapshot", { + vanilla: { damage: 0 }, + ace: { bloodVolume: 5.7, heartRate: 0, bloodPressure: [0, 0], cardiacArrest: true, unconscious: true }, + }), + ]); + + fireEvent.click(screen.getByRole("tab", { name: "Medical" })); + + expect(screen.getByText("No pressure")).toBeTruthy(); + expect(screen.queryByText("0/0")).toBeNull(); + }); + + it("lists every body part even when they are uninjured", () => { + renderCard([ + snapshotEvent(5, "medicalSnapshot", { + vanilla: { damage: 0 }, + ace: { bloodVolume: 6, heartRate: 80 }, + bodyParts: [ + { part: "head", damage: 0, items: [] }, + { part: "body", damage: 0, items: [] }, + { part: "leftarm", damage: 0, items: [] }, + { part: "rightarm", damage: 0, items: [] }, + { part: "leftleg", damage: 0, items: [] }, + { part: "rightleg", damage: 0, items: [] }, + ], + }), + ]); + + fireEvent.click(screen.getByRole("tab", { name: "Medical" })); + + expect(screen.getByText("Head")).toBeTruthy(); + expect(screen.getByText("Torso")).toBeTruthy(); + expect(screen.getByText("L Arm")).toBeTruthy(); + expect(screen.getByText("R Arm")).toBeTruthy(); + expect(screen.getByText("L Leg")).toBeTruthy(); + expect(screen.getByText("R Leg")).toBeTruthy(); + expect(screen.getAllByText("Uninjured").length).toBe(6); + expect(screen.getAllByText("Dmg 0%").length).toBe(6); + }); + + it("caps ACE body-part trauma at 100 percent", () => { + renderCard([ + snapshotEvent(5, "medicalSnapshot", { + vanilla: { damage: 0 }, + ace: { bloodVolume: 5, heartRate: 0 }, + bodyParts: [{ part: "head", damage: 2.02, items: [{ kind: "wound", name: "Large Velocity Wound", count: 2 }] }], + }), + ]); + + fireEvent.click(screen.getByRole("tab", { name: "Medical" })); + + expect(screen.getByText("Dmg 100%")).toBeTruthy(); + expect(screen.queryByText("Dmg 202%")).toBeNull(); + }); + + it("replaces the vanilla hit point list with ACE readings when ACE is loaded", () => { + renderCard([ + snapshotEvent(5, "medicalSnapshot", { + vanilla: { damage: 0.25, lifeState: "HEALTHY", hitPointNames: ["hithead"], hitPointDamage: [0.3] }, + ace: { bloodVolume: 5.1, heartRate: 133, pain: 0.4, hemorrhage: 2 }, + }), + ]); + + fireEvent.click(screen.getByRole("tab", { name: "Medical" })); + + expect(screen.getByText("ACE Medical")).toBeTruthy(); + expect(screen.getByText("Class II")).toBeTruthy(); + expect(screen.queryByText("Life state")).toBeNull(); + expect(screen.queryByText("Damaged hit points")).toBeNull(); + }); + + it("prefers the KAT oxygen saturation over the ACE one", () => { + renderCard([ + snapshotEvent(5, "medicalSnapshot", { + vanilla: { damage: 0 }, + ace: { bloodVolume: 6, heartRate: 80, spo2: 97 }, + kat: { spo2: 81.4, etco2: 30 }, + }), + ]); + + fireEvent.click(screen.getByRole("tab", { name: "Medical" })); + + expect(screen.getByText("81.4%")).toBeTruthy(); + expect(screen.queryByText("97.0%")).toBeNull(); + }); + + it("renders whatever treatments a body part carries without knowing their kind", () => { + renderCard([ + snapshotEvent(5, "medicalSnapshot", { + vanilla: { damage: 0.2 }, + ace: { bloodVolume: 5, heartRate: 90 }, + bodyParts: [ + { part: "head", damage: 0, items: [] }, + { + part: "body", + damage: 0.45, + items: [ + { kind: "bandage", name: "Bandaged wound", count: 3 }, + { kind: "iv", name: "Saline IV (250ml)", detail: "180 ml left" }, + { kind: "somethingNew", name: "Experimental patch" }, + ], + }, + ], + treatments: [{ kind: "medication", name: "Morphine", count: 2 }], + }), + ]); + + fireEvent.click(screen.getByRole("tab", { name: "Medical" })); + + expect(screen.getByText("Torso")).toBeTruthy(); + expect(screen.getByText(/Bandaged wound/)).toBeTruthy(); + expect(screen.getByText(/Saline IV \(250ml\)/)).toBeTruthy(); + expect(screen.getByText(/Experimental patch/)).toBeTruthy(); + expect(screen.getByText(/Morphine/)).toBeTruthy(); + expect(screen.getByRole("group", { name: "Body diagram" })).toBeTruthy(); + expect(document.querySelector('[data-overlay="chestSeal"]')).toBeNull(); + }); + + it("draws ACE/KAT treatment icons on the body diagram", () => { + renderCard([ + snapshotEvent(5, "medicalSnapshot", { + vanilla: { damage: 0.2 }, + ace: { bloodVolume: 5, heartRate: 90 }, + kat: { pneumothorax: 1, chestSeal: true }, + bodyParts: [ + { + part: "body", + damage: 0.4, + items: [{ kind: "chestSeal", name: "Chest seal" }], + }, + { + part: "rightarm", + damage: 0, + items: [{ kind: "tourniquet", name: "Tourniquet" }], + }, + ], + }), + ]); + + fireEvent.click(screen.getByRole("tab", { name: "Medical" })); + + expect(document.querySelector('[data-overlay="chestSeal"]')).toBeTruthy(); + expect(document.querySelector('[data-overlay="pneumothorax"]')).toBeTruthy(); + expect(document.querySelector('[data-overlay="tourniquet:rightarm"]')).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Torso" })); + expect(document.querySelector('[data-overlay="selected:body"]')).toBeTruthy(); + }); + + it("prints ACE activity and quick-view lines the way the medical menu does", () => { + renderCard([ + snapshotEvent(5, "medicalSnapshot", { + vanilla: { damage: 0.2 }, + ace: { bloodVolume: 5, heartRate: 0 }, + activity: [ + { time: "10:02", text: "Matthew Allen has bandaged patient" }, + { time: "10:02", text: "Matthew Allen applied a tourniquet" }, + ], + quickView: [{ time: "10:02", text: "Matthew Allen checked Heart Rate: None" }], + }), + ]); + + fireEvent.click(screen.getByRole("tab", { name: "Medical" })); + + expect(screen.getByText("Activity log")).toBeTruthy(); + expect(screen.getByText("Quick view")).toBeTruthy(); + expect(screen.getByText("Matthew Allen has bandaged patient")).toBeTruthy(); + expect(screen.getByText("Matthew Allen applied a tourniquet")).toBeTruthy(); + expect(screen.getByText("Matthew Allen checked Heart Rate: None")).toBeTruthy(); + expect(screen.getAllByText("10:02").length).toBe(3); + }); + + it("rebuilds a diff-encoded snapshot when the playhead sits on it", () => { + renderCard( + [ + snapshotEvent(5, "radioSnapshot", { + playerUid: "uid", + radios: [ + { + class: "TFAR_anprc152_1", + name: "AN/PRC-152", + mod: "TFAR", + type: "SW", + channel: 1, + frequency: 472.8, + code: "", + rangeMeters: 5000, + additional: false, + }, + ], + }), + snapshotEvent(20, "radioSnapshot", { + diffOf: 5, + set: { radios: [{ class: "TFAR_anprc152_1", name: "AN/PRC-152", mod: "TFAR", type: "SW", channel: 3, frequency: 101, code: "", rangeMeters: 2000, additional: false }] }, + }), + ], + 20, + ); + + fireEvent.click(screen.getByRole("tab", { name: "Radios" })); + + expect(screen.getByText("101.000 MHz")).toBeTruthy(); + expect(screen.getByText(/2\.0 km/)).toBeTruthy(); + }); + + it("offers simple and approximate range buttons", () => { + const { renderer } = renderCard([ + snapshotEvent(5, "radioSnapshot", { + radios: [ + { + class: "TFAR_anprc152_1", + name: "AN/PRC-152", + mod: "TFAR", + type: "SW", + channel: 1, + frequency: 472.8, + code: "", + rangeMeters: 5000, + additional: false, + }, + ], + }), + ]); + const createSpy = vi.spyOn(renderer, "createBriefingMarker"); + + fireEvent.click(screen.getByRole("tab", { name: "Radios" })); + + expect(screen.getByRole("button", { name: "Simple range" })).toBeTruthy(); + const approx = screen.getByRole("button", { name: "Approximate range" }); + expect(approx).toBeTruthy(); + expect(approx.hasAttribute("disabled")).toBe(true); + + fireEvent.click(screen.getByRole("button", { name: "Simple range" })); + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ + shape: "ELLIPSE", + size: [5000, 5000], + brush: "SolidBorder", + layer: "systemMarkers", + }), + ); + }); + + it("enables approximate range when the map has elevation", () => { + renderCard( + [ + snapshotEvent(5, "radioSnapshot", { + radios: [ + { + class: "TFAR_anprc152_1", + name: "AN/PRC-152", + mod: "TFAR", + type: "SW", + channel: 1, + frequency: 472.8, + code: "", + rangeMeters: 5000, + additional: false, + }, + ], + }), + ], + 10, + { + worldName: "Altis", + worldSize: 30720, + maxZoom: 6, + minZoom: 0, + tileBaseUrl: "http://maps.test/altis", + hasDem: true, + }, + ); + + fireEvent.click(screen.getByRole("tab", { name: "Radios" })); + expect(screen.getByRole("button", { name: "Approximate range" }).hasAttribute("disabled")).toBe(false); + }); + + it("notes that ACRE coverage uses ACRE terrain loss", () => { + renderCard([ + snapshotEvent(5, "radioSnapshot", { + radios: [ + { + class: "ACRE_PRC152", + name: "AN/PRC-152", + mod: "ACRE", + type: "SR", + channel: 1, + frequency: 60, + code: "", + rangeMeters: 5000, + additional: false, + powerMilliwatts: 5000, + }, + ], + }), + ]); + + fireEvent.click(screen.getByRole("tab", { name: "Radios" })); + expect(screen.getByText("Approximate range uses ACRE terrain loss")).toBeTruthy(); + }); + + it("points at the first snapshot instead of implying the data is missing", () => { + renderCard([snapshotEvent(50, "inventorySnapshot", { weapons: [], magazines: [], assignedItems: [] })], 10); + + expect(screen.getByText("First gear snapshot is at frame 50. Scrub forward to see it.")).toBeTruthy(); + }); + + it("hides untracked snapshot tabs and metrics when a unit has none", () => { + const { engine, renderer } = createTestEngine(); + engine.loadRecording(makeManifest([unitDef({ id: 2, name: "Allen", isPlayer: false })], [])); + const unit = engine.entityManager.getEntity(2) as Unit; + + render(() => ( + + {}} + onToggleFollow={() => {}} + /> + + )); + + expect(screen.getByText("Follow")).toBeTruthy(); + expect(screen.queryByRole("tab")).toBeNull(); + expect(screen.queryByText("Gear")).toBeNull(); + expect(screen.queryByText("Medical")).toBeNull(); + expect(screen.queryByText("Radios")).toBeNull(); + expect(screen.queryByText(/weight/i)).toBeNull(); + expect(screen.queryByText("No gear snapshot was recorded for this player.")).toBeNull(); + }); + + it("only lists snapshot tabs that exist for the unit", () => { + renderCard([snapshotEvent(5, "medicalSnapshot", { vanilla: { damage: 0.1, lifeState: "HEALTHY" } })]); + + expect(screen.getByRole("tab", { name: "Medical" })).toBeTruthy(); + expect(screen.queryByRole("tab", { name: "Gear" })).toBeNull(); + expect(screen.queryByRole("tab", { name: "Stamina" })).toBeNull(); + expect(screen.queryByRole("tab", { name: "Radios" })).toBeNull(); + expect(screen.getByText("Life state")).toBeTruthy(); + }); + + it("marks the selected tab as selected", () => { + renderCard([ + snapshotEvent(5, "inventorySnapshot", { weapons: [], magazines: [], assignedItems: [] }), + snapshotEvent(5, "medicalSnapshot", { vanilla: { damage: 0.25, lifeState: "HEALTHY" } }), + ]); + + expect(screen.getByRole("tab", { name: "Gear" }).getAttribute("aria-selected")).toBe("true"); + fireEvent.click(screen.getByRole("tab", { name: "Medical" })); + expect(screen.getByRole("tab", { name: "Medical" }).getAttribute("aria-selected")).toBe("true"); + expect(screen.getByRole("tab", { name: "Gear" }).getAttribute("aria-selected")).toBe("false"); + }); + + it("shows one snapshot category at a time and switches on tab click", () => { + renderCard([ + snapshotEvent(5, "inventorySnapshot", { weapons: [], magazines: [], assignedItems: [] }), + snapshotEvent(5, "medicalSnapshot", { vanilla: { damage: 0.25, lifeState: "HEALTHY" } }), + ]); + + expect(screen.getByText("Weapons")).toBeTruthy(); + expect(screen.queryByText("Life state")).toBeNull(); + + fireEvent.click(screen.getByRole("tab", { name: "Medical" })); + + expect(screen.queryByText("Weapons")).toBeNull(); + expect(screen.getByText("Life state")).toBeTruthy(); + }); +}); diff --git a/ui/src/pages/recording-playback/__tests__/UnitsTab.test.tsx b/ui/src/pages/recording-playback/__tests__/UnitsTab.test.tsx index b894f4aff..3363edaf6 100644 --- a/ui/src/pages/recording-playback/__tests__/UnitsTab.test.tsx +++ b/ui/src/pages/recording-playback/__tests__/UnitsTab.test.tsx @@ -108,9 +108,12 @@ describe("UnitsTab", () => { // Detail card should now show with Follow button and stats expect(screen.getByText("Follow")).toBeTruthy(); - expect(screen.getByText("KILLS")).toBeTruthy(); - expect(screen.getByText("DEATHS")).toBeTruthy(); - expect(screen.getByText("MARKERS")).toBeTruthy(); + expect(screen.getByText(/kills/i)).toBeTruthy(); + expect(screen.getByText(/deaths/i)).toBeTruthy(); + expect(screen.getByText(/markers/i)).toBeTruthy(); + expect(screen.queryByRole("tab")).toBeNull(); + expect(screen.queryByText(/weight/i)).toBeNull(); + expect(screen.queryByText("Stamina")).toBeNull(); }); it("clicking Follow button in detail card follows the entity", () => { @@ -545,7 +548,7 @@ describe("UnitsTab", () => { fireEvent.click(screen.getByText("Marker Player")); // Admin section should show with blacklist button - expect(screen.getByText("ADMIN ACTIONS")).toBeTruthy(); + expect(screen.getByText(/admin actions/i)).toBeTruthy(); expect(screen.getByText(/Blacklist 3 markers/)).toBeTruthy(); }); @@ -610,7 +613,7 @@ describe("UnitsTab", () => { // Follow button should show but no admin section expect(screen.getByText("Follow")).toBeTruthy(); - expect(screen.queryByText("ADMIN ACTIONS")).toBeNull(); + expect(screen.queryByText(/admin actions/i)).toBeNull(); expect(screen.queryByTitle("Toggle marker blacklist")).toBeNull(); }); @@ -670,7 +673,7 @@ describe("UnitsTab", () => { fireEvent.click(screen.getByText("Spammer")); // Stats grid should show 0 visible markers (blacklisted) - const markersStat = screen.getByText("MARKERS").parentElement!; + const markersStat = screen.getByText(/^markers$/i).parentElement!; expect(markersStat.textContent).toContain("0"); // But the restore button still shows the total @@ -704,6 +707,6 @@ describe("UnitsTab", () => { // Follow should show, but no admin section (no markers) expect(screen.getByText("Follow")).toBeTruthy(); - expect(screen.queryByText("ADMIN ACTIONS")).toBeNull(); + expect(screen.queryByText(/admin actions/i)).toBeNull(); }); }); diff --git a/ui/src/pages/recording-playback/__tests__/loadRecording.test.ts b/ui/src/pages/recording-playback/__tests__/loadRecording.test.ts index f4b0705a7..53aee1a8b 100644 --- a/ui/src/pages/recording-playback/__tests__/loadRecording.test.ts +++ b/ui/src/pages/recording-playback/__tests__/loadRecording.test.ts @@ -99,6 +99,7 @@ function makeMockApi(worldConfig: WorldConfig, recordingData?: ArrayBuffer): Api function makeMockEngine(): PlaybackEngine { return { loadRecording: vi.fn(), + setWorldConfig: vi.fn(), entityManager: { getEntity: vi.fn().mockReturnValue(null), }, diff --git a/ui/src/pages/recording-playback/components/EventsTab.tsx b/ui/src/pages/recording-playback/components/EventsTab.tsx index 9cf6815d1..c2513fae3 100644 --- a/ui/src/pages/recording-playback/components/EventsTab.tsx +++ b/ui/src/pages/recording-playback/components/EventsTab.tsx @@ -8,6 +8,7 @@ import { EndMissionEvent } from "../../../playback/events/endMissionEvent"; import { GeneralMissionEvent } from "../../../playback/events/generalEvent"; import { CapturedEvent } from "../../../playback/events/capturedEvent"; import { TerminalHackEvent } from "../../../playback/events/terminalHackEvent"; +import { PlayerSnapshotEvent } from "../../../playback/events/playerSnapshotEvent"; import type { GameEvent } from "../../../playback/events/gameEvent"; import { SIDE_COLORS_UI } from "../../../config/sideColors"; import { formatElapsedTime } from "../../../playback/time"; @@ -56,6 +57,9 @@ export function EventsTab(): JSX.Element { const [filters, setFilters] = createSignal(DEFAULT_EVENT_FILTERS); const passesEventType = (event: GameEvent, f: EventFilterState): boolean => { + // Detail snapshots drive the player profile and are intentionally hidden + // from the human-readable mission event log. + if (event instanceof PlayerSnapshotEvent) return false; if (event instanceof HitKilledEvent) { return event.type === "killed" ? f.showKills : f.showHits; } diff --git a/ui/src/pages/recording-playback/components/MedicalBodyImage.module.css b/ui/src/pages/recording-playback/components/MedicalBodyImage.module.css new file mode 100644 index 000000000..6fae18d26 --- /dev/null +++ b/ui/src/pages/recording-playback/components/MedicalBodyImage.module.css @@ -0,0 +1,41 @@ +.stage { + position: relative; + width: min(100%, 220px); + aspect-ratio: 1; + margin: 0 auto; + background: #050607; + border-radius: 8px; + overflow: hidden; +} + +.background { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + pointer-events: none; + user-select: none; +} + +.layer { + position: absolute; + inset: 0; + background-color: var(--tint, #fff); + -webkit-mask: var(--src) center / contain no-repeat; + mask: var(--src) center / contain no-repeat; + pointer-events: none; +} + +.hit { + position: absolute; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; +} + +.hit:focus-visible { + outline: 1px solid rgba(255, 255, 255, 0.55); + outline-offset: -1px; +} diff --git a/ui/src/pages/recording-playback/components/MedicalBodyImage.tsx b/ui/src/pages/recording-playback/components/MedicalBodyImage.tsx new file mode 100644 index 000000000..e583a9585 --- /dev/null +++ b/ui/src/pages/recording-playback/components/MedicalBodyImage.tsx @@ -0,0 +1,95 @@ +import { For } from "solid-js"; +import type { JSX } from "solid-js"; +import type { MedicalBodyPart } from "../../../data/types"; +import { useI18n } from "../../../hooks/useLocale"; +import { BODY_BACKGROUND, BODY_OVERLAY_ASSETS, BODY_PART_FILLS } from "../medical/bodyAssets"; +import { + BODY_PART_IDS, + HIT_REGIONS, + bodyPartFillColor, + overlaysForMedical, + type BodyPartId, +} from "../medical/bodyImage"; +import styles from "./MedicalBodyImage.module.css"; + +const PART_LABEL_KEYS: Record = { + head: "profile_part_head", + body: "profile_part_body", + leftarm: "profile_part_leftarm", + rightarm: "profile_part_rightarm", + leftleg: "profile_part_leftleg", + rightleg: "profile_part_rightleg", +}; + +interface Props { + parts: MedicalBodyPart[]; + kat?: Record; + vanillaDamage?: number; + selected: string | null; + onSelect: (part: BodyPartId) => void; +} + +function maskStyle(src: string, tint: string): JSX.CSSProperties { + return { + "--src": `url("${src}")`, + "--tint": tint, + } as JSX.CSSProperties; +} + +export function MedicalBodyImage(props: Props): JSX.Element { + const { t } = useI18n(); + const overlays = () => overlaysForMedical(props.parts, props.kat, props.selected); + + return ( +

+ + + {(partId) => { + const part = () => props.parts.find((entry) => entry.part === partId); + return ( +
+ ); + }} + + + {(overlay) => { + const src = BODY_OVERLAY_ASSETS[overlay.asset]; + return src ? ( +
+ ) : null; + }} + + + {(partId) => { + const region = HIT_REGIONS[partId]; + return ( +
+ ); +} diff --git a/ui/src/pages/recording-playback/components/PlayerProfileCard.module.css b/ui/src/pages/recording-playback/components/PlayerProfileCard.module.css new file mode 100644 index 000000000..81347222a --- /dev/null +++ b/ui/src/pages/recording-playback/components/PlayerProfileCard.module.css @@ -0,0 +1,428 @@ +.card { + position: fixed; + top: 56px; + left: calc(var(--pb-panel-width, 370px) + 18px); + bottom: calc(var(--pb-bottom-height, 90px) + 8px); + width: min(640px, calc(100vw - var(--pb-panel-width, 370px) - 36px)); + z-index: 40; + display: flex; + flex-direction: column; + color: var(--text-secondary); + background: color-mix(in srgb, var(--bg-panel) 96%, #101820); + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 12px; + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.48); + overflow: hidden; + font-family: var(--font-mono); +} + +.header { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 15px 16px 12px; + border-top: 3px solid; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.header h2 { + margin: 3px 0 0; + color: var(--text-primary); + font-size: 20px; +} + +.eyebrow, +.subheading { + color: var(--text-dimmer); + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.close { + border: 0; + background: transparent; + color: var(--text-dimmer); + font-size: 26px; + cursor: pointer; + line-height: 1; +} + +.actions { + display: flex; + gap: 6px; + padding: 8px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.actions button, +.radioRow button { + padding: 6px 9px; + color: var(--text-muted); + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 6px; + font: inherit; + font-size: 11px; + cursor: pointer; +} + +.adminActions { + display: flex; + align-items: center; + gap: 6px; +} + +.adminActions span { + color: var(--text-dimmest); + font-size: 9px; + letter-spacing: 0.08em; +} + +.active { + color: var(--accent-primary) !important; + border-color: color-mix(in srgb, var(--accent-primary) 40%, transparent) !important; + background: color-mix(in srgb, var(--accent-primary) 12%, transparent) !important; +} + +.danger { + color: var(--accent-danger) !important; +} + +.metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(84px, 1fr)); + gap: 4px; + padding: 10px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.metrics div { + padding: 7px 3px; + text-align: center; + background: rgba(0, 0, 0, 0.2); + border-radius: 6px; +} + +.metrics strong, +.metrics span { + display: block; +} + +.metrics strong { + color: var(--text-primary); + font-size: 15px; +} + +.metrics span { + margin-top: 2px; + color: var(--text-dimmest); + font-size: 9px; + text-transform: uppercase; +} + +.tabs { + display: flex; + gap: 4px; + padding: 8px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.tabs button { + flex: 1; + padding: 7px 10px; + color: var(--text-muted); + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 6px; + font: inherit; + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; + cursor: pointer; + transition: color 0.12s ease, background 0.12s ease, border-color 0.12s ease, box-shadow 0.12s ease; +} + +.tabs button:hover { + color: var(--text-secondary); + background: rgba(255, 255, 255, 0.08); +} + +.tabs button.activeTab { + color: #fff; + font-weight: 700; + border-color: var(--accent-primary); + background: color-mix(in srgb, var(--accent-primary) 42%, #0c1218); + box-shadow: inset 0 -2px 0 #fff, 0 0 0 1px color-mix(in srgb, var(--accent-primary) 55%, transparent); +} + +.scroll { + min-height: 0; + overflow-y: auto; + padding: 15px 14px 18px; +} + +.snapshotMeta { + margin: 0 0 8px; + color: var(--text-dimmest); + font-size: 10px; +} + +.gearBlock { + margin-bottom: 9px; + padding: 8px; + background: rgba(0, 0, 0, 0.16); + border-radius: 7px; +} + +.blockTitle { + margin-bottom: 6px; + color: var(--text-dimmer); + font-size: 10px; + text-transform: uppercase; +} + +.blockTitle span { + float: right; + color: var(--text-muted); + text-transform: none; +} + +.itemList { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +.itemChip { + padding: 3px 5px; + color: var(--text-muted); + background: rgba(255, 255, 255, 0.05); + border-radius: 4px; + font-size: 10px; +} + +.empty, +.noData { + color: var(--text-dimmest); + font-size: 11px; + font-style: italic; +} + +.equipped { + display: flex; + gap: 6px; + margin-bottom: 8px; +} + +.equipped span { + flex: 1; + padding: 6px; + background: rgba(255, 255, 255, 0.03); + border-radius: 5px; + font-size: 10px; +} + +.weapon { + margin-bottom: 7px; +} + +.weapon b { + font-size: 11px; +} + +.weapon small { + margin-left: 7px; + color: var(--text-dimmest); + text-transform: uppercase; +} + +.weapon .itemList { + margin-top: 4px; +} + +.medicalLayout { + display: flex; + flex-wrap: wrap; + gap: 10px 12px; + align-items: start; + margin-bottom: 4px; +} + +.medicalReadings { + flex: 1 1 220px; + min-width: 0; +} + +.dataGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 5px; +} + +.dataGrid span { + display: flex; + flex-direction: column; + padding: 6px 7px; + color: var(--text-dimmest); + background: rgba(0, 0, 0, 0.16); + border-radius: 5px; + font-size: 9px; + text-transform: uppercase; +} + +.dataGrid b { + margin-top: 2px; + color: var(--text-secondary); + font-size: 11px; + text-transform: none; +} + +.subheading { + margin: 11px 0 6px; +} + +.bodyParts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + gap: 5px; + margin-top: 8px; +} + +.bodyParts div { + padding: 6px 7px; + background: rgba(0, 0, 0, 0.16); + border-radius: 5px; + cursor: pointer; +} + +.bodyPartSelected { + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.55); +} + +.bodyParts b, +.bodyParts span { + display: block; +} + +.bodyParts b { + color: var(--text-secondary); + font-size: 10px; +} + +.bodyParts span { + color: var(--text-dimmest); + font-size: 9px; +} + +.medicalLogs { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; + margin-top: 12px; +} + +.medicalLog { + min-height: 72px; + padding: 8px; + background: rgba(0, 0, 0, 0.16); + border-radius: 7px; +} + +.medicalLogHeader { + margin-bottom: 6px; + color: rgb(153, 179, 255); + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.medicalLogEntry { + display: flex; + gap: 8px; + margin-bottom: 3px; + font-size: 10px; +} + +.medicalLogEntry time { + flex: 0 0 auto; + color: var(--text-dimmer); + font-variant-numeric: tabular-nums; +} + +.medicalLogEntry span { + color: var(--text-secondary); +} + +.radioRow { + display: grid; + grid-template-columns: 1fr auto; + gap: 4px 8px; + margin-bottom: 7px; + padding: 9px; + background: rgba(0, 0, 0, 0.18); + border-radius: 7px; +} + +.radioRow div b, +.radioRow div span { + display: block; +} + +.radioRow div b { + font-size: 11px; +} + +.radioRow div span, +.radioRow small { + color: var(--text-dimmest); + font-size: 9px; +} + +.radioRow strong { + color: var(--accent-primary); + font-size: 12px; +} + +.radioRow small { + align-self: center; +} + +.radioRow button { + justify-self: end; + padding: 4px 7px; +} + +.radioActions { + grid-column: 1 / -1; + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.radioActions button { + justify-self: auto; + flex: 1 1 0; + min-width: 0; +} + +.radioActions button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.radioHint { + grid-column: 1 / -1; + color: var(--text-dimmest); + font-size: 9px; +} + +@media (max-width: 900px) { + .card { + left: 8px; + right: 8px; + top: 64px; + width: auto; + bottom: calc(var(--pb-bottom-height, 90px) + 8px); + } +} diff --git a/ui/src/pages/recording-playback/components/PlayerProfileCard.tsx b/ui/src/pages/recording-playback/components/PlayerProfileCard.tsx new file mode 100644 index 000000000..f60baeb07 --- /dev/null +++ b/ui/src/pages/recording-playback/components/PlayerProfileCard.tsx @@ -0,0 +1,983 @@ +import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"; +import type { JSX } from "solid-js"; +import type { + GearContainer, + GearItem, + InventorySnapshot, + MedicalBodyPart, + MedicalLogEntry, + MedicalSnapshot, + PlayerSnapshotType, + RadioSnapshot, + RadioSnapshotEntry, + StaminaSnapshot, + TreatmentItem, +} from "../../../data/types"; +import { useEngine } from "../../../hooks/useEngine"; +import { useI18n } from "../../../hooks/useLocale"; +import { useRenderer } from "../../../hooks/useRenderer"; +import type { Unit } from "../../../playback/entities/unit"; +import { PlayerSnapshotEvent } from "../../../playback/events/playerSnapshotEvent"; +import type { BriefingMarkerHandle } from "../../../renderers/renderer.types"; +import { SIDE_COLORS_BRIGHT, SIDE_COLORS_UI } from "../../../config/sideColors"; +import { BODY_PART_IDS, type BodyPartId } from "../medical/bodyImage"; +import { loadWorldDem, worldHasElevation, type DemGrid } from "../../../playback/radioRange/demGrid"; +import { marchAcreCoverage } from "../../../playback/radioRange/acreCoverage"; +import { marchCoverage } from "../../../playback/radioRange/tfarCoverage"; +import { MedicalBodyImage } from "./MedicalBodyImage"; +import styles from "./PlayerProfileCard.module.css"; + +type RangeMode = "simple" | "approx"; + +interface Props { + unit: Unit; + kills: number; + deaths: number; + markerCount: number; + isBlacklisted: boolean; + isFollowed: boolean; + isAdmin: boolean; + showKillCount: boolean; + onClose: () => void; + onToggleFollow: (id: number) => void; + onToggleBlacklist?: (id: number) => void; +} + +const percent = (value: unknown): string => + typeof value === "number" ? `${Math.round(value * 100)}%` : "—"; +const number = (value: unknown, digits = 0): string => + typeof value === "number" ? value.toFixed(digits) : "—"; + +const BODY_PART_KEYS: Record = { + head: "profile_part_head", + body: "profile_part_body", + leftarm: "profile_part_leftarm", + rightarm: "profile_part_rightarm", + leftleg: "profile_part_leftleg", + rightleg: "profile_part_rightleg", +}; +const HEMORRHAGE_KEYS = [ + "profile_hemorrhage_0", + "profile_hemorrhage_1", + "profile_hemorrhage_2", + "profile_hemorrhage_3", + "profile_hemorrhage_4", +] as const; + +const TAB_DEFS = [ + { id: "gear", type: "inventorySnapshot", labelKey: "profile_tab_gear", missingKey: "profile_label_gear" }, + { id: "medical", type: "medicalSnapshot", labelKey: "profile_tab_medical", missingKey: "profile_label_medical" }, + { id: "stamina", type: "staminaSnapshot", labelKey: "profile_tab_stamina", missingKey: "profile_label_stamina" }, + { id: "radio", type: "radioSnapshot", labelKey: "profile_tab_radios", missingKey: "profile_label_radio" }, +] as const; +type TabId = (typeof TAB_DEFS)[number]["id"]; + +/** One Arma mass unit is a tenth of a pound. */ +const MASS_UNIT_KG = 0.0453592; +/** Engine sprint duration for an unloaded soldier, used when staminaMax is absent. */ +const STAMINA_DURATION = 60; + +function pickVital(...layers: unknown[]): number | undefined { + let picked: number | undefined; + for (const layer of layers) { + if (typeof layer === "number") picked = layer; + } + return picked; +} + +/** ACE stores [diastolic, systolic]. Display as the clinical 120/80. */ +function formatBloodPressure(value: unknown, noneLabel: string): string { + if (!Array.isArray(value) || value.length < 2) return "—"; + const diastolic = value[0]; + const systolic = value[1]; + if (typeof systolic !== "number" || typeof diastolic !== "number") return "—"; + // Cardiac arrest zeros cardiac output, so ACE writes [0, 0]. That is not a reading. + if (systolic <= 0 && diastolic <= 0) return noneLabel; + return `${Math.round(systolic)}/${Math.round(diastolic)}`; +} + +/** ACE bodyPartDamage is additive trauma, not 0–1. Cap the label at 100%. */ +function formatBodyPartDamage(damage: unknown): string { + if (typeof damage !== "number") return "—"; + return `${Math.round(Math.min(1, Math.max(0, damage)) * 100)}%`; +} + +function displayedBodyParts(parts: MedicalBodyPart[]): MedicalBodyPart[] { + const byId = new Map(parts.map((part) => [part.part, part])); + return BODY_PART_IDS.map( + (id) => byId.get(id) ?? { part: id, damage: 0, items: [] }, + ); +} + +function carriedWeightKg(source?: { massUnits?: number }): number | undefined { + if (typeof source?.massUnits === "number") return source.massUnits * MASS_UNIT_KG; + return undefined; +} + +function snapshotReasonKey(reason: string): string { + return `profile_reason_${reason}`; +} + +function SnapshotMeta(props: { event?: PlayerSnapshotEvent; extra?: string }): JSX.Element { + const { t } = useI18n(); + return ( + + {(event) => { + const reason = + "reason" in event().payload ? (event().payload as InventorySnapshot).reason : undefined; + const reasonKey = reason ? snapshotReasonKey(reason) : ""; + const reasonLabel = reasonKey ? t(reasonKey) : ""; + return ( +
+ {t("profile_frame", { frame: event().frameNum })} + {` · ${reasonLabel}`} + {` · ${props.extra}`} +
+ ); + }} +
+ ); +} + +function ItemList(props: { items?: GearItem[] }): JSX.Element { + const { t } = useI18n(); + return ( +
+ + {(item) => ( + + {item.name || item.class} + 1}> ×{item.count} + + )} + + + {t("profile_empty")} + +
+ ); +} + +function TreatmentList(props: { items?: TreatmentItem[] }): JSX.Element { + const { t } = useI18n(); + return ( +
+ + {(item) => ( + + {item.name} + 1}> ×{item.count} + ({item.detail}) + + )} + + + {t("profile_none")} + +
+ ); +} + +function MedicalLogList(props: { titleKey: string; entries?: MedicalLogEntry[] }): JSX.Element { + const { t } = useI18n(); + return ( +
+
{t(props.titleKey)}
+ + {(entry) => ( +
+ + {entry.text} +
+ )} +
+ + {t("profile_log_empty")} + +
+ ); +} + +function Container(props: { label: string; container?: GearContainer }): JSX.Element { + const { t } = useI18n(); + return ( +
+
+ {props.label} + {props.container?.name || t("profile_none")} +
+ +
+ ); +} + +export function PlayerProfileCard(props: Props): JSX.Element { + const engine = useEngine(); + const renderer = useRenderer(); + const { t } = useI18n(); + const [rangeRadio, setRangeRadio] = createSignal(null); + const [rangeMode, setRangeMode] = createSignal("simple"); + const [demGrid, setDemGrid] = createSignal(); + const [demFailed, setDemFailed] = createSignal(false); + const [tab, setTab] = createSignal("gear"); + const [selectedPart, setSelectedPart] = createSignal(null); + + const approxAvailable = createMemo(() => { + if (demFailed()) return false; + if (demGrid()) return true; + return worldHasElevation(engine.worldConfig); + }); + + const selectRange = (key: string, mode: RangeMode) => { + if (rangeRadio() === key && rangeMode() === mode) { + setRangeRadio(null); + return; + } + if (mode === "approx") { + if (!approxAvailable()) return; + const world = engine.worldConfig; + if (world && !demGrid() && !demFailed()) { + void loadWorldDem({ + tileBaseUrl: world.tileBaseUrl, + worldName: world.worldName, + worldSize: world.worldSize, + hasDem: world.hasDem, + hasHeightmap: world.hasHeightmap, + }).then((grid) => { + if (grid) setDemGrid(grid); + else { + setDemFailed(true); + setRangeMode((current) => (current === "approx" ? "simple" : current)); + } + }); + } + } + setRangeRadio(key); + setRangeMode(mode); + }; + + createEffect(() => { + props.unit.id; + setSelectedPart(null); + setRangeRadio(null); + }); + + const trackedKinds = createMemo(() => { + engine.endFrame(); + const kinds = new Set(); + for (const entry of TAB_DEFS) { + if (engine.eventManager.getFirstPlayerSnapshotFrame(props.unit.id, entry.type) !== undefined) { + kinds.add(entry.type); + } + } + return kinds; + }); + const availableTabs = createMemo(() => TAB_DEFS.filter((entry) => trackedKinds().has(entry.type))); + const tabIsTracked = (id: TabId) => availableTabs().some((entry) => entry.id === id); + + createEffect(() => { + const tabs = availableTabs(); + if (tabs.length === 0) return; + if (!tabs.some((entry) => entry.id === tab())) { + setTab(tabs[0].id); + } + }); + + const snapshots = createMemo(() => { + engine.currentFrame(); + return engine.eventManager.getPlayerSnapshots(props.unit.id, engine.currentFrame()); + }); + const snapshotEvent = (type: PlayerSnapshotType): PlayerSnapshotEvent | undefined => + snapshots().get(type); + const payload = (type: PlayerSnapshotType): T | undefined => + snapshotEvent(type)?.payload as T | undefined; + const inventory = () => payload("inventorySnapshot"); + const medical = () => payload("medicalSnapshot"); + const stamina = () => payload("staminaSnapshot"); + const radio = () => payload("radioSnapshot"); + + const radioKey = (entry: RadioSnapshotEntry, index: number) => + `${index}:${entry.type}:${entry.frequency}:${entry.additional}`; + + let simpleHandle: BriefingMarkerHandle | undefined; + let approxHandle: BriefingMarkerHandle | undefined; + let simpleSignature = ""; + let lastMarchOrigin: [number, number] | undefined; + let lastMarchKey = ""; + let lastMarchVerts: [number, number][] | undefined; + + const clearRangeOverlays = () => { + if (simpleHandle) renderer.removeBriefingMarker(simpleHandle); + if (approxHandle) renderer.removeBriefingMarker(approxHandle); + simpleHandle = undefined; + approxHandle = undefined; + simpleSignature = ""; + lastMarchOrigin = undefined; + lastMarchKey = ""; + lastMarchVerts = undefined; + }; + + createEffect(() => { + const selected = rangeRadio(); + const mode = rangeMode(); + const entries = radio()?.radios ?? []; + const index = entries.findIndex((entry, i) => radioKey(entry, i) === selected); + const entry = index >= 0 ? entries[index] : undefined; + const snapshot = engine.entitySnapshots().get(props.unit.id); + const grid = demGrid(); + + if (!entry || !snapshot || !selected) { + clearRangeOverlays(); + return; + } + + const color = SIDE_COLORS_BRIGHT[props.unit.side].replace("#", ""); + const dashed = mode === "approx"; + const signature = `${mode}:${entry.rangeMeters}:${entry.type}:${color}`; + + if (!simpleHandle || simpleSignature !== signature) { + if (simpleHandle) renderer.removeBriefingMarker(simpleHandle); + simpleHandle = renderer.createBriefingMarker({ + shape: "ELLIPSE", + type: "ocap_radio_range", + color, + side: props.unit.side, + size: [entry.rangeMeters, entry.rangeMeters], + brush: dashed ? "Border" : "SolidBorder", + dashArray: dashed ? "8 8" : undefined, + layer: "systemMarkers", + }); + simpleSignature = signature; + } + renderer.updateBriefingMarker(simpleHandle, { + position: snapshot.position, + direction: 0, + alpha: dashed ? 0.55 : 0.25, + }); + + if (mode !== "approx" || !grid) { + if (approxHandle) { + renderer.removeBriefingMarker(approxHandle); + approxHandle = undefined; + } + return; + } + + const pos = snapshot.position; + const origin: [number, number, number?] = [ + pos[0], + pos[1], + pos.length > 2 ? pos[2] : undefined, + ]; + const isAcre = entry.mod === "ACRE"; + const tfarPropagation = engine.radioPropagation; + const acrePropagation = engine.acrePropagation; + const marchKey = isAcre + ? `acre:${selected}:${entry.rangeMeters}:${entry.frequency}:${acrePropagation.terrainLoss}:${acrePropagation.signalModel}` + : `tfar:${selected}:${entry.rangeMeters}:${tfarPropagation.terrainInterceptionCoefficient}:${tfarPropagation.globalRadioRangeCoef}`; + const moved = + !lastMarchOrigin || + Math.hypot(pos[0] - lastMarchOrigin[0], pos[1] - lastMarchOrigin[1]) >= 5; + if (!lastMarchVerts || lastMarchKey !== marchKey || moved) { + lastMarchVerts = isAcre + ? marchAcreCoverage(grid, origin, { + rangeMeters: entry.rangeMeters, + frequencyMHz: entry.frequency, + propagation: acrePropagation, + }) + : marchCoverage(grid, origin, { + rangeMeters: entry.rangeMeters, + propagation: tfarPropagation, + }); + lastMarchOrigin = [pos[0], pos[1]]; + lastMarchKey = marchKey; + } + + if (!approxHandle) { + approxHandle = renderer.createBriefingMarker({ + shape: "POLYGON", + type: "ocap_radio_coverage", + color, + side: props.unit.side, + brush: "SolidBorder", + layer: "systemMarkers", + }); + } + renderer.updateBriefingMarker(approxHandle, { + position: snapshot.position, + direction: 0, + alpha: 0.28, + points: lastMarchVerts, + }); + }); + onCleanup(clearRangeOverlays); + + const vanillaMedical = () => medical()?.vanilla ?? {}; + const aceMedical = () => medical()?.ace; + const katMedical = () => medical()?.kat; + const vanillaStamina = () => stamina()?.vanilla ?? {}; + const aceFatigue = () => stamina()?.ace; + // The stamina snapshot is sampled far more often than the inventory one, so it wins. + const weightKg = () => carriedWeightKg(vanillaStamina()) ?? carriedWeightKg(inventory()); + const loadFraction = () => vanillaStamina().load ?? inventory()?.load; + const sprintSecondsMax = () => { + const max = vanillaStamina().staminaMax; + if (typeof max === "number" && max > 0) return max; + const load = loadFraction(); + return typeof load === "number" ? STAMINA_DURATION * Math.max(0, 1 - load) : undefined; + }; + const sprintReserve = () => { + const seconds = vanillaStamina().stamina; + const max = sprintSecondsMax(); + if (typeof seconds !== "number" || !max) return undefined; + return Math.min(1, seconds / max); + }; + // ACE Advanced Fatigue turns the vanilla stamina pool off, which freezes getStamina above + // the load-adjusted ceiling it should never exceed. Reading it then is meaningless. + const vanillaStaminaSimulated = () => { + if (aceFatigue()) return false; + const seconds = vanillaStamina().stamina; + const max = sprintSecondsMax(); + return typeof seconds === "number" && !!max && seconds <= max + 0.5; + }; + const staminaReserve = () => + aceFatigue()?.anaerobicReserve ?? (vanillaStaminaSimulated() ? sprintReserve() : undefined); + const staminaSource = () => + aceFatigue() ? t("profile_stamina_source_ace") : t("profile_stamina_source_vanilla"); + const bodyParts = (): MedicalBodyPart[] => displayedBodyParts(medical()?.bodyParts ?? []); + const treatments = (): TreatmentItem[] => medical()?.treatments ?? []; + const activityLog = (): MedicalLogEntry[] => medical()?.activity ?? []; + const quickView = (): MedicalLogEntry[] => medical()?.quickView ?? []; + // ACE and KAT describe the same body far better than the engine does, so when they + // are loaded their readings replace the vanilla hit point list instead of joining it. + // Later medical mods replace earlier ones: vanilla → ACE → KAT. + const spo2 = () => pickVital(vanillaMedical().spo2, aceMedical()?.spo2, katMedical()?.spo2); + const openWoundParts = () => + bodyParts().filter((part) => part.items.some((item) => item.kind === "wound")).length; + const vanillaHitPoints = () => { + const names = vanillaMedical().hitPointNames; + const damages = vanillaMedical().hitPointDamage; + if (!Array.isArray(names) || !Array.isArray(damages)) return []; + return names + .map((name, i) => ({ name: String(name), damage: Number(damages[i] ?? 0) })) + .filter((entry) => entry.damage > 0); + }; + const yesNo = (value: unknown): string => + value === true ? t("profile_yes") : value === false ? t("profile_no") : String(value ?? "—"); + const missingSnapshotNote = (type: PlayerSnapshotType, labelKey: string): string => { + const first = engine.eventManager.getFirstPlayerSnapshotFrame(props.unit.id, type); + if (first === undefined) return t("profile_missing", { label: t(labelKey) }); + return t("profile_first_at", { label: t(labelKey), frame: first }); + }; + const cardLabel = () => + props.unit.name + ? t("profile_aria_named", { name: props.unit.name }) + : t("profile_aria", { id: props.unit.id }); + + return ( +
+ } + > + +
+ setSelectedPart(part)} + /> +
+ +
+ + {t("profile_damage")} + {percent(vanillaMedical().damage)} + + + {t("profile_life_state")} + {String(vanillaMedical().lifeState ?? "—")} + + + {t("profile_incapacitated")} + {yesNo(vanillaMedical().incapacitated)} + +
+
+
{t("profile_hit_points")}
+
+ + {(hitPoint) => ( + + {hitPoint.name} {percent(hitPoint.damage)} + + )} + + + {t("profile_uninjured")} + +
+
+ + }> + {(ace) => ( + <> +
{t("profile_ace_medical")}
+
+ + {t("profile_blood")} + {number(ace().bloodVolume, 2)} L + + + {t("profile_heart_rate")} + {number(ace().heartRate)} bpm + + + {t("profile_blood_pressure")} + + {formatBloodPressure(ace().bloodPressure, t("profile_no_blood_pressure"))} + + + + + {t("profile_spo2")} + {number(spo2(), 1)}% + + + + {t("profile_pain")} + {percent(ace().pain)} + + + {t("profile_bleeding")} + {number(ace().bleedingRate, 2)} + + + {t("profile_hemorrhage")} + + {HEMORRHAGE_KEYS[Number(ace().hemorrhage ?? 0)] + ? t(HEMORRHAGE_KEYS[Number(ace().hemorrhage ?? 0)]) + : number(ace().hemorrhage)} + + + + {t("profile_unconscious")} + {yesNo(ace().unconscious)} + + + {t("profile_cardiac_arrest")} + {yesNo(ace().cardiacArrest)} + + + {t("profile_open_wound_parts")} + {openWoundParts()} + +
+ + )} +
+
+
+
{t("profile_body_parts")}
+
+ + {(part) => ( +
setSelectedPart(part.part)} + > + {BODY_PART_KEYS[part.part] ? t(BODY_PART_KEYS[part.part]) : part.part} + + {t("profile_dmg", { value: formatBodyPartDamage(part.damage) })} + + + {(item) => ( + + {item.name} + 1}> ×{item.count} + + )} + + + + {(part.damage ?? 0) > 0 ? t("profile_untreated") : t("profile_uninjured")} + + +
+ )} +
+
+ +
+
{t("profile_in_system")}
+ +
+
+ + {(kat) => ( + <> +
{t("profile_kat_medical")}
+
+ + {t("profile_spo2")} + {number(spo2(), 1)}% + + + {t("profile_etco2")} + {number(kat().etco2, 1)} + + + {t("profile_breath_rate")} + {number(kat().breathRate, 1)} + + + {t("profile_blood_type")} + {String(kat().bloodType || "—")} + + + {t("profile_airway_secured")} + {yesNo(kat().airwaySecured)} + + + {t("profile_airway_item")} + {String(kat().airwayItem || "—")} + + + {t("profile_obstruction")} + {yesNo(kat().airwayObstruction)} + + + {t("profile_occluded")} + {yesNo(kat().airwayOccluded)} + + + {t("profile_pneumothorax")} + {number(kat().pneumothorax)} + + + {t("profile_chest_seal")} + {yesNo(kat().chestSeal)} + + + {t("profile_hemopneumothorax")} + {yesNo(kat().hemopneumothorax)} + + + {t("profile_tension_ptx")} + {yesNo(kat().tensionPneumothorax)} + + + {t("profile_internal_bleed")} + {number(kat().internalBleeding, 2)} + +
+ + )} +
+ +
+ + +
+
+ + + + + {missingSnapshotNote("staminaSnapshot", "profile_label_stamina")}
+ } + > + +
+ + + {t("profile_sprint_reserve")} + + + {t("profile_sprint_detail", { + percent: percent(sprintReserve()), + current: number(vanillaStamina().stamina, 1), + max: number(sprintSecondsMax(), 1), + })} + + + + + {t("profile_fatigue")} + {percent(vanillaStamina().fatigue)} + + + + {t("profile_carried_weight")} + {number(weightKg(), 1)} kg + + + {t("profile_load")} + {percent(loadFraction())} + +
+ + {(ace) => ( + <> +
{t("profile_ace_fatigue")}
+
+ + {t("profile_anaerobic")} + {percent(ace().anaerobicReserve)} + + + {t("profile_aerobic")} + {percent(ace().aerobicReserve)} + + + {t("profile_muscle_damage")} + {percent(ace().muscleDamage)} + + + {t("profile_performance")} + + {typeof ace().performanceFactor === "number" + ? `${ace().performanceFactor.toFixed(2)}×` + : "—"} + + +
+ + )} +
+ + + + + 0} + fallback={ +
+ {radio() + ? t("profile_no_radio") + : missingSnapshotNote("radioSnapshot", "profile_label_radio")} +
+ } + > + + + {(entry, index) => { + const key = () => radioKey(entry, index()); + return ( +
+
+ {entry.name || entry.class} + + {entry.mod} {entry.type} + {entry.additional ? ` ${t("profile_radio_additional")}` : ""} + {entry.active ? ` ${t("profile_radio_active")}` : ""} + {" · "} + {t("profile_radio_channel", { channel: entry.channel })} + +
+ + {typeof entry.frequency === "number" ? entry.frequency.toFixed(3) : "—"} MHz + + + {t("profile_radio_code", { code: entry.code || "—" })} + {" · "} + {((entry.rangeMeters || 0) / 1000).toFixed(1)} km + + {` · ${((entry.powerMilliwatts ?? 0) / 1000).toFixed(1)} W`} + + +
+ + +
+ + {t("profile_range_acre_note")} + +
+ ); + }} +
+
+
+ + + ); +} diff --git a/ui/src/pages/recording-playback/components/UnitsTab.tsx b/ui/src/pages/recording-playback/components/UnitsTab.tsx index cc3909676..a07c84f3d 100644 --- a/ui/src/pages/recording-playback/components/UnitsTab.tsx +++ b/ui/src/pages/recording-playback/components/UnitsTab.tsx @@ -1,14 +1,16 @@ import { createSignal, createMemo, createEffect, For, Show } from "solid-js"; import type { JSX, Accessor } from "solid-js"; +import { Portal } from "solid-js/web"; import type { Side } from "../../../data/types"; -import type { Unit } from "../../../playback/entities/unit"; +import { Unit } from "../../../playback/entities/unit"; import { SIDE_COLORS_UI, SIDE_BG_COLORS } from "../../../config/sideColors"; import { useEngine } from "../../../hooks/useEngine"; import { useCustomize } from "../../../hooks/useCustomize"; import { useI18n } from "../../../hooks/useLocale"; import { activeSide, setActiveSide } from "../shortcuts"; -import { CrosshairIcon, ChevronRightIcon, EyeOffIcon, EyeIcon, NavigationIcon } from "../../../components/Icons"; +import { CrosshairIcon, ChevronRightIcon } from "../../../components/Icons"; import styles from "./SidePanel.module.css"; +import { PlayerProfileCard } from "./PlayerProfileCard"; const SIDES: Side[] = ["WEST", "EAST", "GUER", "CIV"]; @@ -38,6 +40,12 @@ export function UnitsTab(props: UnitsTabProps): JSX.Element { const showKillCount = (): boolean => !customize().disableKillCount; const [expandedGroups, setExpandedGroups] = createSignal>(new Set()); const [selectedUnit, setSelectedUnit] = createSignal(null); + const selectedProfileUnit = createMemo(() => { + const id = selectedUnit(); + if (id === null) return undefined; + const entity = engine.entityManager.getEntity(id); + return entity instanceof Unit ? entity : undefined; + }); const unitsForSide = (side: Side): Unit[] => { // Access endFrame to create reactive dependency on operation load @@ -199,6 +207,10 @@ export function UnitsTab(props: UnitsTabProps): JSX.Element { [styles.unitRowDead]: status() === "dead", [styles.unitRowInactive]: status() === "inactive", }} + title={t("profile_open")} + aria-label={t("profile_open_named", { + name: unit.name || t("profile_title_unit", { id: unit.id }), + })} onClick={() => setSelectedUnit(selected() ? null : unit.id) } @@ -236,21 +248,6 @@ export function UnitsTab(props: UnitsTabProps): JSX.Element { - - - ); }} @@ -261,98 +258,25 @@ export function UnitsTab(props: UnitsTabProps): JSX.Element { }} - - ); -} - -interface UnitDetailCardProps { - unitId: number; - kills: number; - deaths: number; - markerCount: number; - isBlacklisted: boolean; - isFollowed: boolean; - isAdmin: boolean; - onToggleFollow: (unitId: number) => void; - onToggleBlacklist?: (playerEntityId: number) => void; - side: Side; - showKillCount: boolean; -} - -function UnitDetailCard(props: UnitDetailCardProps): JSX.Element { - return ( -
- {/* Stats row */} -
- -
-
0 }} - > - {props.kills} -
-
KILLS
-
-
-
0 }} - > - {props.deaths} -
-
DEATHS
-
-
-
- {(() => { - const visible = props.isBlacklisted ? 0 : props.markerCount; - return ( - <> -
0 }} - > - {visible} -
-
MARKERS
- - ); - })()} -
-
- - {/* Follow button */} -
- -
- - {/* Admin Actions */} - 0}> -
-
ADMIN ACTIONS
- -
+ + {(unit) => ( + + setSelectedUnit(null)} + onToggleFollow={toggleFollow} + onToggleBlacklist={props.onToggleBlacklist} + /> + + )} -
+ ); } diff --git a/ui/src/pages/recording-playback/loadRecording.ts b/ui/src/pages/recording-playback/loadRecording.ts index fb6abe791..f032a1c13 100644 --- a/ui/src/pages/recording-playback/loadRecording.ts +++ b/ui/src/pages/recording-playback/loadRecording.ts @@ -28,6 +28,7 @@ export async function loadRecording( // Notify caller so the renderer can be initialized before // engine.loadRecording triggers snapshot effects. onWorldResolved?.(world); + engine.setWorldConfig(world); const filename = rec.filename ?? String(rec.id); let decoder: DecoderStrategy; diff --git a/ui/src/pages/recording-playback/medical/__tests__/bodyImage.test.ts b/ui/src/pages/recording-playback/medical/__tests__/bodyImage.test.ts new file mode 100644 index 000000000..1311ef215 --- /dev/null +++ b/ui/src/pages/recording-playback/medical/__tests__/bodyImage.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import type { MedicalBodyPart } from "../../../data/types"; +import { + BLOOD_LOSS_COLORS, + DAMAGE_COLORS, + TINT, + bodyPartFillColor, + colorIndex, + overlaysForMedical, + parseBloodLoss, + rgbaCss, +} from "../bodyImage"; + +const part = (id: string, items: MedicalBodyPart["items"], damage = 0): MedicalBodyPart => ({ + part: id, + damage, + items, +}); + +describe("ACE body-image colours", () => { + it("keeps an uninjured part black so only the silhouette shows", () => { + expect(bodyPartFillColor(part("head", []))).toBe(rgbaCss(DAMAGE_COLORS[0])); + }); + + it("tints bleeding yellow-red using ACE's ceil index", () => { + expect(colorIndex(0, 0.5)).toBe(0); + expect(colorIndex(0.01, 0.5)).toBe(1); + expect(colorIndex(0.5, 0.5)).toBe(9); + const bleeding = part("leftarm", [ + { kind: "wound", name: "Medium puncture", detail: "bleeding 0.200" }, + ]); + expect(parseBloodLoss(bleeding)).toBeCloseTo(0.2); + expect(bodyPartFillColor(bleeding)).toBe( + rgbaCss(BLOOD_LOSS_COLORS[colorIndex(0.2, 0.5)]), + ); + }); + + it("falls back to the cyan-blue damage ramp when nothing is bleeding", () => { + expect(bodyPartFillColor(part("body", [], 0.4))).toBe( + rgbaCss(DAMAGE_COLORS[colorIndex(0.4, 0.8)]), + ); + }); +}); + +describe("ACE/KAT body overlays", () => { + it("shows a tourniquet band and hides it under REBOA", () => { + const withTq = overlaysForMedical([part("rightarm", [{ kind: "tourniquet", name: "Tourniquet" }])]); + expect(withTq.some((overlay) => overlay.id === "tourniquet:rightarm" && overlay.tint === TINT.tourniquet)).toBe( + true, + ); + + const blocked = overlaysForMedical([ + part("rightarm", [ + { kind: "tourniquet", name: "Tourniquet" }, + { kind: "reboa", name: "REBOA / surgical block" }, + ]), + ]); + expect(blocked.some((overlay) => overlay.id.startsWith("tourniquet:"))).toBe(false); + }); + + it("colours a fracture red and a splint blue", () => { + const broken = overlaysForMedical([part("leftleg", [{ kind: "fracture", name: "Fracture" }])]); + expect(broken).toEqual( + expect.arrayContaining([expect.objectContaining({ id: "fracture:leftleg", tint: TINT.fracture })]), + ); + const splinted = overlaysForMedical([part("leftleg", [{ kind: "splint", name: "Splint" }])]); + expect(splinted).toEqual( + expect.arrayContaining([expect.objectContaining({ id: "splint:leftleg", tint: TINT.splint })]), + ); + }); + + it("places KAT treatment icons the same way the medical menu does", () => { + const overlays = overlaysForMedical( + [ + part("body", [ + { kind: "chestSeal", name: "Chest seal" }, + { kind: "ivAccess", name: "IO access" }, + ]), + part("head", [ + { kind: "airway", name: "Larynxtubus" }, + { kind: "oxygen", name: "Nasal cannula" }, + ]), + part("leftarm", [ + { kind: "monitor", name: "Pulse oximeter" }, + { kind: "ivAccess", name: "IV access 16g" }, + ]), + ], + { pneumothorax: 2 }, + ); + + const ids = overlays.map((overlay) => overlay.id); + expect(ids).toEqual( + expect.arrayContaining(["chestSeal", "pneumothorax", "io", "kinglt", "nasal", "pulseOx:leftarm", "iv:leftarm"]), + ); + expect(ids).not.toContain("guedel"); + }); + + it("uses a Guedel tube for a non-King airway and a selection outline", () => { + const overlays = overlaysForMedical([part("head", [{ kind: "airway", name: "Guedeltubus" }])], undefined, "head"); + expect(overlays.map((overlay) => overlay.id)).toEqual(expect.arrayContaining(["guedel", "selected:head"])); + }); +}); diff --git a/ui/src/pages/recording-playback/medical/bodyAssets.ts b/ui/src/pages/recording-playback/medical/bodyAssets.ts new file mode 100644 index 000000000..e0f8ea227 --- /dev/null +++ b/ui/src/pages/recording-playback/medical/bodyAssets.ts @@ -0,0 +1,74 @@ +import background from "../../../assets/medical/body/background.png"; +import head from "../../../assets/medical/body/head.png"; +import headS from "../../../assets/medical/body/head_s.png"; +import torso from "../../../assets/medical/body/torso.png"; +import torsoS from "../../../assets/medical/body/torso_s.png"; +import armLeft from "../../../assets/medical/body/arm_left.png"; +import armLeftS from "../../../assets/medical/body/arm_left_s.png"; +import armLeftT from "../../../assets/medical/body/arm_left_t.png"; +import armLeftB from "../../../assets/medical/body/arm_left_b.png"; +import armRight from "../../../assets/medical/body/arm_right.png"; +import armRightS from "../../../assets/medical/body/arm_right_s.png"; +import armRightT from "../../../assets/medical/body/arm_right_t.png"; +import armRightB from "../../../assets/medical/body/arm_right_b.png"; +import legLeft from "../../../assets/medical/body/leg_left.png"; +import legLeftS from "../../../assets/medical/body/leg_left_s.png"; +import legLeftT from "../../../assets/medical/body/leg_left_t.png"; +import legLeftB from "../../../assets/medical/body/leg_left_b.png"; +import legRight from "../../../assets/medical/body/leg_right.png"; +import legRightS from "../../../assets/medical/body/leg_right_s.png"; +import legRightT from "../../../assets/medical/body/leg_right_t.png"; +import legRightB from "../../../assets/medical/body/leg_right_b.png"; +import torsoChestSeal from "../../../assets/medical/body/torso_chestseal.png"; +import torsoPneumothorax from "../../../assets/medical/body/torso_pneumothorax.png"; +import torsoIo from "../../../assets/medical/body/torso_io.png"; +import headGuedel from "../../../assets/medical/body/head_guedeltube.png"; +import headKingLt from "../../../assets/medical/body/head_kinglt.png"; +import headNasal from "../../../assets/medical/body/head_nasalcannula.png"; +import leftArmPulseOx from "../../../assets/medical/body/leftarm_pulseoximeter.png"; +import rightArmPulseOx from "../../../assets/medical/body/rightarm_pulseoximeter.png"; +import leftArmIv from "../../../assets/medical/body/leftarm_iv.png"; +import rightArmIv from "../../../assets/medical/body/rightarm_iv.png"; +import leftLegIv from "../../../assets/medical/body/leftleg_iv.png"; +import rightLegIv from "../../../assets/medical/body/rightleg_iv.png"; +import type { BodyPartId } from "./bodyImage"; + +export const BODY_BACKGROUND = background; + +export const BODY_PART_FILLS: Record = { + head, + body: torso, + leftarm: armLeft, + rightarm: armRight, + leftleg: legLeft, + rightleg: legRight, +}; + +export const BODY_OVERLAY_ASSETS: Record = { + head_s: headS, + torso_s: torsoS, + arm_left_s: armLeftS, + arm_left_t: armLeftT, + arm_left_b: armLeftB, + arm_right_s: armRightS, + arm_right_t: armRightT, + arm_right_b: armRightB, + leg_left_s: legLeftS, + leg_left_t: legLeftT, + leg_left_b: legLeftB, + leg_right_s: legRightS, + leg_right_t: legRightT, + leg_right_b: legRightB, + torso_chestseal: torsoChestSeal, + torso_pneumothorax: torsoPneumothorax, + torso_io: torsoIo, + head_guedeltube: headGuedel, + head_kinglt: headKingLt, + head_nasalcannula: headNasal, + leftarm_pulseoximeter: leftArmPulseOx, + rightarm_pulseoximeter: rightArmPulseOx, + leftarm_iv: leftArmIv, + rightarm_iv: rightArmIv, + leftleg_iv: leftLegIv, + rightleg_iv: rightLegIv, +}; diff --git a/ui/src/pages/recording-playback/medical/bodyImage.ts b/ui/src/pages/recording-playback/medical/bodyImage.ts new file mode 100644 index 000000000..b43a076d8 --- /dev/null +++ b/ui/src/pages/recording-playback/medical/bodyImage.ts @@ -0,0 +1,235 @@ +import type { MedicalBodyPart } from "../../../data/types"; + +export const BODY_PART_IDS = ["head", "body", "leftarm", "rightarm", "leftleg", "rightleg"] as const; +export type BodyPartId = (typeof BODY_PART_IDS)[number]; + +/** ACE `BLOOD_LOSS_RED_THRESHOLD` / `DAMAGE_BLUE_THRESHOLD`. */ +export const BLOOD_LOSS_RED_THRESHOLD = 0.5; +export const DAMAGE_BLUE_THRESHOLD = 0.8; +export const COLOR_STEPS = 10; + +/** + * ACE medical GUI palettes (`initSettings.inc.sqf`). Values are 0–1 like Arma + * `colorText[]`, including the black slot used for an uninjured part. + */ +export const BLOOD_LOSS_COLORS: ReadonlyArray = [ + [0.0, 0.0, 0.0, 1], + [1.0, 0.95, 0.64, 1], + [1.0, 0.87, 0.46, 1], + [1.0, 0.8, 0.33, 1], + [1.0, 0.72, 0.24, 1], + [1.0, 0.63, 0.15, 1], + [1.0, 0.54, 0.08, 1], + [1.0, 0.43, 0.02, 1], + [1.0, 0.3, 0.0, 1], + [1.0, 0.0, 0.0, 1], +]; + +export const DAMAGE_COLORS: ReadonlyArray = [ + [0.0, 0.0, 0.0, 1], + [0.75, 0.95, 1.0, 1], + [0.62, 0.86, 1.0, 1], + [0.54, 0.77, 1.0, 1], + [0.48, 0.67, 1.0, 1], + [0.42, 0.57, 1.0, 1], + [0.37, 0.47, 1.0, 1], + [0.31, 0.36, 1.0, 1], + [0.22, 0.23, 1.0, 1], + [0.0, 0.0, 1.0, 1], +]; + +export const TINT = { + tourniquet: "rgb(0, 0, 204)", + fracture: "rgb(255, 0, 0)", + splint: "rgb(0, 0, 255)", + selected: "rgb(255, 255, 255)", + chestSeal: "rgb(255, 242, 0)", + airway: "rgb(26, 255, 255)", + nasal: "rgb(46, 153, 245)", + pulseOx: "rgb(77, 204, 204)", + iv: "rgb(77, 153, 77)", + pneumothorax: "rgb(255, 255, 255)", +} as const; + +/** Approximate click targets on the facing A-pose figure, in percent. */ +export const HIT_REGIONS: Record = { + head: { left: 38, top: 1, width: 24, height: 15 }, + body: { left: 34, top: 15, width: 32, height: 32 }, + rightarm: { left: 14, top: 17, width: 22, height: 32 }, + leftarm: { left: 64, top: 17, width: 22, height: 32 }, + rightleg: { left: 28, top: 46, width: 22, height: 50 }, + leftleg: { left: 50, top: 46, width: 22, height: 50 }, +}; + +export interface BodyOverlay { + id: string; + asset: string; + tint: string; +} + +export function isBodyPartId(part: string): part is BodyPartId { + return (BODY_PART_IDS as readonly string[]).includes(part); +} + +export function rgbaCss(color: readonly [number, number, number, number]): string { + return `rgba(${Math.round(color[0] * 255)}, ${Math.round(color[1] * 255)}, ${Math.round(color[2] * 255)}, ${color[3]})`; +} + +/** ACE `ceil(value / threshold * (steps - 1))` so any bleeding leaves white. */ +export function colorIndex(value: number, threshold: number, steps = COLOR_STEPS): number { + const fraction = Math.max(0, Math.min(1, value / threshold)); + return Math.ceil(fraction * (steps - 1)); +} + +export function parseBloodLoss(part: MedicalBodyPart): number { + let loss = 0; + for (const item of part.items) { + if (item.kind !== "wound") continue; + const match = item.detail?.match(/bleeding\s+([0-9.]+)/i); + loss += match ? Number(match[1]) : 0.01; + } + return loss; +} + +export function bodyPartFillColor(part: MedicalBodyPart | undefined, vanillaDamage?: number): string { + if (!part) { + if (typeof vanillaDamage === "number" && vanillaDamage > 0) { + return rgbaCss(DAMAGE_COLORS[colorIndex(vanillaDamage, DAMAGE_BLUE_THRESHOLD)]); + } + return rgbaCss(DAMAGE_COLORS[0]); + } + const bloodLoss = parseBloodLoss(part); + if (bloodLoss > 0) { + return rgbaCss(BLOOD_LOSS_COLORS[colorIndex(bloodLoss, BLOOD_LOSS_RED_THRESHOLD)]); + } + const damage = part.damage ?? 0; + if (damage > 0) { + return rgbaCss(DAMAGE_COLORS[colorIndex(damage, DAMAGE_BLUE_THRESHOLD)]); + } + return rgbaCss(DAMAGE_COLORS[0]); +} + +function hasKind(part: MedicalBodyPart | undefined, kind: string): boolean { + return !!part?.items.some((item) => item.kind === kind); +} + +function itemsNamed(part: MedicalBodyPart | undefined, kind: string): string[] { + return (part?.items ?? []).filter((item) => item.kind === kind).map((item) => item.name); +} + +function limbAsset(part: BodyPartId, suffix: "t" | "b" | "s"): string | undefined { + switch (part) { + case "leftarm": + return `arm_left_${suffix}`; + case "rightarm": + return `arm_right_${suffix}`; + case "leftleg": + return `leg_left_${suffix}`; + case "rightleg": + return `leg_right_${suffix}`; + default: + return undefined; + } +} + +function ivAsset(part: BodyPartId): string | undefined { + switch (part) { + case "leftarm": + return "leftarm_iv"; + case "rightarm": + return "rightarm_iv"; + case "leftleg": + return "leftleg_iv"; + case "rightleg": + return "rightleg_iv"; + default: + return undefined; + } +} + +function pulseOxAsset(part: BodyPartId): string | undefined { + if (part === "leftarm") return "leftarm_pulseoximeter"; + if (part === "rightarm") return "rightarm_pulseoximeter"; + return undefined; +} + +function katFlag(kat: Record | undefined, key: string): boolean { + const value = kat?.[key]; + return value === true || (typeof value === "number" && value > 0); +} + +/** + * Overlays ACE and KAT toggle on `updateBodyImage`, derived from the generic + * `bodyParts` items plus KAT vitals that have no per-part item. + */ +export function overlaysForMedical( + parts: MedicalBodyPart[], + kat?: Record, + selected?: string | null, +): BodyOverlay[] { + const byPart = new Map(parts.map((part) => [part.part, part])); + const overlays: BodyOverlay[] = []; + + for (const partId of BODY_PART_IDS) { + const part = byPart.get(partId); + const bone = limbAsset(partId, "b"); + const tourniquet = limbAsset(partId, "t"); + if (bone && hasKind(part, "fracture")) { + overlays.push({ id: `fracture:${partId}`, asset: bone, tint: TINT.fracture }); + } else if (bone && hasKind(part, "splint")) { + overlays.push({ id: `splint:${partId}`, asset: bone, tint: TINT.splint }); + } + // KAT hides the ACE tourniquet icon when a surgical block / REBOA is on the limb. + if (tourniquet && hasKind(part, "tourniquet") && !hasKind(part, "reboa")) { + overlays.push({ id: `tourniquet:${partId}`, asset: tourniquet, tint: TINT.tourniquet }); + } + const iv = ivAsset(partId); + if (iv && (hasKind(part, "ivAccess") || hasKind(part, "iv"))) { + overlays.push({ id: `iv:${partId}`, asset: iv, tint: TINT.iv }); + } + const pulseOx = pulseOxAsset(partId); + if (pulseOx && hasKind(part, "monitor")) { + overlays.push({ id: `pulseOx:${partId}`, asset: pulseOx, tint: TINT.pulseOx }); + } + } + + const torso = byPart.get("body"); + const head = byPart.get("head"); + if (hasKind(torso, "chestSeal") || katFlag(kat, "chestSeal")) { + overlays.push({ id: "chestSeal", asset: "torso_chestseal", tint: TINT.chestSeal }); + } + if ( + katFlag(kat, "pneumothorax") || + katFlag(kat, "hemopneumothorax") || + katFlag(kat, "tensionPneumothorax") + ) { + overlays.push({ id: "pneumothorax", asset: "torso_pneumothorax", tint: TINT.pneumothorax }); + } + if (hasKind(torso, "ivAccess") || hasKind(torso, "iv")) { + overlays.push({ id: "io", asset: "torso_io", tint: TINT.iv }); + } + + const airwayNames = itemsNamed(head, "airway").join(" ").toLowerCase(); + if (airwayNames.includes("larynxtubus") || airwayNames.includes("king")) { + overlays.push({ id: "kinglt", asset: "head_kinglt", tint: TINT.airway }); + } else if (airwayNames && !airwayNames.includes("head-tilt") && !airwayNames.includes("chin-lift")) { + overlays.push({ id: "guedel", asset: "head_guedeltube", tint: TINT.airway }); + } + if (hasKind(head, "oxygen") || itemsNamed(head, "oxygen").length) { + overlays.push({ id: "nasal", asset: "head_nasalcannula", tint: TINT.nasal }); + } + + if (selected && isBodyPartId(selected)) { + const selectedAsset = + selected === "head" + ? "head_s" + : selected === "body" + ? "torso_s" + : limbAsset(selected, "s"); + if (selectedAsset) { + overlays.push({ id: `selected:${selected}`, asset: selectedAsset, tint: TINT.selected }); + } + } + + return overlays; +} diff --git a/ui/src/playback/__tests__/engine.test.ts b/ui/src/playback/__tests__/engine.test.ts index 1bfc78d92..a5c286400 100644 --- a/ui/src/playback/__tests__/engine.test.ts +++ b/ui/src/playback/__tests__/engine.test.ts @@ -124,6 +124,72 @@ describe("PlaybackEngine", () => { expect(engine.eventManager.getAll()).toHaveLength(2); }); + it("uses stamped TFAR settings and skips tfarSettings as a timeline event", () => { + const manifest = makeManifest({ + radioPropagation: { + terrainInterceptionCoefficient: 12, + globalRadioRangeCoef: 0.5, + source: "cba", + }, + events: [ + { + frameNum: 1, + type: "tfarSettings", + payload: { + terrainInterceptionCoefficient: 12, + globalRadioRangeCoef: 0.5, + source: "cba", + }, + }, + ], + }); + const cm = makeMockChunkManager(); + engine.loadRecording(manifest, cm); + + expect(engine.radioPropagation.terrainInterceptionCoefficient).toBe(12); + expect(engine.radioPropagation.globalRadioRangeCoef).toBe(0.5); + expect(engine.eventManager.getAll()).toHaveLength(0); + }); + + it("falls back to TFAR defaults when a recording has no settings event", () => { + engine.loadRecording(makeManifest(), makeMockChunkManager()); + expect(engine.radioPropagation.terrainInterceptionCoefficient).toBe(7); + expect(engine.radioPropagation.globalRadioRangeCoef).toBe(1); + }); + + it("uses stamped ACRE settings and skips acreSettings as a timeline event", () => { + const manifest = makeManifest({ + acrePropagation: { + terrainLoss: 0.4, + signalModel: 1, + source: "cba", + }, + events: [ + { + frameNum: 1, + type: "acreSettings", + payload: { + terrainLoss: 0.4, + signalModel: 1, + source: "cba", + }, + }, + ], + }); + const cm = makeMockChunkManager(); + engine.loadRecording(manifest, cm); + + expect(engine.acrePropagation.terrainLoss).toBe(0.4); + expect(engine.acrePropagation.signalModel).toBe(1); + expect(engine.eventManager.getAll()).toHaveLength(0); + }); + + it("falls back to ACRE defaults when a recording has no acreSettings event", () => { + engine.loadRecording(makeManifest(), makeMockChunkManager()); + expect(engine.acrePropagation.terrainLoss).toBe(1); + expect(engine.acrePropagation.signalModel).toBe(2); + }); + it("sets endFrame from manifest endFrame", () => { const manifest = makeManifest({ endFrame: 499 }); const cm = makeMockChunkManager(); diff --git a/ui/src/playback/__tests__/eventManager.test.ts b/ui/src/playback/__tests__/eventManager.test.ts index 0d2d7b362..68adaadcc 100644 --- a/ui/src/playback/__tests__/eventManager.test.ts +++ b/ui/src/playback/__tests__/eventManager.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import { GameEvent } from "../events/gameEvent"; import { HitKilledEvent } from "../events/hitKilledEvent"; import { ConnectEvent } from "../events/connectEvent"; +import { PlayerSnapshotEvent } from "../events/playerSnapshotEvent"; import { getCounterStateAtFrame, type CounterState, @@ -364,6 +365,133 @@ describe("EventManager", () => { }); }); + describe("player snapshots", () => { + it("returns the latest snapshot at or before the playback frame", () => { + const first = new PlayerSnapshotEvent(10, "inventorySnapshot", 1, { + unitId: 7, + playerUid: "uid", + reason: "periodic", + weightKg: 12, + load: 0.2, + uniform: { class: "", name: "", items: [] }, + vest: { class: "", name: "", items: [] }, + backpack: { class: "", name: "", items: [] }, + headgear: { class: "", name: "" }, + goggles: { class: "", name: "" }, + weapons: [], + magazines: [], + assignedItems: [], + }); + const second = new PlayerSnapshotEvent(20, "inventorySnapshot", 2, { + ...first.payload, + weightKg: 18, + }); + + mgr.addEvent(first); + mgr.addEvent(second); + + expect(mgr.getPlayerSnapshots(7, 9).size).toBe(0); + expect(mgr.getPlayerSnapshots(7, 10).get("inventorySnapshot")).toBe(first); + expect(mgr.getPlayerSnapshots(7, 99).get("inventorySnapshot")).toBe(second); + expect(mgr.getAll()).toEqual([]); + }); + + it("reports the frame of the first snapshot of a type", () => { + mgr.addEvent(new PlayerSnapshotEvent(30, "radioSnapshot", 1, { unitId: 7, radios: [] })); + mgr.addEvent(new PlayerSnapshotEvent(10, "radioSnapshot", 2, { unitId: 7, radios: [] })); + mgr.reconstructPlayerSnapshots(); + + expect(mgr.getFirstPlayerSnapshotFrame(7, "radioSnapshot")).toBe(10); + expect(mgr.getFirstPlayerSnapshotFrame(7, "medicalSnapshot")).toBeUndefined(); + expect(mgr.getFirstPlayerSnapshotFrame(99, "radioSnapshot")).toBeUndefined(); + }); + + it("rebuilds diff-encoded snapshots into full ones", () => { + mgr.addEvent(new PlayerSnapshotEvent(10, "medicalSnapshot", 1, { + unitId: 7, + playerUid: "uid", + vanilla: { damage: 0 }, + ace: { heartRate: 80, pain: 0 }, + })); + mgr.addEvent(new PlayerSnapshotEvent(20, "medicalSnapshot", 2, { + unitId: 7, + diffOf: 10, + set: { ace: { heartRate: 130 } }, + })); + mgr.addEvent(new PlayerSnapshotEvent(30, "medicalSnapshot", 3, { + unitId: 7, + diffOf: 20, + set: { ace: { pain: 0.5 } }, + unset: ["vanilla.damage"], + })); + + mgr.reconstructPlayerSnapshots(); + + const atTwenty = mgr.getPlayerSnapshots(7, 20).get("medicalSnapshot")!; + expect(atTwenty.isDiff).toBe(true); + expect(atTwenty.payload).toEqual({ + unitId: 7, + playerUid: "uid", + vanilla: { damage: 0 }, + ace: { heartRate: 130, pain: 0 }, + }); + + const atThirty = mgr.getPlayerSnapshots(7, 30).get("medicalSnapshot")!; + expect(atThirty.payload).toEqual({ + unitId: 7, + playerUid: "uid", + vanilla: {}, + ace: { heartRate: 130, pain: 0.5 }, + }); + }); + + it("rebuilds diffs that arrived out of order", () => { + const diff = new PlayerSnapshotEvent(20, "staminaSnapshot", 2, { + unitId: 7, + diffOf: 10, + set: { vanilla: { load: 0.6 } }, + }); + const full = new PlayerSnapshotEvent(10, "staminaSnapshot", 1, { + unitId: 7, + vanilla: { load: 0.2, fatigue: 0 }, + }); + + mgr.addEvent(diff); + mgr.addEvent(full); + mgr.reconstructPlayerSnapshots(); + + expect(diff.payload).toEqual({ unitId: 7, vanilla: { load: 0.6, fatigue: 0 } }); + }); + + it("keeps a diff usable when its base snapshot is missing", () => { + const orphan = new PlayerSnapshotEvent(20, "radioSnapshot", 1, { + unitId: 7, + diffOf: 10, + set: { radios: [{ class: "TFAR_anprc152_1", name: "AN/PRC-152" }] }, + }); + + mgr.addEvent(orphan); + mgr.reconstructPlayerSnapshots(); + + expect(orphan.payload).toEqual({ + unitId: 7, + radios: [{ class: "TFAR_anprc152_1", name: "AN/PRC-152" }], + }); + }); + + it("removes snapshot indexes on clear", () => { + mgr.addEvent(new PlayerSnapshotEvent(10, "radioSnapshot", 1, { + unitId: 7, + playerUid: "uid", + radios: [], + })); + + mgr.clear(); + + expect(mgr.getPlayerSnapshots(7, 10).size).toBe(0); + }); + }); + describe("getActiveEvents", () => { it("returns all events where frameNum <= given frame", () => { mgr.addEvent(new GameEvent(10, "hit", 1)); diff --git a/ui/src/playback/engine.ts b/ui/src/playback/engine.ts index edb485da3..b8f1a22ec 100644 --- a/ui/src/playback/engine.ts +++ b/ui/src/playback/engine.ts @@ -1,6 +1,12 @@ import { createSignal, type Accessor } from "solid-js"; -import type { Manifest, EventDef } from "../data/types"; +import type { Manifest, EventDef, WorldConfig } from "../data/types"; +import { + resolveAcrePropagation, + resolveTfarPropagation, + type AcreRadioPropagation, + type TfarRadioPropagation, +} from "../data/radioPropagation"; import type { TimeConfig } from "./time"; import type { ChunkManager } from "../data/chunkManager"; import type { MapRenderer } from "../renderers/renderer.interface"; @@ -15,6 +21,7 @@ import { EndMissionEvent } from "./events/endMissionEvent"; import { GeneralMissionEvent } from "./events/generalEvent"; import { CapturedEvent } from "./events/capturedEvent"; import { TerminalHackEvent } from "./events/terminalHackEvent"; +import { PlayerSnapshotEvent } from "./events/playerSnapshotEvent"; import { Unit } from "./entities/unit"; import { Vehicle } from "./entities/vehicle"; @@ -55,6 +62,14 @@ function createGameEvent(def: EventDef): GameEvent | null { case "counterSet": // Counter events are handled separately via CounterState return null; + case "inventorySnapshot": + case "medicalSnapshot": + case "staminaSnapshot": + case "radioSnapshot": + return new PlayerSnapshotEvent(def.frameNum, def.type, id, def.payload); + case "tfarSettings": + case "acreSettings": + return null; default: return null; } @@ -79,6 +94,7 @@ export class PlaybackEngine { private renderer: MapRenderer; private chunkManager: ChunkManager | null = null; private manifest: Manifest | null = null; + private _worldConfig: WorldConfig | undefined; // ─── Signals (reactive state) ─── private _currentFrame: Accessor; @@ -188,6 +204,18 @@ export class PlaybackEngine { get captureDelayMs(): Accessor { return this._captureDelayMs; } + get worldConfig(): WorldConfig | undefined { + return this._worldConfig; + } + setWorldConfig(world: WorldConfig | undefined): void { + this._worldConfig = world; + } + get radioPropagation(): TfarRadioPropagation { + return resolveTfarPropagation(this.manifest?.radioPropagation); + } + get acrePropagation(): AcreRadioPropagation { + return resolveAcrePropagation(this.manifest?.acrePropagation); + } get timeConfig(): TimeConfig { const times = this.manifest?.times; // Extract mission date and time multiplier from the first time sample @@ -325,6 +353,9 @@ export class PlaybackEngine { } } + // Fold diff-encoded player snapshots back into full snapshots + this.eventManager.reconstructPlayerSnapshots(); + // Resolve entity references on hit/killed events this.eventManager.resolveReferences(this.entityManager); @@ -347,6 +378,7 @@ export class PlaybackEngine { this.eventManager.clear(); this.chunkManager = null; this.manifest = null; + this._worldConfig = undefined; } // ─── Playback loop ─── diff --git a/ui/src/playback/eventManager.ts b/ui/src/playback/eventManager.ts index 84a011b04..5e3794381 100644 --- a/ui/src/playback/eventManager.ts +++ b/ui/src/playback/eventManager.ts @@ -3,6 +3,9 @@ import { Unit } from "./entities/unit"; import { Vehicle } from "./entities/vehicle"; import { GameEvent } from "./events/gameEvent"; import { HitKilledEvent } from "./events/hitKilledEvent"; +import { PlayerSnapshotEvent } from "./events/playerSnapshotEvent"; +import { applySnapshotDiff, isSnapshotDiff } from "../data/snapshotDiff"; +import type { PlayerSnapshotPayload, PlayerSnapshotType } from "../data/types"; /** * Manages all mission events for a playback session. @@ -12,9 +15,22 @@ import { HitKilledEvent } from "./events/hitKilledEvent"; export class EventManager { private events: GameEvent[] = []; private frameIndex: Map = new Map(); + private playerSnapshots = new Map>(); /** Add an event and index it by frame number. */ addEvent(event: GameEvent): void { + if (event instanceof PlayerSnapshotEvent) { + let unitSnapshots = this.playerSnapshots.get(event.unitId); + if (!unitSnapshots) { + unitSnapshots = new Map(); + this.playerSnapshots.set(event.unitId, unitSnapshots); + } + const type = event.type as PlayerSnapshotType; + const series = unitSnapshots.get(type); + if (series) series.push(event); + else unitSnapshots.set(type, [event]); + return; + } this.events.push(event); const existing = this.frameIndex.get(event.frameNum); @@ -42,6 +58,51 @@ export class EventManager { return this.events; } + /** + * Rebuild every player snapshot that was recorded as a diff into a full snapshot. + * Must run once after all events are added and before the card reads them. + */ + reconstructPlayerSnapshots(): void { + for (const byType of this.playerSnapshots.values()) { + for (const series of byType.values()) { + series.sort((a, b) => a.frameNum - b.frameNum); + let previous: PlayerSnapshotPayload | undefined; + for (const event of series) { + if (isSnapshotDiff(event.raw)) { + // Without a base the keyframe was lost, so the diff is all we know. + event.payload = previous + ? applySnapshotDiff(previous, event.raw) + : ({ unitId: event.raw.unitId, ...event.raw.set } as PlayerSnapshotPayload); + } else { + event.payload = event.raw; + } + previous = event.payload; + } + } + } + } + + /** Frame of a player's first snapshot of a type, so the UI can explain an empty card. */ + getFirstPlayerSnapshotFrame(unitId: number, type: PlayerSnapshotType): number | undefined { + return this.playerSnapshots.get(unitId)?.get(type)?.[0]?.frameNum; + } + + /** Latest snapshot of each requested type for a player at the given frame. */ + getPlayerSnapshots(unitId: number, frame: number): Map { + const result = new Map(); + const unitSnapshots = this.playerSnapshots.get(unitId); + if (!unitSnapshots) return result; + for (const [type, series] of unitSnapshots) { + for (let i = series.length - 1; i >= 0; i--) { + if (series[i].frameNum <= frame) { + result.set(type, series[i]); + break; + } + } + } + return result; + } + /** * Resolve entity references on HitKilledEvent instances. * Populates names, sides, and computes kill counts. @@ -150,5 +211,6 @@ export class EventManager { clear(): void { this.events = []; this.frameIndex = new Map(); + this.playerSnapshots = new Map(); } } diff --git a/ui/src/playback/events/playerSnapshotEvent.ts b/ui/src/playback/events/playerSnapshotEvent.ts new file mode 100644 index 000000000..9f12b69c9 --- /dev/null +++ b/ui/src/playback/events/playerSnapshotEvent.ts @@ -0,0 +1,34 @@ +import type { + PlayerSnapshotPayload, + PlayerSnapshotType, + RawPlayerSnapshot, +} from "../../data/types"; +import { isSnapshotDiff } from "../../data/snapshotDiff"; +import { GameEvent } from "./gameEvent"; + +export class PlayerSnapshotEvent extends GameEvent { + /** + * Full snapshot for this frame. Equal to `raw` unless the recording stored a + * diff, in which case EventManager rebuilds it from the preceding snapshots. + */ + payload: PlayerSnapshotPayload; + + constructor( + frameNum: number, + type: PlayerSnapshotType, + id: number, + /** Payload exactly as recorded: either a full snapshot or a diff. */ + public readonly raw: RawPlayerSnapshot, + ) { + super(frameNum, type, id); + this.payload = raw as PlayerSnapshotPayload; + } + + get unitId(): number { + return this.raw.unitId; + } + + get isDiff(): boolean { + return isSnapshotDiff(this.raw); + } +} diff --git a/ui/src/playback/radioRange/__tests__/acreCoverage.test.ts b/ui/src/playback/radioRange/__tests__/acreCoverage.test.ts new file mode 100644 index 000000000..53bcf2d4f --- /dev/null +++ b/ui/src/playback/radioRange/__tests__/acreCoverage.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from "vitest"; +import { + ACRE_DEFAULT_PROPAGATION, + ACRE_SIGNAL_MODEL_ARCADE, + TFAR_DEFAULT_PROPAGATION, +} from "../../../data/radioPropagation"; +import { makeFlatDem, type DemGrid } from "../demGrid"; +import { inTfarRange, marchCoverage } from "../tfarCoverage"; +import { inAcreRange, marchAcreCoverage, acreDiffractionLoss } from "../acreCoverage"; + +function makeRidgeDem( + worldSize: number, + ridgeX: number, + height: number, + cellSize = 25, +): DemGrid { + const cols = Math.ceil(worldSize / cellSize) + 1; + const rows = cols; + const data = new Float32Array(cols * rows); + const ridgeCol = Math.round(ridgeX / cellSize); + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + if (c === ridgeCol) data[r * cols + c] = height; + } + } + return { cols, rows, originX: 0, originY: 0, cellSize, worldSize, data }; +} + +function makeHorizontalRidgeDem( + worldSize: number, + ridgeY: number, + height: number, + cellSize = 25, +): DemGrid { + const cols = Math.ceil(worldSize / cellSize) + 1; + const rows = cols; + const data = new Float32Array(cols * rows); + const ridgeRow = Math.round(ridgeY / cellSize); + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + if (r === ridgeRow) data[r * cols + c] = height; + } + } + return { cols, rows, originX: 0, originY: 0, cellSize, worldSize, data }; +} + +describe("inAcreRange", () => { + it("keeps a 5 km radio in range at 3 km on flat ground", () => { + const dem = makeFlatDem(8000, 0, 50); + expect( + inAcreRange(dem, 0, 4000, 1.6, 3000, 4000, 1.6, 5000, ACRE_DEFAULT_PROPAGATION, 60), + ).toBe(true); + }); + + it("drops the same radio at 3 km behind a 100 m ridge", () => { + const dem = makeRidgeDem(8000, 1500, 100); + expect( + inAcreRange(dem, 0, 4000, 1.6, 3000, 4000, 1.6, 5000, ACRE_DEFAULT_PROPAGATION, 60), + ).toBe(false); + }); + + it("stays in range behind that ridge when terrainLoss is 0", () => { + const dem = makeRidgeDem(8000, 1500, 100); + expect( + inAcreRange( + dem, + 0, + 4000, + 1.6, + 3000, + 4000, + 1.6, + 5000, + { ...ACRE_DEFAULT_PROPAGATION, terrainLoss: 0 }, + 60, + ), + ).toBe(true); + }); + + it("stays in range behind that ridge in arcade mode even with terrainLoss 1", () => { + const dem = makeRidgeDem(8000, 1500, 100); + expect( + inAcreRange( + dem, + 0, + 4000, + 1.6, + 3000, + 4000, + 1.6, + 5000, + { ...ACRE_DEFAULT_PROPAGATION, signalModel: ACRE_SIGNAL_MODEL_ARCADE }, + 60, + ), + ).toBe(true); + }); + + it("does not reuse TFAR's coefficient for the same ridge", () => { + const dem = makeRidgeDem(8000, 1500, 100); + const tfarInRange = inTfarRange( + dem, + 0, + 4000, + 1.6, + 3000, + 4000, + 1.6, + 5000, + TFAR_DEFAULT_PROPAGATION, + ); + const acreInRange = inAcreRange( + dem, + 0, + 4000, + 1.6, + 3000, + 4000, + 1.6, + 5000, + ACRE_DEFAULT_PROPAGATION, + 60, + ); + expect(tfarInRange).toBe(true); + expect(acreInRange).toBe(false); + }); + + it("does not treat bilinear noise as knife edges on raised flat ground", () => { + const dem = makeFlatDem(8000, 20, 50); + expect(acreDiffractionLoss(dem, 1000, 1000, 21.6, 6000, 5000, 21.6, 60)).toBe(0); + }); +}); + +describe("marchAcreCoverage", () => { + it("is a circle of rangeMeters on a flat DEM", () => { + const dem = makeFlatDem(12000, 20, 50); + const verts = marchAcreCoverage(dem, [4000, 4000], { + rangeMeters: 5000, + propagation: ACRE_DEFAULT_PROPAGATION, + frequencyMHz: 60, + bearings: 16, + }); + expect(verts).toHaveLength(16); + for (const [x, y] of verts) { + expect(Math.hypot(x - 4000, y - 4000)).toBeCloseTo(5000, -1); + } + }); + + it("stays a circle when terrainLoss is 0 even if there is a ridge", () => { + const dem = makeRidgeDem(12000, 6000, 180); + const verts = marchAcreCoverage(dem, [4000, 6000], { + rangeMeters: 3000, + propagation: { ...ACRE_DEFAULT_PROPAGATION, terrainLoss: 0 }, + frequencyMHz: 60, + bearings: 12, + }); + for (const [x, y] of verts) { + expect(Math.hypot(x - 4000, y - 6000)).toBeCloseTo(3000, -1); + } + }); + + it("shortens farther than TFAR behind a 100 m ridge", () => { + const dem = makeRidgeDem(12000, 5500, 100); + const origin: [number, number] = [4000, 6000]; + const acre = marchAcreCoverage(dem, origin, { + rangeMeters: 5000, + propagation: ACRE_DEFAULT_PROPAGATION, + frequencyMHz: 60, + bearings: 16, + }); + const tfar = marchCoverage(dem, origin, { + rangeMeters: 5000, + propagation: TFAR_DEFAULT_PROPAGATION, + bearings: 16, + }); + const acreEast = acre.reduce((best, v) => (v[0] > best[0] ? v : best)); + const tfarEast = tfar.reduce((best, v) => (v[0] > best[0] ? v : best)); + expect(acreEast[0] - 4000).toBeLessThan(tfarEast[0] - 4000); + expect(acreEast[0] - 4000).toBeLessThan(2000); + }); + + it("shifts ACRE coverage onto an off-map radio", () => { + const dem = makeFlatDem(8000, 20, 50); + const origin: [number, number] = [4000, -900]; + const verts = marchAcreCoverage(dem, origin, { + rangeMeters: 5000, + propagation: ACRE_DEFAULT_PROPAGATION, + frequencyMHz: 60, + bearings: 16, + }); + expect(verts).toHaveLength(16); + for (const [x, y] of verts) { + expect(Math.hypot(x - origin[0], y - origin[1])).toBeCloseTo(5000, -1); + } + }); + + it("keeps inland terrain when the radio is south of the map and shifts the polygon", () => { + const dem = makeHorizontalRidgeDem(12000, 400, 120); + const origin: [number, number, number] = [6000, -900, 400]; + const verts = marchAcreCoverage(dem, origin, { + rangeMeters: 5000, + propagation: ACRE_DEFAULT_PROPAGATION, + frequencyMHz: 60, + bearings: 16, + }); + const north = verts.reduce((best, v) => (v[1] > best[1] ? v : best)); + const south = verts.reduce((best, v) => (v[1] < best[1] ? v : best)); + expect(north[1] - origin[1]).toBeLessThan(origin[1] - south[1]); + expect(origin[1] - south[1]).toBeGreaterThan(4500); + }); +}); diff --git a/ui/src/playback/radioRange/__tests__/demGrid.test.ts b/ui/src/playback/radioRange/__tests__/demGrid.test.ts new file mode 100644 index 000000000..4bc08944f --- /dev/null +++ b/ui/src/playback/radioRange/__tests__/demGrid.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { + decodeDemBinary, + encodeDemBinary, + makeFlatDem, + nearestOnDem, + projectCoverageOrigin, + sampleDem, + worldHasElevation, +} from "../demGrid"; + +describe("dem.bin roundtrip", () => { + it("encodes and decodes heights to a tenth of a metre", () => { + const grid = makeFlatDem(1000, 42.5, 100); + grid.data[3] = -12.3; + const back = decodeDemBinary(encodeDemBinary(grid)); + expect(back.cols).toBe(grid.cols); + expect(back.rows).toBe(grid.rows); + expect(back.cellSize).toBe(grid.cellSize); + expect(back.worldSize).toBe(1000); + expect(back.data[0]).toBeCloseTo(42.5, 1); + expect(back.data[3]).toBeCloseTo(-12.3, 1); + }); + + it("rejects a truncated buffer", () => { + expect(() => decodeDemBinary(new Uint8Array(8))).toThrow(/too small/); + }); +}); + +describe("sampleDem", () => { + it("bilinearly interpolates between cells", () => { + const grid = makeFlatDem(100, 0, 50); + grid.data[0] = 0; + grid.data[1] = 10; + grid.data[grid.cols] = 20; + grid.data[grid.cols + 1] = 30; + expect(sampleDem(grid, 0, 0)).toBeCloseTo(0); + expect(sampleDem(grid, 50, 0)).toBeCloseTo(10); + expect(sampleDem(grid, 25, 0)).toBeCloseTo(5); + }); +}); + +describe("worldHasElevation", () => { + it("requires a tile URL or world name, and a world size", () => { + expect(worldHasElevation({ tileBaseUrl: "/maps/altis", worldSize: 30720 })).toBe(true); + expect(worldHasElevation({ worldName: "brf_sumava", worldSize: 12288 })).toBe(true); + expect(worldHasElevation({ worldSize: 30720 })).toBe(false); + expect(worldHasElevation({ tileBaseUrl: "/maps/altis", worldSize: 0 })).toBe(false); + }); + + it("is false when the map has neither a DEM nor a heightmap", () => { + expect( + worldHasElevation({ + tileBaseUrl: "/maps/altis", + worldSize: 30720, + hasDem: false, + hasHeightmap: false, + }), + ).toBe(false); + }); + + it("still tries a local DEM when the CDN map has no elevation", () => { + expect( + worldHasElevation({ + worldName: "brf_sumava", + worldSize: 12288, + tileBaseUrl: "https://maps.ocap2.com/brf_sumava", + hasDem: false, + hasHeightmap: false, + }), + ).toBe(true); + }); +}); + +describe("nearestOnDem", () => { + it("clamps off-map positions onto the DEM rectangle", () => { + const grid = makeFlatDem(1000, 0, 100); + expect(nearestOnDem(grid, 400, 400)).toEqual([400, 400]); + expect(nearestOnDem(grid, 400, -900)).toEqual([400, 0]); + expect(nearestOnDem(grid, 2000, 2000)).toEqual([1000, 1000]); + }); + + it("records the shift from an off-map radio back onto the edge", () => { + const grid = makeFlatDem(12288, 200, 48); + const projected = projectCoverageOrigin(grid, [8492, -904, 221]); + expect(projected.x).toBeCloseTo(8492); + expect(projected.y).toBe(0); + expect(projected.shiftY).toBeCloseTo(-904); + expect(projected.offDem).toBe(true); + }); +}); diff --git a/ui/src/playback/radioRange/__tests__/tfarCoverage.test.ts b/ui/src/playback/radioRange/__tests__/tfarCoverage.test.ts new file mode 100644 index 000000000..90d3a8a7d --- /dev/null +++ b/ui/src/playback/radioRange/__tests__/tfarCoverage.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "vitest"; +import { + TFAR_DEFAULT_PROPAGATION, + TFAR_T_MAX_METERS, +} from "../../../data/radioPropagation"; +import { makeFlatDem, type DemGrid } from "../demGrid"; +import { + calcTerrainInterception, + effectiveDistance, + inTfarRange, + marchCoverage, +} from "../tfarCoverage"; + +function makeHorizontalRidgeDem( + worldSize: number, + ridgeY: number, + height: number, + cellSize = 25, +): DemGrid { + const cols = Math.ceil(worldSize / cellSize) + 1; + const rows = cols; + const data = new Float32Array(cols * rows); + const ridgeRow = Math.round(ridgeY / cellSize); + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + if (Math.abs(r - ridgeRow) <= 1) data[r * cols + c] = height; + } + } + return { cols, rows, originX: 0, originY: 0, cellSize, worldSize, data }; +} + +function makeRidgeDem( + worldSize: number, + ridgeX: number, + height: number, + cellSize = 25, +): DemGrid { + const cols = Math.ceil(worldSize / cellSize) + 1; + const rows = cols; + const data = new Float32Array(cols * rows); + const ridgeCol = Math.round(ridgeX / cellSize); + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + if (Math.abs(c - ridgeCol) <= 1) data[r * cols + c] = height; + } + } + return { cols, rows, originX: 0, originY: 0, cellSize, worldSize, data }; +} + +describe("effectiveDistance", () => { + it("matches the TFAR worked example for a 100 m ridge at 3 km", () => { + expect(effectiveDistance(3000, 100, TFAR_DEFAULT_PROPAGATION)).toBe(4750); + expect(effectiveDistance(3500, 100, TFAR_DEFAULT_PROPAGATION)).toBe(5425); + }); + + it("ignores terrain when the coefficient is 0", () => { + const flat = { ...TFAR_DEFAULT_PROPAGATION, terrainInterceptionCoefficient: 0 }; + expect(effectiveDistance(4000, 250, flat)).toBe(4000); + }); + + it("grows range when TFAR_globalRadioRangeCoef is above 1", () => { + const boosted = { ...TFAR_DEFAULT_PROPAGATION, globalRadioRangeCoef: 2 }; + expect(effectiveDistance(4000, 0, boosted)).toBe(2000); + }); +}); + +describe("calcTerrainInterception", () => { + it("is 0 on flat ground", () => { + const dem = makeFlatDem(8000, 0, 50); + expect(calcTerrainInterception(dem, 100, 100, 1.6, 3100, 100, 1.6)).toBe(0); + }); + + it("finds about 100 m of extra midpoint height for a 100 m ridge", () => { + const dem = makeRidgeDem(8000, 1500, 100); + const t = calcTerrainInterception(dem, 0, 4000, 1.6, 3000, 4000, 1.6); + expect(t).toBeGreaterThan(80); + expect(t).toBeLessThan(130); + }); + + it("caps T at 250 m even when the wall is 400 m", () => { + const dem = makeRidgeDem(8000, 1500, 400); + const t = calcTerrainInterception(dem, 0, 4000, 1.6, 3000, 4000, 1.6); + expect(t).toBeGreaterThan(TFAR_T_MAX_METERS - 15); + expect(t).toBeLessThanOrEqual(TFAR_T_MAX_METERS); + }); +}); + +describe("inTfarRange", () => { + it("keeps a 5 km radio in range at 3 km behind a 100 m ridge", () => { + const dem = makeRidgeDem(8000, 1500, 100); + expect( + inTfarRange(dem, 0, 4000, 1.6, 3000, 4000, 1.6, 5000, TFAR_DEFAULT_PROPAGATION), + ).toBe(true); + }); + + it("drops the same radio at 3.5 km behind that ridge", () => { + const dem = makeRidgeDem(8000, 1500, 100); + expect( + inTfarRange(dem, 0, 4000, 1.6, 3500, 4000, 1.6, 5000, TFAR_DEFAULT_PROPAGATION), + ).toBe(false); + }); +}); + +describe("marchCoverage", () => { + it("is a circle of rangeMeters on a flat DEM", () => { + const dem = makeFlatDem(12000, 0, 50); + const verts = marchCoverage(dem, [4000, 4000], { + rangeMeters: 5000, + propagation: TFAR_DEFAULT_PROPAGATION, + bearings: 16, + }); + expect(verts).toHaveLength(16); + for (const [x, y] of verts) { + expect(Math.hypot(x - 4000, y - 4000)).toBeCloseTo(5000, -1); + } + }); + + it("stays a circle when C is 0 even if there is a ridge", () => { + const dem = makeRidgeDem(12000, 6000, 180); + const verts = marchCoverage(dem, [4000, 6000], { + rangeMeters: 3000, + propagation: { ...TFAR_DEFAULT_PROPAGATION, terrainInterceptionCoefficient: 0 }, + bearings: 12, + }); + for (const [x, y] of verts) { + expect(Math.hypot(x - 4000, y - 6000)).toBeCloseTo(3000, -1); + } + }); + + it("shortens the far side of a 5 km radio behind a 100 m ridge", () => { + const dem = makeRidgeDem(12000, 5500, 100); + const verts = marchCoverage(dem, [4000, 6000], { + rangeMeters: 5000, + propagation: TFAR_DEFAULT_PROPAGATION, + bearings: 16, + }); + const east = verts.reduce((best, v) => (v[0] > best[0] ? v : best)); + const west = verts.reduce((best, v) => (v[0] < best[0] ? v : best)); + expect(east[0] - 4000).toBeLessThan(4000); + expect(4000 - west[0]).toBeGreaterThan(4500); + }); + + it("shifts coverage from the nearest on-map point onto an off-map radio", () => { + const dem = makeFlatDem(8000, 20, 50); + const origin: [number, number] = [4000, -900]; + const verts = marchCoverage(dem, origin, { + rangeMeters: 5000, + propagation: TFAR_DEFAULT_PROPAGATION, + bearings: 16, + }); + expect(verts).toHaveLength(16); + for (const [x, y] of verts) { + expect(Math.hypot(x - origin[0], y - origin[1])).toBeCloseTo(5000, -1); + } + }); + + it("keeps inland terrain when the radio is south of the map and shifts the polygon", () => { + const dem = makeHorizontalRidgeDem(12000, 400, 120); + const origin: [number, number, number] = [6000, -900, 400]; + const verts = marchCoverage(dem, origin, { + rangeMeters: 5000, + propagation: TFAR_DEFAULT_PROPAGATION, + bearings: 16, + }); + const north = verts.reduce((best, v) => (v[1] > best[1] ? v : best)); + const south = verts.reduce((best, v) => (v[1] < best[1] ? v : best)); + expect(north[1] - origin[1]).toBeLessThan(origin[1] - south[1]); + expect(origin[1] - south[1]).toBeGreaterThan(4500); + }); +}); diff --git a/ui/src/playback/radioRange/acreCoverage.ts b/ui/src/playback/radioRange/acreCoverage.ts new file mode 100644 index 000000000..a574b3347 --- /dev/null +++ b/ui/src/playback/radioRange/acreCoverage.ts @@ -0,0 +1,175 @@ +import { + ACRE_DEFAULT_FREQUENCY_MHZ, + ACRE_PROFILE_STEP_METERS, + TFAR_EYE_HEIGHT_METERS, + acreAppliesTerrain, + type AcreRadioPropagation, +} from "../../data/radioPropagation"; +import { sampleDem, projectCoverageOrigin, shiftCoveragePoints, type DemGrid } from "./demGrid"; + +export interface AcreCoverageOptions { + rangeMeters: number; + propagation: AcreRadioPropagation; + /** TX frequency in MHz. ITU first-Fresnel radius depends on it. */ + frequencyMHz?: number; + /** Extra height added to DEM samples (forest canopy). Default 0. */ + canopyMeters?: number; + eyeHeightMeters?: number; + bearings?: number; +} + +const DEFAULT_BEARINGS = 64; +/** Local-max prominence before a sample counts as an ITU knife-edge. */ +const ACRE_PEAK_PROMINENCE_METERS = 0.5; + +function heightAt(grid: DemGrid, x: number, y: number, canopy: number): number { + return sampleDem(grid, x, y) + canopy; +} + +/** + * ITU-R P.526 single knife-edge, matching ACRE `los_simple::itu`. + * `h` is ray height minus terrain at the peak (negative when the hill + * sticks above the ray). Loss below 6 dB is ignored. + */ +export function ituKnifeEdge(h: number, d1Km: number, d2Km: number, fGhz: number): number { + if (d1Km <= 0 || d2Km <= 0 || fGhz <= 0) return 0; + const d = d1Km + d2Km; + const f1 = 17.3 * Math.sqrt((d1Km * d2Km) / (fGhz * d)); + if (!(f1 > 0)) return 0; + const a = (-20 * h) / f1 + 10; + return a < 6 ? 0 : a; +} + +/** + * Port of ACRE `los_simple::diffraction_loss`: 7.5 m terrain profile, add + * ITU loss at local maxima. Does not use TFAR's T or coefficient. + */ +export function acreDiffractionLoss( + grid: DemGrid, + ax: number, + ay: number, + az: number, + bx: number, + by: number, + bz: number, + frequencyMHz: number, + canopy = 0, +): number { + const dx = bx - ax; + const dy = by - ay; + const dz = bz - az; + const dist3d = Math.hypot(dx, dy, dz); + if (dist3d <= 0) return 0; + + const step = ACRE_PROFILE_STEP_METERS; + const count = Math.max(2, Math.floor(dist3d / step) + 1); + if (count < 4) return 0; + + const inv = step / dist3d; + const stepX = dx * inv; + const stepY = dy * inv; + const stepZ = dz * inv; + const profile = new Array(count); + for (let i = 0; i < count; i++) { + profile[i] = heightAt(grid, ax + stepX * i, ay + stepY * i, canopy); + } + + const total2d = Math.hypot(dx, dy); + const fGhz = frequencyMHz / 1000; + let loss = 0; + for (let c = 0; c < count - 1; c++) { + const sample = profile[c]; + const last = profile[Math.max(c - 1, 0)]; + const next = profile[Math.min(c + 1, count - 1)]; + // Bilinear sampling of a plateau is not bit-exact. ITU still bills ~10 dB + // for a 1.6 m clearance when F1 is large, so ulp "peaks" shrink range to + // ~500 m on otherwise flat ground. Real DEM hills are much taller. + if ( + !( + sample - last >= ACRE_PEAK_PROMINENCE_METERS && + sample - next >= ACRE_PEAK_PROMINENCE_METERS + ) + ) { + continue; + } + const peakZ = az + stepZ * c; + const d1 = Math.hypot(stepX * c, stepY * c); + const d2 = total2d - d1; + loss += ituKnifeEdge(peakZ - sample, d1 / 1000, d2 / 1000, fGhz); + } + return loss; +} + +/** + * In-range using the recorded `rangeMeters` as the flat-ground budget, then + * shrinking by ACRE diffraction × `terrainLoss`. Arcade or terrainLoss 0 is + * a circle. Never reads TFAR's coefficient. + * + * Extra dB of diffraction is converted with `d * 10^(loss/20) <= range`, + * which is the same as comparing FSPL(d)+diffraction against FSPL(range). + */ +export function inAcreRange( + grid: DemGrid, + tx: number, + ty: number, + tz: number, + rx: number, + ry: number, + rz: number, + rangeMeters: number, + propagation: AcreRadioPropagation, + frequencyMHz = ACRE_DEFAULT_FREQUENCY_MHZ, + canopy = 0, +): boolean { + const d = Math.hypot(tx - rx, ty - ry, tz - rz); + if (d > rangeMeters) return false; + if (!acreAppliesTerrain(propagation)) return true; + + const freq = frequencyMHz > 0 ? frequencyMHz : ACRE_DEFAULT_FREQUENCY_MHZ; + const diffraction = + acreDiffractionLoss(grid, tx, ty, tz, rx, ry, rz, freq, canopy) * propagation.terrainLoss; + return d * 10 ** (diffraction / 20) <= rangeMeters; +} + +export function marchAcreCoverage( + grid: DemGrid, + origin: [number, number, number?], + options: AcreCoverageOptions, +): [number, number][] { + const range = Math.max(0, options.rangeMeters); + const eye = options.eyeHeightMeters ?? TFAR_EYE_HEIGHT_METERS; + const canopy = options.canopyMeters ?? 0; + const bearings = Math.max(8, options.bearings ?? DEFAULT_BEARINGS); + const freq = + typeof options.frequencyMHz === "number" && options.frequencyMHz > 0 + ? options.frequencyMHz + : ACRE_DEFAULT_FREQUENCY_MHZ; + const projected = projectCoverageOrigin(grid, origin); + const tx = projected.x; + const ty = projected.y; + const tz = projected.offDem + ? heightAt(grid, tx, ty, 0) + eye + : (origin[2] ?? heightAt(grid, tx, ty, 0)) + eye; + + const verts: [number, number][] = []; + for (let i = 0; i < bearings; i++) { + const angle = (i / bearings) * Math.PI * 2; + const dx = Math.cos(angle); + const dy = Math.sin(angle); + let lo = 0; + let hi = range; + for (let step = 0; step < 12; step++) { + const mid = (lo + hi) / 2; + const rx = tx + dx * mid; + const ry = ty + dy * mid; + const rz = heightAt(grid, rx, ry, 0) + eye; + if (inAcreRange(grid, tx, ty, tz, rx, ry, rz, range, options.propagation, freq, canopy)) { + lo = mid; + } else { + hi = mid; + } + } + verts.push([tx + dx * lo, ty + dy * lo]); + } + return shiftCoveragePoints(verts, projected.shiftX, projected.shiftY); +} diff --git a/ui/src/playback/radioRange/demGrid.ts b/ui/src/playback/radioRange/demGrid.ts new file mode 100644 index 000000000..50bf251b8 --- /dev/null +++ b/ui/src/playback/radioRange/demGrid.ts @@ -0,0 +1,378 @@ +import { basePath } from "../../data/basePath"; + +/** + * In-memory heightmap in Arma metres. Row 0 is south, matching the map-tool DEM. + */ +export interface DemGrid { + cols: number; + rows: number; + /** Southwest corner of the grid, usually 0. */ + originX: number; + originY: number; + cellSize: number; + /** World size in metres (Arma). */ + worldSize: number; + /** Row-major, south to north. */ + data: Float32Array; +} + +export const DEM_MAGIC = "OCAPDEM\u0001"; + +function writeF32(view: DataView, offset: number, value: number): void { + view.setFloat32(offset, value, true); +} + +export function encodeDemBinary(grid: DemGrid): Uint8Array { + const header = 8 + 4 * 4 + 4 + 4; + const buf = new ArrayBuffer(header + grid.data.length * 2); + const view = new DataView(buf); + for (let i = 0; i < 8; i++) view.setUint8(i, DEM_MAGIC.charCodeAt(i)); + writeF32(view, 8, grid.worldSize); + writeF32(view, 12, grid.originX); + writeF32(view, 16, grid.originY); + writeF32(view, 20, grid.cellSize); + view.setUint32(24, grid.cols, true); + view.setUint32(28, grid.rows, true); + for (let i = 0; i < grid.data.length; i++) { + const dm = Math.round(grid.data[i] * 10); + const clamped = Math.max(-32767, Math.min(32767, dm)); + view.setInt16(header + i * 2, clamped, true); + } + return new Uint8Array(buf); +} + +export function decodeDemBinary(bytes: Uint8Array): DemGrid { + if (bytes.length < 32) throw new Error("dem.bin is too small"); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let magic = ""; + for (let i = 0; i < 8; i++) magic += String.fromCharCode(view.getUint8(i)); + if (magic !== DEM_MAGIC) throw new Error("dem.bin magic mismatch"); + const worldSize = view.getFloat32(8, true); + const originX = view.getFloat32(12, true); + const originY = view.getFloat32(16, true); + const cellSize = view.getFloat32(20, true); + const cols = view.getUint32(24, true); + const rows = view.getUint32(28, true); + if (cols <= 0 || rows <= 0 || cellSize <= 0) { + throw new Error("dem.bin has invalid dimensions"); + } + const expected = 32 + cols * rows * 2; + if (bytes.length < expected) throw new Error("dem.bin truncated"); + const data = new Float32Array(cols * rows); + for (let i = 0; i < data.length; i++) { + data[i] = view.getInt16(32 + i * 2, true) / 10; + } + return { cols, rows, originX, originY, cellSize, worldSize, data }; +} + +export function sampleDem(grid: DemGrid, x: number, y: number): number { + const gx = (x - grid.originX) / grid.cellSize; + const gy = (y - grid.originY) / grid.cellSize; + const x0 = Math.floor(gx); + const y0 = Math.floor(gy); + const x1 = x0 + 1; + const y1 = y0 + 1; + const tx = gx - x0; + const ty = gy - y0; + const h00 = demAt(grid, x0, y0); + const h10 = demAt(grid, x1, y0); + const h01 = demAt(grid, x0, y1); + const h11 = demAt(grid, x1, y1); + return h00 * (1 - tx) * (1 - ty) + h10 * tx * (1 - ty) + h01 * (1 - tx) * ty + h11 * tx * ty; +} + +function demAt(grid: DemGrid, col: number, row: number): number { + const c = Math.max(0, Math.min(grid.cols - 1, col)); + const r = Math.max(0, Math.min(grid.rows - 1, row)); + return grid.data[r * grid.cols + c]; +} + +export function makeFlatDem(worldSize: number, height = 0, cellSize = 100): DemGrid { + const cols = Math.max(2, Math.ceil(worldSize / cellSize) + 1); + const rows = cols; + const data = new Float32Array(cols * rows); + data.fill(height); + return { cols, rows, originX: 0, originY: 0, cellSize, worldSize, data }; +} + +export function demExtent(grid: DemGrid): { + minX: number; + minY: number; + maxX: number; + maxY: number; +} { + return { + minX: grid.originX, + minY: grid.originY, + maxX: grid.originX + (grid.cols - 1) * grid.cellSize, + maxY: grid.originY + (grid.rows - 1) * grid.cellSize, + }; +} + +/** Closest point on the DEM rectangle. Off-map units land on the nearest edge. */ +export function nearestOnDem(grid: DemGrid, x: number, y: number): [number, number] { + const extent = demExtent(grid); + return [ + Math.max(extent.minX, Math.min(extent.maxX, x)), + Math.max(extent.minY, Math.min(extent.maxY, y)), + ]; +} + +export interface CoverageSampleOrigin { + x: number; + y: number; + shiftX: number; + shiftY: number; + offDem: boolean; +} + +/** + * If the radio is off the DEM, march from the nearest on-terrain point and + * shift the resulting polygon back onto the unit. Uses the edge's ground + * height rather than a Zeus ASL that is floating in empty space. + */ +export function projectCoverageOrigin( + grid: DemGrid, + origin: [number, number, number?], +): CoverageSampleOrigin { + const [x, y] = nearestOnDem(grid, origin[0], origin[1]); + const shiftX = origin[0] - x; + const shiftY = origin[1] - y; + return { + x, + y, + shiftX, + shiftY, + offDem: Math.hypot(shiftX, shiftY) > 0.5, + }; +} + +export function shiftCoveragePoints( + points: [number, number][], + shiftX: number, + shiftY: number, +): [number, number][] { + if (shiftX === 0 && shiftY === 0) return points; + return points.map(([x, y]) => [x + shiftX, y + shiftY]); +} + +async function inflateGzip(bytes: Uint8Array): Promise { + if (typeof DecompressionStream === "undefined") return bytes; + const stream = new Blob([bytes as BlobPart]).stream().pipeThrough(new DecompressionStream("gzip")); + const buf = await new Response(stream).arrayBuffer(); + return new Uint8Array(buf); +} + +function looksGzip(bytes: Uint8Array): boolean { + return bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b; +} + +const demLoads = new Map>(); + +export function resetDemCache(): void { + demLoads.clear(); +} + +export function worldHasElevation(world?: { + tileBaseUrl?: string; + worldName?: string; + worldSize?: number; + hasDem?: boolean; + hasHeightmap?: boolean; +} | null): boolean { + if (!(world?.worldSize && world.worldSize > 0)) return false; + if (world.hasDem === false && world.hasHeightmap === false && !world.worldName) return false; + return Boolean(world.tileBaseUrl || world.worldName); +} + +function looksDemBinary(bytes: Uint8Array): boolean { + if (bytes.length < 8) return false; + if (looksGzip(bytes)) return true; + let magic = ""; + for (let i = 0; i < 8; i++) magic += String.fromCharCode(bytes[i]); + return magic === DEM_MAGIC; +} + +async function fetchDemCandidate(url: string): Promise { + try { + const res = await fetch(url, { cache: "force-cache" }); + if (!res.ok) return undefined; + let bytes = new Uint8Array(await res.arrayBuffer()); + if (!looksDemBinary(bytes)) return undefined; + if (looksGzip(bytes)) bytes = await inflateGzip(bytes); + return decodeDemBinary(bytes); + } catch { + return undefined; + } +} + +function demBinaryUrls(base: string): string[] { + const trimmed = base.replace(/\/$/, ""); + if (!trimmed) return []; + return [ + `${trimmed}/tiles/dem.bin.gz`, + `${trimmed}/dem.bin.gz`, + `${trimmed}/tiles/dem.bin`, + `${trimmed}/dem.bin`, + ]; +} + +function heightmapUrls(base: string): string[] { + const trimmed = base.replace(/\/$/, ""); + if (!trimmed) return []; + return [`${trimmed}/tiles/heightmap.pmtiles`, `${trimmed}/heightmap.pmtiles`]; +} + +function localMapsBase(worldName?: string): string | undefined { + if (!worldName) return undefined; + return `${basePath}images/maps/${encodeURIComponent(worldName.toLowerCase())}`; +} + +/** + * Load a playback DEM. Prefers the compact `dem.bin.gz` the map pipeline + * writes; falls back to assembling Mapbox terrain-RGB tiles from + * `heightmap.pmtiles` so existing maps still work. + * + * Tile basemaps often come from a CDN that has no DEM. A file under the + * webserver's `maps/{world}/` folder is tried first via `/images/maps/…`, + * without replacing the CDN `map.json` / raster tiles. + */ +export async function loadWorldDem(opts: { + tileBaseUrl?: string; + worldName?: string; + worldSize: number; + hasDem?: boolean; + hasHeightmap?: boolean; +}): Promise { + if (opts.worldSize <= 0) return undefined; + const remote = opts.tileBaseUrl?.replace(/\/$/, "") || undefined; + const local = localMapsBase(opts.worldName)?.replace(/\/$/, ""); + if (!remote && !local) return undefined; + const key = `${local ?? ""}|${remote ?? ""}|${opts.worldSize}|${opts.hasDem}|${opts.hasHeightmap}`; + let pending = demLoads.get(key); + if (!pending) { + pending = loadWorldDemUncached({ local, remote, worldSize: opts.worldSize, hasDem: opts.hasDem, hasHeightmap: opts.hasHeightmap }); + demLoads.set(key, pending); + } + return pending; +} + +async function loadWorldDemUncached(opts: { + local?: string; + remote?: string; + worldSize: number; + hasDem?: boolean; + hasHeightmap?: boolean; +}): Promise { + const tryDem = async (base?: string): Promise => { + if (!base) return undefined; + for (const url of demBinaryUrls(base)) { + const grid = await fetchDemCandidate(url); + if (grid) return grid; + } + return undefined; + }; + const tryHeightmap = async (base?: string): Promise => { + if (!base) return undefined; + for (const url of heightmapUrls(base)) { + const grid = await loadDemFromHeightmapPmtiles(url, opts.worldSize); + if (grid) return grid; + } + return undefined; + }; + + // Local files win so a DEM dropped next to the webserver works even when the + // CDN map has tiles but no elevation (Hammertest Šumava). + const localDem = await tryDem(opts.local); + if (localDem) return localDem; + + if (opts.hasDem !== false) { + const remote = opts.remote && opts.remote !== opts.local ? opts.remote : undefined; + const remoteDem = await tryDem(remote); + if (remoteDem) return remoteDem; + } + + if (opts.hasHeightmap !== false) { + const localHeightmap = await tryHeightmap(opts.local); + if (localHeightmap) return localHeightmap; + const remote = opts.remote && opts.remote !== opts.local ? opts.remote : undefined; + const remoteHeightmap = await tryHeightmap(remote); + if (remoteHeightmap) return remoteHeightmap; + } + return undefined; +} + +async function loadDemFromHeightmapPmtiles(url: string, worldSize: number): Promise { + try { + const { PMTiles } = await import("pmtiles"); + const archive = new PMTiles(url); + const header = await archive.getHeader(); + const z = header.minZoom; + const n = 2 ** z; + const west = 0; + const south = 0; + const east = worldSize / 111320; + const north = worldSize / 111320; + const minX = lngToTile(west, n); + const maxX = lngToTile(east, n); + const minY = latToTile(north, n); + const maxY = latToTile(south, n); + + const tiles: { x: number; y: number; bitmap: ImageBitmap }[] = []; + let tileSize = 0; + for (let x = minX; x <= maxX; x++) { + for (let y = minY; y <= maxY; y++) { + const tile = await archive.getZxy(z, x, y); + if (!tile?.data) continue; + const blob = new Blob([tile.data], { type: "image/png" }); + const bitmap = await createImageBitmap(blob); + tileSize = bitmap.width; + tiles.push({ x, y, bitmap }); + } + } + if (!tiles.length || tileSize <= 0) return undefined; + + const cols = (maxX - minX + 1) * tileSize; + const rows = (maxY - minY + 1) * tileSize; + const canvas = new OffscreenCanvas(cols, rows); + const ctx = canvas.getContext("2d"); + if (!ctx) return undefined; + for (const tile of tiles) { + ctx.drawImage(tile.bitmap, (tile.x - minX) * tileSize, (tile.y - minY) * tileSize); + } + const image = ctx.getImageData(0, 0, cols, rows); + const data = new Float32Array(cols * rows); + for (let i = 0; i < cols * rows; i++) { + const o = i * 4; + const val = (image.data[o] << 16) | (image.data[o + 1] << 8) | image.data[o + 2]; + data[i] = val / 10 - 10000; + } + // Canvas row 0 is north; DemGrid row 0 is south. + const flipped = new Float32Array(cols * rows); + for (let r = 0; r < rows; r++) { + flipped.set(data.subarray((rows - 1 - r) * cols, (rows - r) * cols), r * cols); + } + const cellSize = worldSize / Math.max(cols - 1, 1); + return { + cols, + rows, + originX: 0, + originY: 0, + cellSize, + worldSize, + data: flipped, + }; + } catch { + return undefined; + } +} + +function lngToTile(lng: number, n: number): number { + return Math.max(0, Math.min(n - 1, Math.floor(((lng + 180) / 360) * n))); +} + +function latToTile(lat: number, n: number): number { + const rad = (lat * Math.PI) / 180; + const y = Math.floor(((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n); + return Math.max(0, Math.min(n - 1, y)); +} diff --git a/ui/src/playback/radioRange/tfarCoverage.ts b/ui/src/playback/radioRange/tfarCoverage.ts new file mode 100644 index 000000000..e1bee1e9c --- /dev/null +++ b/ui/src/playback/radioRange/tfarCoverage.ts @@ -0,0 +1,163 @@ +import { + TFAR_DISTANCE_SCALE_METERS, + TFAR_EYE_HEIGHT_METERS, + TFAR_T_MAX_METERS, + TFAR_T_MIN_METERS, + TFAR_T_SEARCH_EPSILON, + type TfarRadioPropagation, +} from "../../data/radioPropagation"; +import { sampleDem, projectCoverageOrigin, shiftCoveragePoints, type DemGrid } from "./demGrid"; + +export interface CoverageOptions { + rangeMeters: number; + propagation: TfarRadioPropagation; + /** Extra height added to DEM samples (forest canopy). Default 0. */ + canopyMeters?: number; + eyeHeightMeters?: number; + bearings?: number; +} + +const DEFAULT_BEARINGS = 64; + +function dist3(ax: number, ay: number, az: number, bx: number, by: number, bz: number): number { + const dx = ax - bx; + const dy = ay - by; + const dz = az - bz; + return Math.sqrt(dx * dx + dy * dy + dz * dz); +} + +function heightAt(grid: DemGrid, x: number, y: number, canopy: number): number { + return sampleDem(grid, x, y) + canopy; +} + +/** + * Approximate `terrainIntersectASL`. Samples the DEM along the 3D line and + * reports a hit when terrain (plus optional canopy) rises above the line. + */ +export function terrainIntersects( + grid: DemGrid, + ax: number, + ay: number, + az: number, + bx: number, + by: number, + bz: number, + canopy = 0, +): boolean { + const horizontal = Math.hypot(bx - ax, by - ay); + if (horizontal < 1) return false; + const step = Math.max(grid.cellSize, horizontal / 80); + const samples = Math.max(2, Math.ceil(horizontal / step)); + for (let i = 1; i < samples; i++) { + const t = i / samples; + const x = ax + (bx - ax) * t; + const y = ay + (by - ay) * t; + const z = az + (bz - az) * t; + if (heightAt(grid, x, y, canopy) > z + 0.5) return true; + } + return false; +} + +/** + * Port of `TFAR_fnc_calcTerrainInterception`: extra metres of midpoint height + * needed to clear the ridge, binary-searched in [10, 250]. + */ +export function calcTerrainInterception( + grid: DemGrid, + ax: number, + ay: number, + az: number, + bx: number, + by: number, + bz: number, + canopy = 0, +): number { + if (!terrainIntersects(grid, ax, ay, az, bx, by, bz, canopy)) return 0; + + const mx = (ax + bx) * 0.5; + const my = (ay + by) * 0.5; + const baseZ = (az + bz) * 0.5; + let lo = TFAR_T_MIN_METERS; + let hi = TFAR_T_MAX_METERS; + let mid = 100; + while (hi - lo > TFAR_T_SEARCH_EPSILON) { + const lifted = baseZ + mid; + const clear = + !terrainIntersects(grid, ax, ay, az, mx, my, lifted, canopy) && + !terrainIntersects(grid, bx, by, bz, mx, my, lifted, canopy); + if (clear) hi = mid; + else lo = mid; + mid = (lo + hi) / 2; + } + return mid; +} + +/** + * TFAR `effectiveDistanceTo`, then compared against `range * globalRadioRangeCoef` + * because the plugin multiplies effective distance by `1 / TFAR_globalRadioRangeCoef`. + */ +export function effectiveDistance( + d: number, + terrainInterception: number, + propagation: TfarRadioPropagation, +): number { + const c = propagation.terrainInterceptionCoefficient; + const scaled = d + terrainInterception * c + terrainInterception * c * (d / TFAR_DISTANCE_SCALE_METERS); + return scaled / propagation.globalRadioRangeCoef; +} + +export function inTfarRange( + grid: DemGrid, + tx: number, + ty: number, + tz: number, + rx: number, + ry: number, + rz: number, + rangeMeters: number, + propagation: TfarRadioPropagation, + canopy = 0, +): boolean { + const d = dist3(tx, ty, tz, rx, ry, rz); + const t = calcTerrainInterception(grid, tx, ty, tz, rx, ry, rz, canopy); + return effectiveDistance(d, t, propagation) <= rangeMeters; +} + +export function marchCoverage( + grid: DemGrid, + origin: [number, number, number?], + options: CoverageOptions, +): [number, number][] { + const range = Math.max(0, options.rangeMeters); + const eye = options.eyeHeightMeters ?? TFAR_EYE_HEIGHT_METERS; + const canopy = options.canopyMeters ?? 0; + const bearings = Math.max(8, options.bearings ?? DEFAULT_BEARINGS); + const projected = projectCoverageOrigin(grid, origin); + const tx = projected.x; + const ty = projected.y; + const tz = projected.offDem + ? heightAt(grid, tx, ty, 0) + eye + : (origin[2] ?? heightAt(grid, tx, ty, 0)) + eye; + + const verts: [number, number][] = []; + for (let i = 0; i < bearings; i++) { + const angle = (i / bearings) * Math.PI * 2; + const dx = Math.cos(angle); + const dy = Math.sin(angle); + let lo = 0; + let hi = range; + for (let step = 0; step < 12; step++) { + const mid = (lo + hi) / 2; + const rx = tx + dx * mid; + const ry = ty + dy * mid; + const rz = heightAt(grid, rx, ry, 0) + eye; + if (inTfarRange(grid, tx, ty, tz, rx, ry, rz, range, options.propagation, canopy)) { + lo = mid; + } else { + hi = mid; + } + } + verts.push([tx + dx * lo, ty + dy * lo]); + } + return shiftCoveragePoints(verts, projected.shiftX, projected.shiftY); +} diff --git a/ui/src/renderers/leaflet/__tests__/zoomSuppression.test.ts b/ui/src/renderers/leaflet/__tests__/zoomSuppression.test.ts index c14205ff8..03fdac99e 100644 --- a/ui/src/renderers/leaflet/__tests__/zoomSuppression.test.ts +++ b/ui/src/renderers/leaflet/__tests__/zoomSuppression.test.ts @@ -87,6 +87,57 @@ describe("briefing marker SVG renderer", () => { expect(polyline.options.renderer).toBe(svgRenderer); }); + it("creates POLYGON with the SVG renderer", () => { + const handle = renderer.createBriefingMarker({ + shape: "POLYGON", + type: "ocap_radio_coverage", + color: "00AAFF", + side: "WEST", + brush: "SolidBorder", + }); + + const polygon = getInternalLayer(handle) as any; + expect(polygon.options.renderer).toBe((renderer as any).svgRenderer); + }); + + it("updateBriefingMarker sets POLYGON vertices from state.points", () => { + const handle = renderer.createBriefingMarker({ + shape: "POLYGON", + type: "ocap_radio_coverage", + color: "00AAFF", + side: "WEST", + brush: "SolidBorder", + }); + const polygon = getInternalLayer(handle) as L.Polygon; + const setLatLngs = vi.spyOn(polygon, "setLatLngs"); + + renderer.updateBriefingMarker(handle, { + position: [1000, 1000], + direction: 0, + alpha: 0.25, + points: [[0, 0], [100, 0], [100, 100]], + }); + + expect(setLatLngs).toHaveBeenCalled(); + const latlngs = setLatLngs.mock.calls[0][0] as L.LatLng[]; + expect(latlngs).toHaveLength(3); + }); + + it("stores dashArray on ELLIPSE rings", () => { + const handle = renderer.createBriefingMarker({ + shape: "ELLIPSE", + type: "ocap_radio_range", + color: "FF0000", + side: "WEST", + size: [100, 100], + brush: "Border", + dashArray: "8 8", + }); + const polygon = getInternalLayer(handle) as any; + expect(polygon.options.dashArray).toBe("8 8"); + expect(getInternal(handle).dashArray).toBe("8 8"); + }); + it("ICON markers use default renderer (not SVG)", () => { const handle = renderer.createBriefingMarker({ shape: "ICON", diff --git a/ui/src/renderers/leaflet/leafletRenderer.ts b/ui/src/renderers/leaflet/leafletRenderer.ts index 07a3fbbb9..4c5fad648 100644 --- a/ui/src/renderers/leaflet/leafletRenderer.ts +++ b/ui/src/renderers/leaflet/leafletRenderer.ts @@ -55,11 +55,12 @@ interface InternalMarkerHandle { interface InternalBriefingHandle { layer: L.Layer; - shape: "ICON" | "ELLIPSE" | "RECTANGLE" | "POLYLINE"; + shape: "ICON" | "ELLIPSE" | "RECTANGLE" | "POLYLINE" | "POLYGON"; layerKey: "briefingMarkers" | "systemMarkers" | "projectileMarkers"; size?: [number, number]; patternId?: string; shapeOpts?: { stroke: boolean; fill: boolean; fillOpacity: number }; + dashArray?: string; } interface ShapeResult { @@ -890,11 +891,12 @@ export class LeafletRenderer implements MapRenderer { interactive: false, renderer: this.svgRenderer, } as any); - } else if (def.shape === "ELLIPSE" || def.shape === "RECTANGLE") { + } else if (def.shape === "ELLIPSE" || def.shape === "RECTANGLE" || def.shape === "POLYGON") { // Build polygon options from brush type; use SVG renderer to avoid // canvas bitmap scaling during zoom animation const result = this.buildShapeOptions(cssColor, def.brush); const polygonOpts: any = { ...result.opts, noClip: false, interactive: false, renderer: this.svgRenderer }; + if (def.dashArray) polygonOpts.dashArray = def.dashArray; shapeOpts = { stroke: !!result.opts.stroke, @@ -919,7 +921,7 @@ export class LeafletRenderer implements MapRenderer { if (patternId) { const layerKey = def.layer ?? "briefingMarkers"; layer.addTo(this.layers[layerKey]); - return wrapBriefing({ layer, shape: def.shape, layerKey, size: def.size, patternId, shapeOpts }); + return wrapBriefing({ layer, shape: def.shape, layerKey, size: def.size, patternId, shapeOpts, dashArray: def.dashArray }); } } else if (def.type.includes("Empty") && def.text) { // Empty markers are text-only labels (e.g. sector names). @@ -985,7 +987,7 @@ export class LeafletRenderer implements MapRenderer { const el = (layer as L.Marker).getElement?.(); if (el) el.style.display = "none"; } - return wrapBriefing({ layer, shape: def.shape, layerKey, size: def.size, shapeOpts }); + return wrapBriefing({ layer, shape: def.shape, layerKey, size: def.size, shapeOpts, dashArray: def.dashArray }); } updateBriefingMarker( @@ -1050,6 +1052,10 @@ export class LeafletRenderer implements MapRenderer { polygon.setLatLngs(latlngs); this.applyPolygonOpacity(polygon, internal, state.alpha); + } else if (internal.shape === "POLYGON" && state.points) { + const polygon = layer as L.Polygon; + polygon.setLatLngs(state.points.map((p) => this.armaToLatLng(p))); + this.applyPolygonOpacity(polygon, internal, state.alpha); } else if (internal.shape === "POLYLINE" && state.points) { const polyline = layer as L.Polyline; const latlngs = state.points.map((p) => this.armaToLatLng(p)); @@ -1088,7 +1094,11 @@ export class LeafletRenderer implements MapRenderer { fillOpacity = 0; } - polygon.setStyle({ opacity: strokeOpacity, fillOpacity }); + polygon.setStyle({ + opacity: strokeOpacity, + fillOpacity, + ...(internal.dashArray ? { dashArray: internal.dashArray } : {}), + }); } private buildShapeOptions( diff --git a/ui/src/renderers/renderer.types.ts b/ui/src/renderers/renderer.types.ts index b0075799e..503a42afd 100644 --- a/ui/src/renderers/renderer.types.ts +++ b/ui/src/renderers/renderer.types.ts @@ -58,13 +58,15 @@ export interface EntityMarkerState { // --------------- Briefing markers --------------- export interface BriefingMarkerDef { - shape: "ICON" | "ELLIPSE" | "RECTANGLE" | "POLYLINE"; + shape: "ICON" | "ELLIPSE" | "RECTANGLE" | "POLYLINE" | "POLYGON"; type: string; color: string; text?: string; side: string; size?: [number, number]; brush?: string; + /** Leaflet dashArray, used for the simple-range ring under approximate coverage. */ + dashArray?: string; /** Which layer group to add this marker to. Defaults to "briefingMarkers". */ layer?: "briefingMarkers" | "systemMarkers" | "projectileMarkers"; } From a97a4e9a01f043c528582b79e0feee56e258dc31 Mon Sep 17 00:00:00 2001 From: Yethe Samartaka Date: Mon, 24 Aug 2026 18:41:28 +0200 Subject: [PATCH 02/39] Server fps support --- internal/storage/parser_v1.go | 5 +- internal/storage/player_snapshot_test.go | 26 +++++- .../decoders/__tests__/jsonDecoder.test.ts | 15 +++ .../__tests__/protobufDecoder.test.ts | 17 ++++ ui/src/data/decoders/jsonDecoder.ts | 5 + ui/src/data/decoders/protobufDecoder.ts | 9 ++ ui/src/data/types.ts | 6 ++ ui/src/i18n/locales.ts | 40 ++++++++ .../__tests__/StatsTab.test.tsx | 91 +++++++++++++++++++ .../components/SidePanel.module.css | 39 +++++++- .../components/StatsTab.tsx | 40 ++++++++ ui/src/playback/engine.ts | 3 + ui/src/playback/eventManager.ts | 24 +++++ ui/src/playback/events/serverFpsEvent.ts | 12 +++ 14 files changed, 324 insertions(+), 8 deletions(-) create mode 100644 ui/src/playback/events/serverFpsEvent.ts diff --git a/internal/storage/parser_v1.go b/internal/storage/parser_v1.go index 34c59336a..539e9282c 100644 --- a/internal/storage/parser_v1.go +++ b/internal/storage/parser_v1.go @@ -167,12 +167,13 @@ func parseEventArray(evtArr []interface{}) *Event { return event } - // Additive player detail events keep their structured payload as JSON in + // Additive player detail and server FPS events keep their structured payload as JSON in // the generic protobuf message field. Older readers safely ignore these // unknown event names, while newer readers can reconstruct the object. if event.Type == "inventorySnapshot" || event.Type == "medicalSnapshot" || event.Type == "staminaSnapshot" || event.Type == "radioSnapshot" || - event.Type == "tfarSettings" || event.Type == "acreSettings" { + event.Type == "tfarSettings" || event.Type == "acreSettings" || + event.Type == "serverFps" { if len(evtArr) > 2 { if encoded, err := json.Marshal(evtArr[2]); err == nil { event.Message = string(encoded) diff --git a/internal/storage/player_snapshot_test.go b/internal/storage/player_snapshot_test.go index 17b4c6a8d..2deff637d 100644 --- a/internal/storage/player_snapshot_test.go +++ b/internal/storage/player_snapshot_test.go @@ -110,11 +110,11 @@ func TestParseAcreSettingsEventPreservesPayload(t *testing.T) { float64(1), "acreSettings", map[string]interface{}{ - "terrainLoss": 0.4, - "signalModel": 1.0, - "ignoreAntennaDirection": true, - "acreLoaded": true, - "source": "cba", + "terrainLoss": 0.4, + "signalModel": 1.0, + "ignoreAntennaDirection": true, + "acreLoaded": true, + "source": "cba", }, }) @@ -130,3 +130,19 @@ func TestParseAcreSettingsEventPreservesPayload(t *testing.T) { require.Equal(t, true, payload["acreLoaded"]) require.Equal(t, "cba", payload["source"]) } + +func TestParseServerFpsEventPreservesPayload(t *testing.T) { + event := parseEventArray([]interface{}{ + float64(60), + "serverFps", + map[string]interface{}{"fps": 47.25}, + }) + + require.NotNil(t, event) + require.Equal(t, "serverFps", event.Type) + require.Equal(t, uint32(60), event.FrameNum) + + var payload map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(event.Message), &payload)) + require.Equal(t, 47.25, payload["fps"]) +} diff --git a/ui/src/data/decoders/__tests__/jsonDecoder.test.ts b/ui/src/data/decoders/__tests__/jsonDecoder.test.ts index df14ae79b..97b2b0f9f 100644 --- a/ui/src/data/decoders/__tests__/jsonDecoder.test.ts +++ b/ui/src/data/decoders/__tests__/jsonDecoder.test.ts @@ -26,6 +26,21 @@ describe("JsonDecoder.decodeManifest", () => { }]); }); + it("decodes additive server FPS events and rejects malformed samples", () => { + const manifest = decoder.decodeManifest(toBuffer({ + worldName: "Altis", + missionName: "FPS", + endFrame: 180, + captureDelay: 1, + events: [ + [0, "serverFps", { fps: 47.25 }], + [60, "serverFps", { fps: "bad" }], + ], + })); + + expect(manifest.events).toEqual([{ frameNum: 0, type: "serverFps", fps: 47.25 }]); + }); + it("passes diff-encoded snapshots through untouched", () => { const diff = { unitId: 7, diffOf: 10, set: { ace: { heartRate: 133 } }, unset: ["kat.spo2"] }; const manifest = decoder.decodeManifest(toBuffer({ diff --git a/ui/src/data/decoders/__tests__/protobufDecoder.test.ts b/ui/src/data/decoders/__tests__/protobufDecoder.test.ts index 498a0827d..68d20d445 100644 --- a/ui/src/data/decoders/__tests__/protobufDecoder.test.ts +++ b/ui/src/data/decoders/__tests__/protobufDecoder.test.ts @@ -43,6 +43,23 @@ describe("ProtobufDecoder.decodeManifest", () => { }]); }); + it("decodes additive server FPS payloads from the generic event message", () => { + const buffer = encodePb(PbManifest, { + version: 1, + worldName: "Altis", + missionName: "FPS", + endFrame: 180, + chunkSize: 300, + captureDelayMs: 1000, + chunkCount: 1, + events: [{ frameNum: 60, type: "serverFps", message: JSON.stringify({ fps: 42.5 }) }], + }); + + expect(decoder.decodeManifest(buffer).events).toEqual([ + { frameNum: 60, type: "serverFps", fps: 42.5 }, + ]); + }); + it("passes diff-encoded snapshots through untouched", () => { const diff = { unitId: 7, diffOf: 10, set: { vanilla: { load: 0.61 } } }; const buffer = encodePb(PbManifest, { diff --git a/ui/src/data/decoders/jsonDecoder.ts b/ui/src/data/decoders/jsonDecoder.ts index e1cc31620..1837c08da 100644 --- a/ui/src/data/decoders/jsonDecoder.ts +++ b/ui/src/data/decoders/jsonDecoder.ts @@ -329,6 +329,11 @@ function convertEvent(raw: RawJsonEvent): EventDef | null { if (!payload) return null; return { frameNum, type, payload }; } + case "serverFps": { + const fps = (raw[2] as { fps?: unknown } | undefined)?.fps; + if (typeof fps !== "number" || !Number.isFinite(fps) || fps < 0) return null; + return { frameNum, type, fps }; + } default: // Unknown event types: skip return null; diff --git a/ui/src/data/decoders/protobufDecoder.ts b/ui/src/data/decoders/protobufDecoder.ts index 02a5bac92..2750d71da 100644 --- a/ui/src/data/decoders/protobufDecoder.ts +++ b/ui/src/data/decoders/protobufDecoder.ts @@ -186,6 +186,15 @@ function convertEvent(pb: PbEvent): EventDef | null { return null; } } + case "serverFps": { + try { + const payload = JSON.parse(pb.message) as { fps?: unknown }; + if (typeof payload?.fps !== "number" || !Number.isFinite(payload.fps) || payload.fps < 0) return null; + return { frameNum, type, fps: payload.fps }; + } catch { + return null; + } + } case "capturedFlag": return { frameNum, diff --git a/ui/src/data/types.ts b/ui/src/data/types.ts index a8fa74a09..455ed8363 100644 --- a/ui/src/data/types.ts +++ b/ui/src/data/types.ts @@ -239,6 +239,11 @@ export interface AcreSettingsEventDef { payload: AcreRadioPropagation; } +export interface ServerFpsEventDef { + type: "serverFps"; + fps: number; +} + export type EventDef = { frameNum: number } & ( | HitKilledEventDef | ConnectEventDef @@ -250,6 +255,7 @@ export type EventDef = { frameNum: number } & ( | PlayerSnapshotEventDef | TfarSettingsEventDef | AcreSettingsEventDef + | ServerFpsEventDef ); // --------------- Markers --------------- diff --git a/ui/src/i18n/locales.ts b/ui/src/i18n/locales.ts index 034f1b329..328aa023a 100644 --- a/ui/src/i18n/locales.ts +++ b/ui/src/i18n/locales.ts @@ -1367,6 +1367,46 @@ export const translations: Record> = { fi: "Joukkojen yhteenveto", uk: "Зведення сил", }, + server_fps: { + en: "Server FPS", + ru: "FPS сервера", + de: "Server-FPS", + cs: "FPS serveru", + it: "FPS server", + fr: "FPS serveur", + fi: "Palvelimen FPS", + uk: "FPS сервера", + }, + current: { + en: "Current", + ru: "Текущий", + de: "Aktuell", + cs: "Aktuální", + it: "Attuale", + fr: "Actuel", + fi: "Nykyinen", + uk: "Поточний", + }, + average: { + en: "Average", + ru: "Среднее", + de: "Durchschnitt", + cs: "Průměr", + it: "Media", + fr: "Moyenne", + fi: "Keskiarvo", + uk: "Середнє", + }, + median: { + en: "Median", + ru: "Медиана", + de: "Median", + cs: "Medián", + it: "Mediana", + fr: "Médiane", + fi: "Mediaani", + uk: "Медіана", + }, strength: { ru: "Численность", en: "Strength", diff --git a/ui/src/pages/recording-playback/__tests__/StatsTab.test.tsx b/ui/src/pages/recording-playback/__tests__/StatsTab.test.tsx index 8ffc3be3e..d36fa3948 100644 --- a/ui/src/pages/recording-playback/__tests__/StatsTab.test.tsx +++ b/ui/src/pages/recording-playback/__tests__/StatsTab.test.tsx @@ -16,6 +16,97 @@ afterEach(() => { }); describe("StatsTab", () => { + it("shows playhead-scoped server FPS stats above Force Summary when samples exist", () => { + const { engine, renderer } = createTestEngine(); + engine.loadRecording(makeManifest( + [unitDef({ id: 1, side: "WEST" })], + [ + { frameNum: 0, type: "serverFps", fps: 40 }, + { frameNum: 60, type: "serverFps", fps: 20 }, + { frameNum: 120, type: "serverFps", fps: 30 }, + { frameNum: 180, type: "serverFps", fps: 50 }, + ], + 180, + )); + engine.seekTo(120); + + render(() => ( + + + + )); + + expect(screen.getByText("Server FPS")).toBeTruthy(); + expect(screen.getByText("Current")).toBeTruthy(); + expect(screen.getByText("Average")).toBeTruthy(); + expect(screen.getByText("Median")).toBeTruthy(); + expect(screen.getAllByText("30.0")).toHaveLength(3); + + const fpsHeading = screen.getByText("Server FPS"); + const forceHeading = screen.getByText("Force Summary"); + expect(fpsHeading.compareDocumentPosition(forceHeading) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it("hides server FPS stats for backwards-compatible recordings without samples", () => { + const { engine, renderer } = createTestEngine(); + engine.loadRecording(makeManifest([unitDef({ id: 1, side: "WEST" })])); + + render(() => ( + + + + )); + + expect(screen.queryByText("Server FPS")).toBeNull(); + expect(screen.getByText("Force Summary")).toBeTruthy(); + }); + + it("colors each FPS number independently by threshold", () => { + const { engine, renderer } = createTestEngine(); + engine.loadRecording(makeManifest( + [unitDef({ id: 1, side: "WEST" })], + [ + { frameNum: 0, type: "serverFps", fps: 10 }, + { frameNum: 10, type: "serverFps", fps: 25 }, + { frameNum: 20, type: "serverFps", fps: 80 }, + ], + 20, + )); + engine.seekTo(20); + + render(() => ( + + + + )); + + expect(screen.getByText("Current").previousElementSibling?.getAttribute("style")) + .toContain("var(--accent-success)"); + expect(screen.getByText("Average").previousElementSibling?.getAttribute("style")) + .toContain("var(--accent-warning)"); + expect(screen.getByText("Median").previousElementSibling?.getAttribute("style")) + .toContain("rgb(255, 138, 61)"); + }); + + it("colors FPS values below 20 red", () => { + const { engine, renderer } = createTestEngine(); + engine.loadRecording(makeManifest( + [unitDef({ id: 1, side: "WEST" })], + [{ frameNum: 0, type: "serverFps", fps: 19.9 }], + )); + + render(() => ( + + + + )); + + for (const label of ["Current", "Average", "Median"]) { + expect(screen.getByText(label).previousElementSibling?.getAttribute("style")) + .toContain("var(--accent-danger)"); + } + }); + it("shows force cards only for sides with units", () => { const { engine, renderer } = createTestEngine(); engine.loadRecording( diff --git a/ui/src/pages/recording-playback/components/SidePanel.module.css b/ui/src/pages/recording-playback/components/SidePanel.module.css index 199c9ce50..7976057e7 100644 --- a/ui/src/pages/recording-playback/components/SidePanel.module.css +++ b/ui/src/pages/recording-playback/components/SidePanel.module.css @@ -580,6 +580,43 @@ text-transform: uppercase; } +.serverFpsCard { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 4px; + margin-top: 8px; + padding: 8px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; +} + +.serverFpsTitle { + color: var(--text-on-accent); +} + +.serverFpsMetric { + padding: 7px 3px; + text-align: center; + border-radius: 5px; +} + +.serverFpsValue { + font-family: var(--font-mono); + font-size: var(--font-size-xl); + font-weight: 700; + line-height: 1; +} + +.serverFpsLabel { + margin-top: 4px; + color: var(--text-on-accent); + font-family: var(--font-mono); + font-size: var(--font-size-xs); + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} + .forceSummary { display: flex; flex-direction: column; @@ -758,4 +795,4 @@ padding: 32px; text-align: center; font-family: var(--font-mono); -} \ No newline at end of file +} diff --git a/ui/src/pages/recording-playback/components/StatsTab.tsx b/ui/src/pages/recording-playback/components/StatsTab.tsx index 60256fb20..469fe0a93 100644 --- a/ui/src/pages/recording-playback/components/StatsTab.tsx +++ b/ui/src/pages/recording-playback/components/StatsTab.tsx @@ -40,6 +40,18 @@ export function StatsTab(): JSX.Element { const { t } = useI18n(); const showPlayerKillCount = (): boolean => !customize().disableKillCount; + const serverFps = createMemo(() => + engine.eventManager.getServerFpsStats(engine.currentFrame()), + ); + + const formatFps = (value: number): string => value.toFixed(1); + const fpsColor = (value: number): string => { + if (value >= 60) return "var(--accent-success)"; + if (value >= 30) return "var(--accent-warning)"; + if (value >= 20) return "#ff8a3d"; + return "var(--accent-danger)"; + }; + // Frame-aware kill/death counts const killDeathCounts = createMemo(() => engine.eventManager.getKillDeathCounts(engine.currentFrame()), @@ -91,6 +103,34 @@ export function StatsTab(): JSX.Element { return (
+ + {(fps) => ( +
+
{t("server_fps")}
+
+
+
+ {formatFps(fps.current)} +
+
{t("current")}
+
+
+
+ {formatFps(fps.average)} +
+
{t("average")}
+
+
+
+ {formatFps(fps.median)} +
+
{t("median")}
+
+
+
+ )} +
+ {/* Force summary */}
{t("force_summary")}
diff --git a/ui/src/playback/engine.ts b/ui/src/playback/engine.ts index b8f1a22ec..cd5ab6f63 100644 --- a/ui/src/playback/engine.ts +++ b/ui/src/playback/engine.ts @@ -22,6 +22,7 @@ import { GeneralMissionEvent } from "./events/generalEvent"; import { CapturedEvent } from "./events/capturedEvent"; import { TerminalHackEvent } from "./events/terminalHackEvent"; import { PlayerSnapshotEvent } from "./events/playerSnapshotEvent"; +import { ServerFpsEvent } from "./events/serverFpsEvent"; import { Unit } from "./entities/unit"; import { Vehicle } from "./entities/vehicle"; @@ -70,6 +71,8 @@ function createGameEvent(def: EventDef): GameEvent | null { case "tfarSettings": case "acreSettings": return null; + case "serverFps": + return new ServerFpsEvent(def.frameNum, id, def.fps); default: return null; } diff --git a/ui/src/playback/eventManager.ts b/ui/src/playback/eventManager.ts index 5e3794381..bee21ba69 100644 --- a/ui/src/playback/eventManager.ts +++ b/ui/src/playback/eventManager.ts @@ -4,6 +4,7 @@ import { Vehicle } from "./entities/vehicle"; import { GameEvent } from "./events/gameEvent"; import { HitKilledEvent } from "./events/hitKilledEvent"; import { PlayerSnapshotEvent } from "./events/playerSnapshotEvent"; +import { ServerFpsEvent } from "./events/serverFpsEvent"; import { applySnapshotDiff, isSnapshotDiff } from "../data/snapshotDiff"; import type { PlayerSnapshotPayload, PlayerSnapshotType } from "../data/types"; @@ -16,6 +17,7 @@ export class EventManager { private events: GameEvent[] = []; private frameIndex: Map = new Map(); private playerSnapshots = new Map>(); + private serverFpsEvents: ServerFpsEvent[] = []; /** Add an event and index it by frame number. */ addEvent(event: GameEvent): void { @@ -31,6 +33,11 @@ export class EventManager { else unitSnapshots.set(type, [event]); return; } + if (event instanceof ServerFpsEvent) { + this.serverFpsEvents.push(event); + this.serverFpsEvents.sort((a, b) => a.frameNum - b.frameNum); + return; + } this.events.push(event); const existing = this.frameIndex.get(event.frameNum); @@ -103,6 +110,22 @@ export class EventManager { return result; } + /** Current and aggregate server FPS through the requested playback frame. */ + getServerFpsStats(frame: number): { current: number; average: number; median: number } | undefined { + const samples = this.serverFpsEvents.filter((event) => event.frameNum <= frame); + if (samples.length === 0) return undefined; + + const values = samples.map((event) => event.fps); + const average = values.reduce((sum, value) => sum + value, 0) / values.length; + const sorted = [...values].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + const median = sorted.length % 2 === 1 + ? sorted[middle] + : (sorted[middle - 1] + sorted[middle]) / 2; + + return { current: samples[samples.length - 1].fps, average, median }; + } + /** * Resolve entity references on HitKilledEvent instances. * Populates names, sides, and computes kill counts. @@ -212,5 +235,6 @@ export class EventManager { this.events = []; this.frameIndex = new Map(); this.playerSnapshots = new Map(); + this.serverFpsEvents = []; } } diff --git a/ui/src/playback/events/serverFpsEvent.ts b/ui/src/playback/events/serverFpsEvent.ts new file mode 100644 index 000000000..101e722fe --- /dev/null +++ b/ui/src/playback/events/serverFpsEvent.ts @@ -0,0 +1,12 @@ +import { GameEvent } from "./gameEvent"; + +/** One server FPS sample embedded in the mission recording. */ +export class ServerFpsEvent extends GameEvent { + constructor( + frameNum: number, + id: number, + public readonly fps: number, + ) { + super(frameNum, "serverFps", id); + } +} From 8aab1d024735bc331749c9a22f71df926f4770e0 Mon Sep 17 00:00:00 2001 From: Yethe Samartaka Date: Mon, 24 Aug 2026 19:57:56 +0200 Subject: [PATCH 03/39] improved gear visuals --- ui/src/i18n/locales.ts | 5 + .../__tests__/PlayerProfileCard.test.tsx | 216 +++++++++++++++++- .../__tests__/gearCategories.test.ts | 118 ++++++++++ .../components/PlayerProfileCard.module.css | 15 ++ .../components/PlayerProfileCard.tsx | 94 +++++++- .../recording-playback/gearCategories.ts | 148 ++++++++++++ .../playback/__tests__/entityManager.test.ts | 19 ++ ui/src/playback/entities/unit.ts | 10 + 8 files changed, 614 insertions(+), 11 deletions(-) create mode 100644 ui/src/pages/recording-playback/__tests__/gearCategories.test.ts create mode 100644 ui/src/pages/recording-playback/gearCategories.ts diff --git a/ui/src/i18n/locales.ts b/ui/src/i18n/locales.ts index 328aa023a..e7b418b39 100644 --- a/ui/src/i18n/locales.ts +++ b/ui/src/i18n/locales.ts @@ -2615,6 +2615,8 @@ export const translations: Record> = { profile_restore_markers: { en: "Restore {count} markers", ru: "Показать маркеры ({count})", de: "{count} Marker wiederherstellen", cs: "Obnovit {count} značek", it: "Ripristina {count} marker", fr: "Restaurer {count} marqueurs", fi: "Palauta {count} merkkiä", uk: "Відновити {count} маркерів" }, profile_kills: { en: "Kills", ru: "Убийства", de: "Abschüsse", cs: "Zabití", it: "Uccisioni", fr: "Éliminations", fi: "Tapot", uk: "Вбивства" }, profile_deaths: { en: "Deaths", ru: "Смерти", de: "Tode", cs: "Úmrtí", it: "Morti", fr: "Morts", fi: "Kuolemat", uk: "Смерті" }, + profile_rounds: { en: "Rounds", ru: "Патроны", de: "Patronen", cs: "Náboje", it: "Colpi", fr: "Cartouches", fi: "Patruunat", uk: "Набої" }, + profile_rounds_shot: { en: "Shot", ru: "Выстрелы", de: "Abgefeuert", cs: "Vystřeleno", it: "Sparati", fr: "Tirés", fi: "Ammuttu", uk: "Постріли" }, profile_markers: { en: "Markers", ru: "Маркеры", de: "Marker", cs: "Značky", it: "Marker", fr: "Marqueurs", fi: "Merkit", uk: "Маркери" }, profile_weight: { en: "Weight kg", ru: "Вес кг", de: "Gewicht kg", cs: "Hmotnost kg", it: "Peso kg", fr: "Poids kg", fi: "Paino kg", uk: "Вага кг" }, profile_stamina: { en: "Stamina", ru: "Выносливость", de: "Ausdauer", cs: "Výdrž", it: "Stamina", fr: "Endurance", fi: "Kestävyys", uk: "Витривалість" }, @@ -2642,6 +2644,9 @@ export const translations: Record> = { profile_face: { en: "Face", ru: "Лицо", de: "Gesicht", cs: "Obličej", it: "Viso", fr: "Visage", fi: "Kasvot", uk: "Обличчя" }, profile_weapons: { en: "Weapons", ru: "Оружие", de: "Waffen", cs: "Zbraně", it: "Armi", fr: "Armes", fi: "Aseet", uk: "Зброя" }, profile_magazines: { en: "Magazines", ru: "Магазины", de: "Magazine", cs: "Zásobníky", it: "Caricatori", fr: "Chargeurs", fi: "Lippaat", uk: "Магазини" }, + profile_grenades: { en: "Grenades", ru: "Гранаты", de: "Granaten", cs: "Granáty", it: "Granate", fr: "Grenades", fi: "Kranaatit", uk: "Гранати" }, + profile_medical_gear: { en: "Medical", ru: "Медицина", de: "Medizin", cs: "Medicína", it: "Medicina", fr: "Médical", fi: "Lääkintä", uk: "Медицина" }, + profile_items: { en: "Items", ru: "Предметы", de: "Gegenstände", cs: "Předměty", it: "Oggetti", fr: "Objets", fi: "Esineet", uk: "Предмети" }, profile_uniform: { en: "Uniform", ru: "Форма", de: "Uniform", cs: "Uniforma", it: "Uniforme", fr: "Uniforme", fi: "Puku", uk: "Уніформа" }, profile_vest: { en: "Vest", ru: "Жилет", de: "Weste", cs: "Vesta", it: "Giubbotto", fr: "Gilet", fi: "Liivi", uk: "Жилет" }, profile_backpack: { en: "Backpack", ru: "Рюкзак", de: "Rucksack", cs: "Batoh", it: "Zaino", fr: "Sac à dos", fi: "Reppu", uk: "Рюкзак" }, diff --git a/ui/src/pages/recording-playback/__tests__/PlayerProfileCard.test.tsx b/ui/src/pages/recording-playback/__tests__/PlayerProfileCard.test.tsx index acacb9386..f4beeea7e 100644 --- a/ui/src/pages/recording-playback/__tests__/PlayerProfileCard.test.tsx +++ b/ui/src/pages/recording-playback/__tests__/PlayerProfileCard.test.tsx @@ -15,7 +15,12 @@ function snapshotEvent( return { type, frameNum, payload: { unitId: 1, ...payload } } as unknown as EventDef; } -function renderCard(events: EventDef[], frame = 10, world?: WorldConfig) { +function renderCard( + events: EventDef[], + frame = 10, + world?: WorldConfig, + extra?: { framesFired?: Array<[number, [number, number]]> }, +) { const { engine, renderer } = createTestEngine(); if (world) engine.setWorldConfig(world); const unitPositions = Array.from({ length: frame + 1 }, () => ({ @@ -24,7 +29,17 @@ function renderCard(events: EventDef[], frame = 10, world?: WorldConfig) { alive: 1 as const, })); engine.loadRecording( - makeManifest([unitDef({ id: 1, name: "Rifleman", positions: unitPositions })], events), + makeManifest( + [ + unitDef({ + id: 1, + name: "Rifleman", + positions: unitPositions, + framesFired: extra?.framesFired, + }), + ], + events, + ), ); engine.seekTo(frame); const unit = engine.entityManager.getEntity(1) as Unit; @@ -488,4 +503,201 @@ describe("PlayerProfileCard", () => { expect(screen.queryByText("Weapons")).toBeNull(); expect(screen.getByText("Life state")).toBeTruthy(); }); + + it("lists magazines inside their containers instead of a duplicate magazines block", () => { + renderCard([ + snapshotEvent(5, "inventorySnapshot", { + weapons: [ + { + class: "hlc_pistol_P226R_Combat", + name: "SigSauer P226R Combat", + slot: "handgun", + attachments: [], + }, + ], + magazines: [ + { class: "rhs_mag_30Rnd_556x45_Mk318_PMAG", name: "30rnd PMAG Mk318 Mod 0", count: 5 }, + { class: "ACE_16Rnd_9x19_mag", name: "9x19 mm 16Rnd Mag", count: 2 }, + { class: "hlc_15Rnd_9x19_B_P226", name: "9mm FMJ 15rnd P226 Magazine", count: 1, loadedCount: 1 }, + { class: "kat_Painkiller", name: "Painkillers", count: 3 }, + ], + assignedItems: [], + uniform: { + class: "UK3CB_BAF_U_CombatUniform_MTP_RM", + name: "Combat Uniform MTP RM [BAF]", + items: [ + { class: "ACE_MapTools", name: "Map Tools", count: 1 }, + { class: "rhs_mag_30Rnd_556x45_Mk318_PMAG", name: "30rnd PMAG Mk318 Mod 0", count: 1 }, + ], + }, + vest: { + class: "UK3CB_BAF_V_Osprey_SL_A", + name: "Osprey Mk4 MTP SL (A) [BAF]", + items: [ + { class: "ACE_CableTie", name: "Cable Tie", count: 2 }, + { class: "ACE_16Rnd_9x19_mag", name: "9x19 mm 16Rnd Mag", count: 2 }, + { class: "rhs_mag_30Rnd_556x45_Mk318_PMAG", name: "30rnd PMAG Mk318 Mod 0", count: 4 }, + ], + }, + backpack: { + class: "UK3CB_BAF_B_Bergen_MTP_SL_L_A", + name: "Bergen MTP SL L [BAF]", + items: [ + { class: "ACE_packingBandage", name: "Bandage (Packing)", count: 17 }, + { class: "kat_Painkiller", name: "Painkillers", count: 3 }, + ], + }, + }), + ]); + + const weaponsBlock = screen.getByText("Weapons").closest("[class*='gearBlock']"); + expect(weaponsBlock?.nextElementSibling?.textContent).toContain("Uniform"); + expect(weaponsBlock?.textContent).toContain("9mm FMJ 15rnd P226 Magazine"); + + const uniformBlock = screen.getByText("Combat Uniform MTP RM [BAF]").closest("[class*='gearBlock']"); + expect(uniformBlock?.textContent).toContain("Magazines"); + expect(uniformBlock?.textContent).toContain("Items"); + expect(uniformBlock?.textContent).toContain("30rnd PMAG Mk318 Mod 0"); + expect(uniformBlock?.textContent).toContain("Map Tools"); + + const vestBlock = screen.getByText("Osprey Mk4 MTP SL (A) [BAF]").closest("[class*='gearBlock']"); + expect(vestBlock?.textContent).toContain("Magazines"); + expect(vestBlock?.textContent).toContain("9x19 mm 16Rnd Mag ×2"); + expect(vestBlock?.textContent).toContain("30rnd PMAG Mk318 Mod 0 ×4"); + expect(vestBlock?.textContent).toContain("Cable Tie ×2"); + + const backpackBlock = screen.getByText("Bergen MTP SL L [BAF]").closest("[class*='gearBlock']"); + expect(backpackBlock?.textContent).toContain("Medical"); + expect(backpackBlock?.textContent).not.toContain("Magazines"); + expect(backpackBlock?.textContent).toContain("Painkillers ×3"); + expect(backpackBlock?.textContent).toContain("Bandage (Packing) ×17"); + + expect(screen.getAllByText("Magazines").every((el) => el.className.includes("sectionLabel"))).toBe(true); + }); + + it("re-splits container magazines when a later snapshot fills them in", () => { + const { engine } = renderCard( + [ + snapshotEvent(5, "inventorySnapshot", { + weapons: [], + magazines: [], + assignedItems: [], + vest: { class: "vest", name: "Osprey", items: [] }, + }), + snapshotEvent(20, "inventorySnapshot", { + weapons: [], + magazines: [{ class: "30Rnd", name: "30rnd mag", count: 4 }], + assignedItems: [], + vest: { + class: "vest", + name: "Osprey", + items: [ + { class: "ACE_CableTie", name: "Cable Tie", count: 2 }, + { class: "30Rnd", name: "30rnd mag", count: 4 }, + ], + }, + }), + ], + 5, + ); + + expect(screen.queryByText("Magazines")).toBeNull(); + expect(screen.queryByText("30rnd mag ×4")).toBeNull(); + + engine.seekTo(20); + + const vestBlock = screen.getByText("Osprey").closest("[class*='gearBlock']"); + expect(vestBlock?.textContent).toContain("Magazines"); + expect(vestBlock?.textContent).toContain("Items"); + expect(vestBlock?.textContent).toContain("30rnd mag ×4"); + expect(vestBlock?.textContent).toContain("Cable Tie ×2"); + }); + + it("splits grenades and medical inside a container and sorts each group", () => { + renderCard([ + snapshotEvent(5, "inventorySnapshot", { + weapons: [], + magazines: [ + { class: "HandGrenade", name: "M67 Fragmentation Grenade", count: 1 }, + { class: "SmokeShell", name: "M83 Smoke Grenade (White)", count: 2 }, + { class: "rhs_mag_30Rnd_556x45_Mk318_PMAG", name: "30rnd PMAG Mk318 Mod 0", count: 4 }, + { class: "kat_Painkiller", name: "Painkillers", count: 3 }, + ], + assignedItems: [], + vest: { + class: "vest", + name: "Osprey", + items: [ + { class: "ACE_CableTie", name: "Cable Tie", count: 2 }, + { class: "SmokeShell", name: "M83 Smoke Grenade (White)", count: 2 }, + { class: "ACE_splint", name: "Splint", count: 2 }, + { class: "rhs_mag_30Rnd_556x45_Mk318_PMAG", name: "30rnd PMAG Mk318 Mod 0", count: 4 }, + { class: "HandGrenade", name: "M67 Fragmentation Grenade", count: 1 }, + { class: "ACE_adenosine", name: "Adenosine Autoinjector", count: 1 }, + { class: "ACE_MapTools", name: "Map Tools", count: 1 }, + ], + }, + }), + ]); + + const vestBlock = screen.getByText("Osprey").closest("[class*='gearBlock']"); + const text = vestBlock?.textContent ?? ""; + expect(text).toContain("Magazines"); + expect(text).toContain("Grenades"); + expect(text).toContain("Medical"); + expect(text).toContain("Items"); + expect(text.indexOf("Magazines")).toBeLessThan(text.indexOf("Grenades")); + expect(text.indexOf("Grenades")).toBeLessThan(text.indexOf("Medical")); + expect(text.indexOf("Medical")).toBeLessThan(text.indexOf("Items")); + expect(text.indexOf("M67 Fragmentation Grenade")).toBeLessThan(text.indexOf("M83 Smoke Grenade (White)")); + expect(text.indexOf("Adenosine Autoinjector")).toBeLessThan(text.indexOf("Splint")); + expect(text.indexOf("Cable Tie")).toBeLessThan(text.indexOf("Map Tools")); + }); + + it("shows carried magazine rounds after deaths, skipping grenades and medical", () => { + renderCard([ + snapshotEvent(5, "inventorySnapshot", { + weapons: [], + magazines: [ + { class: "rhs_mag_30Rnd_556x45_Mk318_PMAG", name: "30rnd PMAG", count: 5, totalRounds: 120 }, + { class: "ACE_16Rnd_9x19_mag", name: "9x19 mm 16Rnd Mag", count: 2, totalRounds: 34 }, + { class: "HandGrenade", name: "M67 Fragmentation Grenade", count: 1, totalRounds: 1 }, + { class: "kat_Painkiller", name: "Painkillers", count: 3, totalRounds: 20 }, + ], + assignedItems: [], + }), + ]); + + const deaths = screen.getByText("Deaths"); + const rounds = screen.getByText("Rounds"); + expect(deaths.compareDocumentPosition(rounds) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(rounds.previousElementSibling?.textContent).toBe("154"); + }); + + it("counts recorded shots up to the playhead next to rounds", () => { + const { engine } = renderCard( + [ + snapshotEvent(5, "inventorySnapshot", { + weapons: [], + magazines: [{ class: "30Rnd", name: "30rnd mag", count: 4, totalRounds: 90 }], + assignedItems: [], + }), + ], + 10, + undefined, + { + framesFired: [ + [5, [100, 200]], + [8, [110, 210]], + [20, [120, 220]], + ], + }, + ); + + const shot = screen.getByText("Shot"); + expect(shot.previousElementSibling?.textContent).toBe("2"); + + engine.seekTo(20); + expect(shot.previousElementSibling?.textContent).toBe("3"); + }); }); diff --git a/ui/src/pages/recording-playback/__tests__/gearCategories.test.ts b/ui/src/pages/recording-playback/__tests__/gearCategories.test.ts new file mode 100644 index 000000000..01e49e653 --- /dev/null +++ b/ui/src/pages/recording-playback/__tests__/gearCategories.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import type { GearItem } from "../../../data/types"; +import { + categorizeGearItems, + classifyGearItem, + carriedMagazineRounds, + filledGearCategories, + isGrenadeItem, + isMedicalItem, + magazineClassSet, + shouldLabelGearCategory, + sortGearItems, +} from "../gearCategories"; + +const mag = (item: GearItem): GearItem => item; +const classes = (...names: string[]) => new Set(names); + +describe("gearCategories", () => { + it("treats ACE and KAT treatment items as medical even when they are magazines", () => { + expect(isMedicalItem({ class: "ACE_packingBandage", name: "Bandage (Packing)" })).toBe(true); + expect(isMedicalItem({ class: "ACE_salineIV_250", name: "Saline IV (250ml)" })).toBe(true); + expect(isMedicalItem({ class: "kat_Painkiller", name: "Painkillers" })).toBe(true); + expect(isMedicalItem({ class: "kat_chestSeal", name: "Chest Seal" })).toBe(true); + expect(isMedicalItem({ class: "FirstAidKit", name: "First Aid Kit" })).toBe(true); + expect( + classifyGearItem({ class: "kat_Painkiller", name: "Painkillers" }, classes("kat_Painkiller")), + ).toBe("medical"); + }); + + it("does not treat ACE tools as medical", () => { + expect(isMedicalItem({ class: "ACE_MapTools", name: "Map Tools" })).toBe(false); + expect(isMedicalItem({ class: "ACE_CableTie", name: "Cable Tie" })).toBe(false); + expect(isMedicalItem({ class: "ACE_EarPlugs", name: "Earplugs" })).toBe(false); + }); + + it("classifies hand grenades and smokes as grenades", () => { + expect(isGrenadeItem({ class: "HandGrenade", name: "M67 Fragmentation Grenade" })).toBe(true); + expect(isGrenadeItem({ class: "SmokeShell", name: "M83 Smoke Grenade (White)" })).toBe(true); + expect(isGrenadeItem({ class: "SmokeShellRed", name: "M18 Smoke Grenade (Red)" })).toBe(true); + expect( + isGrenadeItem({ + class: "rhs_mag_m67", + name: "M67 Frag", + picture: "\\rhsusf\\addons\\rhsusf_weapons\\icons\\grenade.paa", + }), + ).toBe(true); + expect(isGrenadeItem({ class: "ACE_Chemlight_HiWhite", name: "Chemlight (Hi White)" })).toBe(true); + }); + + it("keeps rifle magazines and UGL shells out of grenades", () => { + expect(isGrenadeItem({ class: "rhs_mag_30Rnd_556x45_Mk318_PMAG", name: "30rnd PMAG Mk318 Mod 0" })).toBe( + false, + ); + expect(isGrenadeItem({ class: "ACE_16Rnd_9x19_mag", name: "9x19 mm 16Rnd Mag" })).toBe(false); + expect(isGrenadeItem({ class: "1Rnd_Smoke_Grenade_shell", name: "Smoke Grenade 40 mm" })).toBe(false); + }); + + it("splits a vest into magazines, grenades, medical and leftover items in that order", () => { + const items: GearItem[] = [ + mag({ class: "ACE_CableTie", name: "Cable Tie", count: 2 }), + mag({ class: "HandGrenade", name: "M67 Fragmentation Grenade", count: 1 }), + mag({ class: "ACE_packingBandage", name: "Bandage (Packing)", count: 8 }), + mag({ class: "rhs_mag_30Rnd_556x45_Mk318_PMAG", name: "30rnd PMAG Mk318 Mod 0", count: 4 }), + mag({ class: "SmokeShell", name: "M83 Smoke Grenade (White)", count: 2 }), + mag({ class: "ACE_16Rnd_9x19_mag", name: "9x19 mm 16Rnd Mag", count: 2 }), + ]; + const groups = categorizeGearItems( + items, + classes( + "HandGrenade", + "SmokeShell", + "rhs_mag_30Rnd_556x45_Mk318_PMAG", + "ACE_16Rnd_9x19_mag", + ), + ); + + expect(filledGearCategories(groups)).toEqual(["magazines", "grenades", "medical", "items"]); + expect(groups.magazines.map((item) => item.class)).toEqual([ + "rhs_mag_30Rnd_556x45_Mk318_PMAG", + "ACE_16Rnd_9x19_mag", + ]); + expect(groups.grenades.map((item) => item.class)).toEqual(["HandGrenade", "SmokeShell"]); + expect(groups.medical.map((item) => item.class)).toEqual(["ACE_packingBandage"]); + expect(groups.items.map((item) => item.class)).toEqual(["ACE_CableTie"]); + }); + + it("sorts chips alphabetically by display name", () => { + expect( + sortGearItems([ + { class: "c", name: "Splint" }, + { class: "a", name: "Bandage (Packing)" }, + { class: "b", name: "Adenosine Autoinjector" }, + ]).map((item) => item.name), + ).toEqual(["Adenosine Autoinjector", "Bandage (Packing)", "Splint"]); + }); + + it("only labels leftover items when another group is present", () => { + expect(shouldLabelGearCategory("items", ["items"])).toBe(false); + expect(shouldLabelGearCategory("items", ["magazines", "items"])).toBe(true); + expect(shouldLabelGearCategory("magazines", ["magazines"])).toBe(true); + expect(shouldLabelGearCategory("grenades", ["grenades"])).toBe(true); + }); + + it("sums totalRounds from ammo magazines and skips grenades and medical", () => { + expect( + carriedMagazineRounds([ + { class: "rhs_mag_30Rnd_556x45_Mk318_PMAG", name: "30rnd PMAG", totalRounds: 120 }, + { class: "ACE_16Rnd_9x19_mag", name: "9x19 mm 16Rnd Mag", totalRounds: 34 }, + { class: "HandGrenade", name: "M67 Fragmentation Grenade", totalRounds: 1 }, + { class: "kat_Painkiller", name: "Painkillers", totalRounds: 20 }, + ]), + ).toBe(154); + expect(carriedMagazineRounds([])).toBeUndefined(); + expect( + carriedMagazineRounds([{ class: "SmokeShell", name: "M83 Smoke Grenade (White)", totalRounds: 2 }]), + ).toBeUndefined(); + }); +}); diff --git a/ui/src/pages/recording-playback/components/PlayerProfileCard.module.css b/ui/src/pages/recording-playback/components/PlayerProfileCard.module.css index 81347222a..9cf075a66 100644 --- a/ui/src/pages/recording-playback/components/PlayerProfileCard.module.css +++ b/ui/src/pages/recording-playback/components/PlayerProfileCard.module.css @@ -227,6 +227,21 @@ margin-bottom: 7px; } +.weapon + .containerSection, +.empty + .containerSection, +.containerSection + .containerSection { + margin-top: 8px; +} + +.sectionLabel { + margin-bottom: 4px; + color: var(--text-dimmest); + font-size: 9px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + .weapon b { font-size: 11px; } diff --git a/ui/src/pages/recording-playback/components/PlayerProfileCard.tsx b/ui/src/pages/recording-playback/components/PlayerProfileCard.tsx index f60baeb07..5a09d81d8 100644 --- a/ui/src/pages/recording-playback/components/PlayerProfileCard.tsx +++ b/ui/src/pages/recording-playback/components/PlayerProfileCard.tsx @@ -25,6 +25,16 @@ import { loadWorldDem, worldHasElevation, type DemGrid } from "../../../playback import { marchAcreCoverage } from "../../../playback/radioRange/acreCoverage"; import { marchCoverage } from "../../../playback/radioRange/tfarCoverage"; import { MedicalBodyImage } from "./MedicalBodyImage"; +import { + categorizeGearItems, + filledGearCategories, + GEAR_CATEGORY_LABEL_KEYS, + magazineClassSet, + magazinesNotInContainers, + carriedMagazineRounds, + shouldLabelGearCategory, + type GearCategory, +} from "../gearCategories"; import styles from "./PlayerProfileCard.module.css"; type RangeMode = "simple" | "approx"; @@ -198,15 +208,60 @@ function MedicalLogList(props: { titleKey: string; entries?: MedicalLogEntry[] } ); } -function Container(props: { label: string; container?: GearContainer }): JSX.Element { +function ContainerSection(props: { label?: string; items: GearItem[] }): JSX.Element { + return ( +
+ +
{props.label}
+
+ +
+ ); +} + +function CategorizedItemSections(props: { + items?: GearItem[]; + magazineClasses: Set; + alwaysLabel?: boolean; +}): JSX.Element { const { t } = useI18n(); + const groups = () => categorizeGearItems(props.items, props.magazineClasses); + const filled = () => filledGearCategories(groups()); + return ( + + {(key: GearCategory) => ( + + )} + + ); +} + +function Container(props: { + label: string; + container?: GearContainer; + magazineClasses: Set; +}): JSX.Element { + const { t } = useI18n(); + const groups = () => categorizeGearItems(props.container?.items, props.magazineClasses); return (
{props.label} {props.container?.name || t("profile_none")}
- + {t("profile_empty")}} + > + +
); } @@ -527,6 +582,14 @@ export function PlayerProfileCard(props: Props): JSX.Element { {t("profile_deaths")}
+
+ {number(carriedMagazineRounds(inventory()?.magazines))} + {t("profile_rounds")} +
+
+ {props.unit.firedCountThrough(engine.currentFrame())} + {t("profile_rounds_shot")} +
{props.isBlacklisted ? 0 : props.markerCount} {t("profile_markers")} @@ -601,14 +664,27 @@ export function PlayerProfileCard(props: Props): JSX.Element { {t("profile_none")} +
-
-
{t("profile_magazines")}
- -
- - - + + +
{t("profile_assigned")}
diff --git a/ui/src/pages/recording-playback/gearCategories.ts b/ui/src/pages/recording-playback/gearCategories.ts new file mode 100644 index 000000000..06fa69db6 --- /dev/null +++ b/ui/src/pages/recording-playback/gearCategories.ts @@ -0,0 +1,148 @@ +import type { GearItem } from "../../data/types"; + +export type GearCategory = "magazines" | "grenades" | "medical" | "items"; + +export const GEAR_CATEGORY_ORDER: GearCategory[] = ["magazines", "grenades", "medical", "items"]; + +export const GEAR_CATEGORY_LABEL_KEYS: Record = { + magazines: "profile_magazines", + grenades: "profile_grenades", + medical: "profile_medical_gear", + items: "profile_items", +}; + +export interface CategorizedGear { + magazines: GearItem[]; + grenades: GearItem[]; + medical: GearItem[]; + items: GearItem[]; +} + +const ACE_MEDICAL_CLASS = + /^ace_(fielddressing|packingbandage|elasticbandage|quikclot|tourniquet|morphine|epinephrine|adenosine|splint|surgicalkit|personalaidkit|bodybag|salineiv|bloodiv|plasmaiv|painkillers|suture)/; + +function classNameOf(item: GearItem): string { + return (item.class || "").toLowerCase(); +} + +function displayNameOf(item: GearItem): string { + return (item.name || "").toLowerCase(); +} + +function pictureOf(item: GearItem): string { + return (item.picture || "").replace(/\\/g, "/").toLowerCase(); +} + +/** 40 mm / UGL grenade *shells* stay with magazines; they are ammo, not throwables. */ +function isLauncherGrenadeAmmo(item: GearItem): boolean { + const cls = classNameOf(item); + return /\d+rnd/.test(cls) && /(shell|ugl|40mm|grenade_shell)/.test(cls); +} + +export function isMedicalItem(item: GearItem): boolean { + const cls = classNameOf(item); + const picture = pictureOf(item); + if (cls.startsWith("kat_")) return true; + if (cls === "firstaidkit" || cls === "medikit") return true; + if (ACE_MEDICAL_CLASS.test(cls)) return true; + if (picture.includes("medical_treatment") || picture.includes("/kat/addons/")) return true; + if (picture.includes("firstaid") || picture.includes("medikit")) return true; + return false; +} + +export function isGrenadeItem(item: GearItem): boolean { + if (isLauncherGrenadeAmmo(item)) return false; + if (isMedicalItem(item)) return false; + const cls = classNameOf(item); + const name = displayNameOf(item); + const picture = pictureOf(item); + if (/^(smokeshell|handgrenade|minigrenade)/.test(cls)) return true; + if (/(chemlight|handflare|ir_grenade|flashbang|ace_m84|ace_cts9)/.test(cls)) return true; + if (/rhs_mag_(m67|m18|an_m8|mk84|mk3a2|rgd5|rgn|rgo|rdg2|nspd|fakel|zarya)/.test(cls)) return true; + if (picture.includes("smokegrenade") || picture.includes("chemlight")) return true; + if (picture.includes("grenade") && !picture.includes("grenade_shell")) return true; + if (/\bgrenade\b/.test(name) && !/(launcher|40\s*mm)/.test(name)) return true; + if (/\bsmoke grenade\b/.test(name) || /\bchemlight\b/.test(name) || /\bstun grenade\b/.test(name)) return true; + return false; +} + +export function classifyGearItem(item: GearItem, magazineClasses: Set): GearCategory { + if (isMedicalItem(item)) return "medical"; + if (isGrenadeItem(item)) return "grenades"; + if (item.class && magazineClasses.has(item.class)) return "magazines"; + return "items"; +} + +export function sortGearItems(items: GearItem[]): GearItem[] { + return [...items].sort((a, b) => { + const byName = (a.name || a.class || "").localeCompare(b.name || b.class || "", undefined, { + sensitivity: "base", + }); + if (byName !== 0) return byName; + return (a.class || "").localeCompare(b.class || ""); + }); +} + +export function categorizeGearItems( + items: GearItem[] | undefined, + magazineClasses: Set, +): CategorizedGear { + const groups: CategorizedGear = { magazines: [], grenades: [], medical: [], items: [] }; + for (const item of items ?? []) { + groups[classifyGearItem(item, magazineClasses)].push(item); + } + for (const key of GEAR_CATEGORY_ORDER) { + groups[key] = sortGearItems(groups[key]); + } + return groups; +} + +export function filledGearCategories(groups: CategorizedGear): GearCategory[] { + return GEAR_CATEGORY_ORDER.filter((key) => groups[key].length > 0); +} + +/** Items stays unlabeled when it is the only group; named groups always keep their heading. */ +export function shouldLabelGearCategory( + key: GearCategory, + filled: GearCategory[], + alwaysLabel = false, +): boolean { + if (alwaysLabel) return true; + if (key === "items") return filled.length > 1; + return true; +} + +export function magazineClassSet(magazines?: Array<{ class: string }>): Set { + return new Set((magazines ?? []).map((item) => item.class).filter(Boolean)); +} + +export function magazinesNotInContainers(gear: { + magazines?: GearItem[]; + uniform?: { items?: GearItem[] }; + vest?: { items?: GearItem[] }; + backpack?: { items?: GearItem[] }; +}): GearItem[] { + const inContainers = new Set(); + for (const container of [gear.uniform, gear.vest, gear.backpack]) { + for (const item of container?.items ?? []) { + if (item.class) inContainers.add(item.class); + } + } + return (gear.magazines ?? []).filter((item) => item.class && !inContainers.has(item.class)); +} + +/** Sum remaining ammo in rifle/pistol magazines, skipping grenades and medical mags. */ +export function carriedMagazineRounds( + magazines?: Array, +): number | undefined { + const magClasses = magazineClassSet(magazines); + let total = 0; + let have = false; + for (const mag of magazines ?? []) { + if (classifyGearItem(mag, magClasses) !== "magazines") continue; + if (typeof mag.totalRounds !== "number") continue; + total += mag.totalRounds; + have = true; + } + return have ? total : undefined; +} diff --git a/ui/src/playback/__tests__/entityManager.test.ts b/ui/src/playback/__tests__/entityManager.test.ts index 3488b50ad..174f24b6f 100644 --- a/ui/src/playback/__tests__/entityManager.test.ts +++ b/ui/src/playback/__tests__/entityManager.test.ts @@ -300,6 +300,25 @@ describe("Unit", () => { expect(u.firedOnFrame(10)).toEqual([[700, 800]]); }); }); + + describe("firedCountThrough", () => { + it("counts shots at or before the given frame", () => { + const framesFired: Array<[number, [number, number]]> = [ + [5, [500, 600]], + [5, [510, 620]], + [12, [700, 800]], + ]; + const u = new Unit(1, "Test", "man", 0, 20, "WEST", true, "G1", "", null, "man", framesFired); + expect(u.firedCountThrough(4)).toBe(0); + expect(u.firedCountThrough(5)).toBe(2); + expect(u.firedCountThrough(12)).toBe(3); + }); + + it("returns 0 when the unit has no fire log", () => { + const u = new Unit(1, "Test", "man", 0, 20, "WEST", true, "G1", "", null, "man", null); + expect(u.firedCountThrough(10)).toBe(0); + }); + }); }); // --------------------------------------------------------------------------- diff --git a/ui/src/playback/entities/unit.ts b/ui/src/playback/entities/unit.ts index 9cd160571..b275f6d80 100644 --- a/ui/src/playback/entities/unit.ts +++ b/ui/src/playback/entities/unit.ts @@ -57,6 +57,16 @@ export class Unit extends Entity { return targets; } + /** Number of recorded shots at or before this absolute frame. */ + firedCountThrough(frame: number): number { + if (!this._framesFired) return 0; + let count = 0; + for (const [f] of this._framesFired) { + if (f <= frame) count++; + } + return count; + } + /** CSS class for the unit's side: WEST->'blufor', EAST->'opfor', etc. */ get sideClass(): string { return SIDE_CLASS[this.side] ?? "unknown"; From b9b2bcbb056319aed90f28bb886140883ec68452 Mon Sep 17 00:00:00 2001 From: Yethe Samartaka Date: Mon, 24 Aug 2026 20:19:24 +0200 Subject: [PATCH 04/39] add diffing tool for profiles --- ui/src/i18n/locales.ts | 19 + .../__tests__/PlayerProfileCard.test.tsx | 74 ++ .../__tests__/profileDiff.test.ts | 196 +++++ .../components/PlayerProfileCard.module.css | 122 +++- .../components/PlayerProfileCard.tsx | 349 +++++++-- .../components/PlayerProfileDiff.tsx | 359 ++++++++++ .../pages/recording-playback/profileDiff.ts | 678 ++++++++++++++++++ 7 files changed, 1734 insertions(+), 63 deletions(-) create mode 100644 ui/src/pages/recording-playback/__tests__/profileDiff.test.ts create mode 100644 ui/src/pages/recording-playback/components/PlayerProfileDiff.tsx create mode 100644 ui/src/pages/recording-playback/profileDiff.ts diff --git a/ui/src/i18n/locales.ts b/ui/src/i18n/locales.ts index e7b418b39..93bda0b6c 100644 --- a/ui/src/i18n/locales.ts +++ b/ui/src/i18n/locales.ts @@ -2724,4 +2724,23 @@ export const translations: Record> = { profile_approx_hint: { en: "Terrain estimate using TFAR's formula", ru: "Оценка по формуле TFAR с учётом рельефа", de: "Geländeschätzung nach TFAR-Formel", cs: "Odhad terénu podle vzorce TFAR", it: "Stima del terreno con la formula TFAR", fr: "Estimation du terrain selon la formule TFAR", fi: "Maastoarvio TFAR-kaavalla", uk: "Оцінка рельєфу за формулою TFAR" }, profile_approx_hint_acre: { en: "Terrain estimate using ACRE's loss model", ru: "Оценка по модели потерь ACRE с учётом рельефа", de: "Geländeschätzung nach ACRE-Verlustmodell", cs: "Odhad terénu podle modelu ztrát ACRE", it: "Stima del terreno con il modello di perdita ACRE", fr: "Estimation du terrain selon le modèle de pertes ACRE", fi: "Maastoarvio ACRE-häviömallilla", uk: "Оцінка рельєфу за моделлю втрат ACRE" }, profile_range_acre_note: { en: "Approximate range uses ACRE terrain loss", ru: "Приблизительный радиус использует потери ACRE на рельефе", de: "Ungefähre Reichweite nutzt ACRE-Geländeverluste", cs: "Přibližný dosah používá ztráty terénu ACRE", it: "Il raggio approssimato usa le perdite di terreno ACRE", fr: "La portée approximative utilise les pertes de terrain ACRE", fi: "Arvioitu kantama käyttää ACRE-maastohäviötä", uk: "Приблизний радіус використовує втрати ACRE на рельєфі" }, + profile_diff: { en: "Diff", ru: "Сравнить", de: "Diff", cs: "Porovnat", it: "Diff", fr: "Diff", fi: "Vertaa", uk: "Порівняти" }, + profile_diffing: { en: "Diffing", ru: "Сравнение", de: "Vergleich", cs: "Porovnání", it: "Confronto", fr: "Comparaison", fi: "Vertailu", uk: "Порівняння" }, + profile_diff_from: { en: "From", ru: "От", de: "Von", cs: "Od", it: "Da", fr: "De", fi: "Alku", uk: "Від" }, + profile_diff_to: { en: "To", ru: "До", de: "Bis", cs: "Do", it: "A", fr: "À", fi: "Loppu", uk: "До" }, + profile_diff_playhead: { en: "playhead", ru: "курсор", de: "Abspielposition", cs: "pozice přehrávání", it: "testina", fr: "tête de lecture", fi: "toistokohta", uk: "курсор" }, + profile_diff_pin_from: { en: "Pin From", ru: "Зафиксировать «от»", de: "„Von“ setzen", cs: "Pin Od", it: "Fissa Da", fr: "Épingler De", fi: "Kiinnitä alku", uk: "Зафіксувати «від»" }, + profile_diff_pin_to: { en: "Pin To", ru: "Зафиксировать «до»", de: "„Bis“ setzen", cs: "Pin Do", it: "Fissa A", fr: "Épingler À", fi: "Kiinnitä loppu", uk: "Зафіксувати «до»" }, + profile_diff_follow: { en: "Follow playhead", ru: "Следить за курсором", de: "Abspielposition folgen", cs: "Sledovat přehrávání", it: "Segui testina", fr: "Suivre la tête", fi: "Seuraa toistoa", uk: "Стежити за курсором" }, + profile_diff_swap: { en: "Swap", ru: "Поменять", de: "Tauschen", cs: "Prohodit", it: "Inverti", fr: "Inverser", fi: "Vaihda", uk: "Поміняти" }, + profile_diff_same: { en: "Scrub the timeline to pick the other time.", ru: "Перемотайте запись, чтобы выбрать второе время.", de: "Spule die Timeline, um den anderen Zeitpunkt zu wählen.", cs: "Posuňte přehrávání a vyberte druhý čas.", it: "Scorri la timeline per scegliere l'altro momento.", fr: "Déplacez la tête de lecture pour choisir l'autre instant.", fi: "Siirrä aikajanaa valitaksesi toisen ajan.", uk: "Перемотайте запис, щоб вибрати інший момент." }, + profile_diff_none: { en: "No changes in this tab.", ru: "В этой вкладке нет изменений.", de: "Keine Änderungen in diesem Tab.", cs: "Na této kartě nejsou žádné změny.", it: "Nessuna modifica in questa scheda.", fr: "Aucun changement dans cet onglet.", fi: "Ei muutoksia tällä välilehdellä.", uk: "На цій вкладці немає змін." }, + profile_diff_net: { en: "Net changes", ru: "Итог", de: "Nettoänderung", cs: "Čistá změna", it: "Variazione netta", fr: "Solde", fi: "Nettomuutos", uk: "Підсумок" }, + profile_diff_moved: { en: "Moved", ru: "Перемещено", de: "Verschoben", cs: "Přesunuto", it: "Spostato", fr: "Déplacé", fi: "Siirretty", uk: "Переміщено" }, + profile_diff_rds: { en: "{count} rds", ru: "{count} патр.", de: "{count} Schuss", cs: "{count} nábojů", it: "{count} colpi", fr: "{count} cps", fi: "{count} ls", uk: "{count} наб." }, + profile_diff_moved_path: { en: "{from} → {to}", ru: "{from} → {to}", de: "{from} → {to}", cs: "{from} → {to}", it: "{from} → {to}", fr: "{from} → {to}", fi: "{from} → {to}", uk: "{from} → {to}" }, + profile_diff_channel: { en: "Channel", ru: "Канал", de: "Kanal", cs: "Kanál", it: "Canale", fr: "Canal", fi: "Kanava", uk: "Канал" }, + profile_diff_frequency: { en: "Frequency", ru: "Частота", de: "Frequenz", cs: "Frekvence", it: "Frequenza", fr: "Fréquence", fi: "Taajuus", uk: "Частота" }, + profile_diff_code: { en: "Code", ru: "Код", de: "Code", cs: "Kód", it: "Codice", fr: "Code", fi: "Koodi", uk: "Код" }, + profile_diff_new_log: { en: "New since then", ru: "Новое с тех пор", de: "Neu seitdem", cs: "Nové od té doby", it: "Nuovo da allora", fr: "Nouveau depuis", fi: "Uutta sen jälkeen", uk: "Нове відтоді" }, }; diff --git a/ui/src/pages/recording-playback/__tests__/PlayerProfileCard.test.tsx b/ui/src/pages/recording-playback/__tests__/PlayerProfileCard.test.tsx index f4beeea7e..280312814 100644 --- a/ui/src/pages/recording-playback/__tests__/PlayerProfileCard.test.tsx +++ b/ui/src/pages/recording-playback/__tests__/PlayerProfileCard.test.tsx @@ -700,4 +700,78 @@ describe("PlayerProfileCard", () => { engine.seekTo(20); expect(shot.previousElementSibling?.textContent).toBe("3"); }); + + it("compares two times after Diff is pinned and the playhead moves", () => { + const { engine } = renderCard( + [ + snapshotEvent(5, "inventorySnapshot", { + weapons: [], + magazines: [{ class: "30Rnd", name: "30rnd mag", count: 5, totalRounds: 120 }], + assignedItems: [], + vest: { + class: "v", + name: "Vest", + items: [{ class: "30Rnd", name: "30rnd mag", count: 5 }], + }, + }), + snapshotEvent(20, "inventorySnapshot", { + weapons: [], + magazines: [{ class: "30Rnd", name: "30rnd mag", count: 2, totalRounds: 40 }], + assignedItems: [], + vest: { + class: "v", + name: "Vest", + items: [{ class: "30Rnd", name: "30rnd mag", count: 2 }], + }, + }), + ], + 5, + ); + + fireEvent.click(screen.getByRole("button", { name: "Diff" })); + expect(screen.getByRole("button", { name: "Diffing" })).toBeTruthy(); + expect(screen.getByText("Scrub the timeline to pick the other time.")).toBeTruthy(); + + engine.seekTo(20); + expect(screen.getByText("Net changes")).toBeTruthy(); + expect(screen.getByText("30rnd mag ×5 → ×2 · 120 → 40 rds")).toBeTruthy(); + expect(screen.queryByText("30rnd mag ×5 → ×2")).toBeNull(); + }); + + it("reports a vest-to-backpack shuffle as moved instead of gained and lost", () => { + const { engine } = renderCard( + [ + snapshotEvent(5, "inventorySnapshot", { + weapons: [], + magazines: [], + assignedItems: [], + vest: { + class: "v", + name: "Vest", + items: [{ class: "ACE_MapTools", name: "Map Tools", count: 1 }], + }, + backpack: { class: "b", name: "Pack", items: [] }, + }), + snapshotEvent(20, "inventorySnapshot", { + weapons: [], + magazines: [], + assignedItems: [], + vest: { class: "v", name: "Vest", items: [] }, + backpack: { + class: "b", + name: "Pack", + items: [{ class: "ACE_MapTools", name: "Map Tools", count: 1 }], + }, + }), + ], + 5, + ); + + fireEvent.click(screen.getByRole("button", { name: "Diff" })); + engine.seekTo(20); + expect(screen.getByText("Moved")).toBeTruthy(); + expect(screen.getByText(/Map Tools/)).toBeTruthy(); + expect(screen.getByText(/Vest → Backpack/)).toBeTruthy(); + expect(screen.queryByText("Net changes")).toBeNull(); + }); }); diff --git a/ui/src/pages/recording-playback/__tests__/profileDiff.test.ts b/ui/src/pages/recording-playback/__tests__/profileDiff.test.ts new file mode 100644 index 000000000..0de3d4c17 --- /dev/null +++ b/ui/src/pages/recording-playback/__tests__/profileDiff.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "vitest"; +import type { InventorySnapshot, MedicalSnapshot, RadioSnapshot, StaminaSnapshot } from "../../../data/types"; +import { + diffGear, + diffMedical, + diffRadios, + diffStamina, + gearDiffIsEmpty, + medicalDiffIsEmpty, +} from "../profileDiff"; + +function inventory(overrides: Partial = {}): InventorySnapshot { + return { + unitId: 1, + load: 0.4, + weapons: [], + magazines: [], + assignedItems: [], + uniform: { class: "u", name: "Uniform", items: [] }, + vest: { class: "v", name: "Vest", items: [] }, + backpack: { class: "b", name: "Pack", items: [] }, + headgear: { class: "", name: "" }, + goggles: { class: "", name: "" }, + ...overrides, + }; +} + +describe("diffGear", () => { + it("summarizes ammo spent as a net magazine change with round counts", () => { + const from = inventory({ + magazines: [{ class: "30Rnd", name: "30rnd mag", count: 5, totalRounds: 120 }], + vest: { + class: "v", + name: "Vest", + items: [{ class: "30Rnd", name: "30rnd mag", count: 4 }], + }, + }); + const to = inventory({ + magazines: [{ class: "30Rnd", name: "30rnd mag", count: 3, totalRounds: 47 }], + vest: { + class: "v", + name: "Vest", + items: [{ class: "30Rnd", name: "30rnd mag", count: 2 }], + }, + }); + + const diff = diffGear(from, to); + expect(diff.net).toEqual([ + expect.objectContaining({ + class: "30Rnd", + fromCount: 5, + toCount: 3, + fromRounds: 120, + toRounds: 47, + polarity: "changed", + }), + ]); + expect(diff.moved).toEqual([]); + }); + + it("treats a pocket-to-pocket shuffle with the same count as a move, not a gain/loss", () => { + const from = inventory({ + vest: { + class: "v", + name: "Vest", + items: [{ class: "ACE_MapTools", name: "Map Tools", count: 1 }], + }, + }); + const to = inventory({ + backpack: { + class: "b", + name: "Pack", + items: [{ class: "ACE_MapTools", name: "Map Tools", count: 1 }], + }, + }); + + const diff = diffGear(from, to); + expect(diff.net).toEqual([]); + expect(diff.moved).toEqual([ + { class: "ACE_MapTools", name: "Map Tools", count: 1, from: "vest", to: "backpack" }, + ]); + expect(diff.locations.every((location) => location.items.length === 0)).toBe(true); + }); + + it("reports a weapon swap on the same slot", () => { + const diff = diffGear( + inventory({ + weapons: [{ class: "arifle_MX", name: "MX", slot: "primary", attachments: [] }], + }), + inventory({ + weapons: [{ class: "arifle_AK12", name: "AK-12", slot: "primary", attachments: [] }], + }), + ); + + expect(diff.weapons).toEqual([ + expect.objectContaining({ + slot: "primary", + fromName: "MX", + toName: "AK-12", + polarity: "changed", + }), + ]); + }); + + it("is empty when the reconstructed kit did not change", () => { + const kit = inventory({ + magazines: [{ class: "30Rnd", name: "30rnd mag", count: 2, totalRounds: 60 }], + vest: { class: "v", name: "Vest", items: [{ class: "30Rnd", name: "30rnd mag", count: 2 }] }, + }); + expect(gearDiffIsEmpty(diffGear(kit, kit))).toBe(true); + }); +}); + +describe("diffMedical", () => { + it("keeps vitals that only jitter below display precision out of the diff", () => { + const from: MedicalSnapshot = { + unitId: 1, + vanilla: { damage: 0 }, + ace: { bloodVolume: 5.101, heartRate: 80.2, pain: 0.201 }, + }; + const to: MedicalSnapshot = { + unitId: 1, + vanilla: { damage: 0 }, + ace: { bloodVolume: 5.104, heartRate: 80.4, pain: 0.204 }, + }; + expect(medicalDiffIsEmpty(diffMedical(from, to))).toBe(true); + }); + + it("surfaces new activity-log lines and body-part treatments", () => { + const from: MedicalSnapshot = { + unitId: 1, + vanilla: { damage: 0 }, + ace: { bloodVolume: 6, heartRate: 80 }, + bodyParts: [{ part: "body", damage: 0.1, items: [] }], + activity: [{ time: "10:00", text: "Checked pulse" }], + }; + const to: MedicalSnapshot = { + unitId: 1, + vanilla: { damage: 0 }, + ace: { bloodVolume: 5.2, heartRate: 130 }, + bodyParts: [ + { + part: "body", + damage: 0.45, + items: [{ kind: "bandage", name: "Bandaged wound", count: 1 }], + }, + ], + activity: [ + { time: "10:00", text: "Checked pulse" }, + { time: "10:02", text: "Applied packing bandage" }, + ], + }; + + const diff = diffMedical(from, to); + expect(diff.fields.some((field) => field.labelKey === "profile_blood")).toBe(true); + expect(diff.fields.some((field) => field.labelKey === "profile_heart_rate")).toBe(true); + expect(diff.bodyParts[0]?.items[0]?.name).toBe("Bandaged wound"); + expect(diff.activity).toEqual([{ time: "10:02", text: "Applied packing bandage" }]); + }); +}); + +describe("diffStamina", () => { + it("diffs load-adjusted weight after rounding to one decimal", () => { + const from: StaminaSnapshot = { unitId: 1, vanilla: { massUnits: 545, load: 0.545 } }; + const to: StaminaSnapshot = { unitId: 1, vanilla: { massUnits: 400, load: 0.4 } }; + const fields = diffStamina(from, to); + expect(fields.some((field) => field.labelKey === "profile_carried_weight")).toBe(true); + expect(fields.some((field) => field.labelKey === "profile_load")).toBe(true); + }); +}); + +describe("diffRadios", () => { + it("treats a frequency change on the same radio as a change, not a swap", () => { + const radio = (frequency: number): RadioSnapshot => ({ + unitId: 1, + radios: [ + { + class: "TFAR_anprc152_1", + name: "AN/PRC-152", + mod: "TFAR", + type: "SW", + channel: 1, + frequency, + code: "enc", + rangeMeters: 5000, + additional: false, + }, + ], + }); + + const diff = diffRadios(radio(472.8), radio(101)); + expect(diff.radios).toHaveLength(1); + expect(diff.radios[0]?.polarity).toBe("changed"); + expect(diff.radios[0]?.fields.some((field) => field.labelKey === "profile_diff_frequency")).toBe(true); + }); +}); diff --git a/ui/src/pages/recording-playback/components/PlayerProfileCard.module.css b/ui/src/pages/recording-playback/components/PlayerProfileCard.module.css index 9cf075a66..b84dddc17 100644 --- a/ui/src/pages/recording-playback/components/PlayerProfileCard.module.css +++ b/ui/src/pages/recording-playback/components/PlayerProfileCard.module.css @@ -105,7 +105,7 @@ } .metrics strong, -.metrics span { +.metrics > div > span { display: block; } @@ -114,7 +114,7 @@ font-size: 15px; } -.metrics span { +.metrics > div > span { margin-top: 2px; color: var(--text-dimmest); font-size: 9px; @@ -432,6 +432,115 @@ font-size: 9px; } +.radioRow .dataGrid { + grid-column: 1 / -1; +} + +.diffBar { + display: grid; + grid-template-columns: 1fr auto 1fr auto; + gap: 8px; + align-items: end; + padding: 8px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + background: color-mix(in srgb, var(--accent-primary) 6%, transparent); +} + +.diffPin { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.diffPin span { + color: var(--text-dimmest); + font-size: 9px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.diffPin strong { + color: var(--text-primary); + font-size: 13px; + font-variant-numeric: tabular-nums; +} + +.diffPin button, +.diffSwap { + padding: 4px 7px; + color: var(--text-muted); + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 6px; + font: inherit; + font-size: 10px; + cursor: pointer; +} + +.diffArrow { + padding-bottom: 18px; + color: var(--text-dimmer); +} + +.chipAdded { + color: var(--accent-success); + background: color-mix(in srgb, var(--accent-success) 14%, transparent); +} + +.chipRemoved { + color: var(--accent-danger); + background: color-mix(in srgb, var(--accent-danger) 14%, transparent); +} + +.chipChanged { + color: var(--accent-warning); + background: color-mix(in srgb, var(--accent-warning) 14%, transparent); +} + +.equippedDiff { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 6px; +} + +.fieldDelta { + font-variant-numeric: tabular-nums; +} + +.metricPair { + font-size: 12px; + line-height: 1.2; +} + +.metricDelta { + display: block; + margin-top: 2px; + font-size: 9px; + text-transform: none; +} + +.metricAdded { + color: var(--accent-success); +} + +.metricRemoved { + color: var(--accent-danger); +} + +.diffAdded { + box-shadow: inset 3px 0 0 var(--accent-success); +} + +.diffRemoved { + box-shadow: inset 3px 0 0 var(--accent-danger); +} + +.diffChanged { + box-shadow: inset 3px 0 0 var(--accent-warning); +} + @media (max-width: 900px) { .card { left: 8px; @@ -440,4 +549,13 @@ width: auto; bottom: calc(var(--pb-bottom-height, 90px) + 8px); } + + .diffBar { + grid-template-columns: 1fr 1fr; + } + + .diffArrow, + .diffSwap { + display: none; + } } diff --git a/ui/src/pages/recording-playback/components/PlayerProfileCard.tsx b/ui/src/pages/recording-playback/components/PlayerProfileCard.tsx index 5a09d81d8..75d388a7e 100644 --- a/ui/src/pages/recording-playback/components/PlayerProfileCard.tsx +++ b/ui/src/pages/recording-playback/components/PlayerProfileCard.tsx @@ -18,6 +18,7 @@ import { useI18n } from "../../../hooks/useLocale"; import { useRenderer } from "../../../hooks/useRenderer"; import type { Unit } from "../../../playback/entities/unit"; import { PlayerSnapshotEvent } from "../../../playback/events/playerSnapshotEvent"; +import { formatTime } from "../../../playback/time"; import type { BriefingMarkerHandle } from "../../../renderers/renderer.types"; import { SIDE_COLORS_BRIGHT, SIDE_COLORS_UI } from "../../../config/sideColors"; import { BODY_PART_IDS, type BodyPartId } from "../medical/bodyImage"; @@ -25,6 +26,14 @@ import { loadWorldDem, worldHasElevation, type DemGrid } from "../../../playback import { marchAcreCoverage } from "../../../playback/radioRange/acreCoverage"; import { marchCoverage } from "../../../playback/radioRange/tfarCoverage"; import { MedicalBodyImage } from "./MedicalBodyImage"; +import { + DiffEmpty, + formatSigned, + GearDiffView, + MedicalDiffView, + RadioDiffView, + StaminaDiffView, +} from "./PlayerProfileDiff"; import { categorizeGearItems, filledGearCategories, @@ -35,6 +44,16 @@ import { shouldLabelGearCategory, type GearCategory, } from "../gearCategories"; +import { + diffGear, + diffMedical, + diffRadios, + diffStamina, + gearDiffIsEmpty, + medicalDiffIsEmpty, + radioDiffIsEmpty, + staminaReserve as reserveAt, +} from "../profileDiff"; import styles from "./PlayerProfileCard.module.css"; type RangeMode = "simple" | "approx"; @@ -276,6 +295,9 @@ export function PlayerProfileCard(props: Props): JSX.Element { const [demFailed, setDemFailed] = createSignal(false); const [tab, setTab] = createSignal("gear"); const [selectedPart, setSelectedPart] = createSignal(null); + const [diffing, setDiffing] = createSignal(false); + const [fromFrame, setFromFrame] = createSignal(0); + const [toPinned, setToPinned] = createSignal(null); const approxAvailable = createMemo(() => { if (demFailed()) return false; @@ -315,6 +337,8 @@ export function PlayerProfileCard(props: Props): JSX.Element { props.unit.id; setSelectedPart(null); setRangeRadio(null); + setDiffing(false); + setToPinned(null); }); const trackedKinds = createMemo(() => { @@ -342,14 +366,50 @@ export function PlayerProfileCard(props: Props): JSX.Element { engine.currentFrame(); return engine.eventManager.getPlayerSnapshots(props.unit.id, engine.currentFrame()); }); + const toFrame = () => toPinned() ?? engine.currentFrame(); + const snapshotsAt = (frame: number) => engine.eventManager.getPlayerSnapshots(props.unit.id, frame); const snapshotEvent = (type: PlayerSnapshotType): PlayerSnapshotEvent | undefined => snapshots().get(type); const payload = (type: PlayerSnapshotType): T | undefined => snapshotEvent(type)?.payload as T | undefined; + const payloadAt = (frame: number, type: PlayerSnapshotType): T | undefined => + snapshotsAt(frame).get(type)?.payload as T | undefined; const inventory = () => payload("inventorySnapshot"); const medical = () => payload("medicalSnapshot"); const stamina = () => payload("staminaSnapshot"); const radio = () => payload("radioSnapshot"); + const fromInventory = () => payloadAt(fromFrame(), "inventorySnapshot"); + const toInventory = () => payloadAt(toFrame(), "inventorySnapshot"); + const fromMedical = () => payloadAt(fromFrame(), "medicalSnapshot"); + const toMedical = () => payloadAt(toFrame(), "medicalSnapshot"); + const fromStamina = () => payloadAt(fromFrame(), "staminaSnapshot"); + const toStamina = () => payloadAt(toFrame(), "staminaSnapshot"); + const fromRadio = () => payloadAt(fromFrame(), "radioSnapshot"); + const toRadio = () => payloadAt(toFrame(), "radioSnapshot"); + const sameDiffFrame = () => diffing() && fromFrame() === toFrame(); + const gearChanges = createMemo(() => diffGear(fromInventory(), toInventory())); + const medicalChanges = createMemo(() => diffMedical(fromMedical(), toMedical())); + const staminaChanges = createMemo(() => + diffStamina(fromStamina(), toStamina(), fromInventory(), toInventory()), + ); + const radioChanges = createMemo(() => diffRadios(fromRadio(), toRadio())); + const frameLabel = (frame: number) => formatTime(frame, "elapsed", engine.timeConfig); + const toggleDiff = () => { + if (diffing()) { + setDiffing(false); + setToPinned(null); + return; + } + setFromFrame(engine.currentFrame()); + setToPinned(null); + setDiffing(true); + }; + const swapDiff = () => { + const from = fromFrame(); + const to = toFrame(); + setFromFrame(to); + setToPinned(from); + }; const radioKey = (entry: RadioSnapshotEntry, index: number) => `${index}:${entry.type}:${entry.frequency}:${entry.additional}`; @@ -528,6 +588,52 @@ export function PlayerProfileCard(props: Props): JSX.Element { props.unit.name ? t("profile_aria_named", { name: props.unit.name }) : t("profile_aria", { id: props.unit.id }); + const killsAt = (frame: number) => + engine.eventManager.getKillDeathCounts(frame).kills.get(props.unit.id) ?? 0; + const deathsAt = (frame: number) => + engine.eventManager.getKillDeathCounts(frame).deaths.get(props.unit.id) ?? 0; + const metricNumber = ( + fromValue: number | undefined, + toValue: number | undefined, + digits = 0, + asPercent = false, + ): JSX.Element => { + const fmt = (value: number | undefined) => { + if (value === undefined) return "—"; + return asPercent ? percent(value) : number(value, digits); + }; + if (!diffing()) return <>{fmt(toValue)}; + const fromShown = fmt(fromValue); + const toShown = fmt(toValue); + if (fromShown === toShown) return <>{toShown}; + if (fromValue === undefined || toValue === undefined) { + return ( + + {fromShown} → {toShown} + + ); + } + const delta = toValue - fromValue; + const deltaLabel = asPercent + ? `${formatSigned(Math.round(delta * 100))}%` + : formatSigned(delta, digits); + return ( + <> + + {fromShown} → {toShown} + + 0, + [styles.metricRemoved]: delta < 0, + }} + > + {deltaLabel} + + + ); + }; return (
); diff --git a/ui/src/pages/recording-playback/components/PlayerProfileDiff.tsx b/ui/src/pages/recording-playback/components/PlayerProfileDiff.tsx new file mode 100644 index 000000000..8213fbb67 --- /dev/null +++ b/ui/src/pages/recording-playback/components/PlayerProfileDiff.tsx @@ -0,0 +1,359 @@ +import { For, Show } from "solid-js"; +import type { JSX } from "solid-js"; +import type { MedicalLogEntry } from "../../../data/types"; +import { useI18n } from "../../../hooks/useLocale"; +import { + GEAR_LOCATION_LABEL_KEYS, + type BodyPartDelta, + type DiffPolarity, + type EquippedDelta, + type FieldDelta, + type GearDiff, + type ItemDelta, + type MedicalDiff, + type MovedItem, + type RadioDiff, + type WeaponDelta, +} from "../profileDiff"; +import styles from "./PlayerProfileCard.module.css"; + +const BODY_PART_KEYS: Record = { + head: "profile_part_head", + body: "profile_part_body", + leftarm: "profile_part_leftarm", + rightarm: "profile_part_rightarm", + leftleg: "profile_part_leftleg", + rightleg: "profile_part_rightleg", +}; +const HEMORRHAGE_KEYS = [ + "profile_hemorrhage_0", + "profile_hemorrhage_1", + "profile_hemorrhage_2", + "profile_hemorrhage_3", + "profile_hemorrhage_4", +] as const; + +export function formatSigned(value: number, digits = 0): string { + const absolute = digits > 0 ? Math.abs(value).toFixed(digits) : String(Math.abs(Math.round(value))); + if (value > 0) return `+${absolute}`; + if (value < 0) return `−${absolute}`; + return absolute; +} + +function polarityClass(polarity: DiffPolarity): string { + if (polarity === "added") return styles.chipAdded; + if (polarity === "removed") return styles.chipRemoved; + return styles.chipChanged; +} + +function formatNumber(value: unknown, digits = 0): string { + return typeof value === "number" ? value.toFixed(digits) : "—"; +} + +function formatPercent(value: unknown): string { + return typeof value === "number" ? `${Math.round(value * 100)}%` : "—"; +} + +function formatBp(value: unknown, noneLabel: string): string { + if (!Array.isArray(value) || value.length < 2) return "—"; + const diastolic = value[0]; + const systolic = value[1]; + if (typeof systolic !== "number" || typeof diastolic !== "number") return "—"; + if (systolic <= 0 && diastolic <= 0) return noneLabel; + return `${Math.round(systolic)}/${Math.round(diastolic)}`; +} + +function formatField( + field: FieldDelta, + t: (key: string, params?: Record) => string, +): { from: string; to: string } { + const format = (value: unknown): string => { + if (value === undefined || value === null || value === "") return t("profile_none"); + switch (field.kind) { + case "percent": + return formatPercent(value); + case "number": + return `${formatNumber(value, field.digits ?? 0)}${field.suffix ?? ""}`; + case "bool": + return value === true ? t("profile_yes") : value === false ? t("profile_no") : String(value); + case "bp": + return formatBp(value, t("profile_no_blood_pressure")); + case "hemorrhage": { + const index = Number(value ?? 0); + return HEMORRHAGE_KEYS[index] ? t(HEMORRHAGE_KEYS[index]) : formatNumber(value); + } + default: + return String(value); + } + }; + return { from: format(field.from), to: format(field.to) }; +} + +function itemLabel(item: ItemDelta, t: (key: string, params?: Record) => string): string { + const count = + item.polarity === "added" + ? `×${item.toCount}` + : item.polarity === "removed" + ? `×${item.fromCount}` + : `×${item.fromCount} → ×${item.toCount}`; + const prefix = item.polarity === "added" ? "+ " : item.polarity === "removed" ? "− " : ""; + let rounds = ""; + if (item.fromRounds !== undefined || item.toRounds !== undefined) { + if (item.polarity === "added" && item.toRounds !== undefined) { + rounds = ` · ${t("profile_diff_rds", { count: item.toRounds })}`; + } else if (item.polarity === "removed" && item.fromRounds !== undefined) { + rounds = ` · ${t("profile_diff_rds", { count: item.fromRounds })}`; + } else if (item.fromRounds !== item.toRounds) { + rounds = ` · ${t("profile_diff_rds", { count: `${item.fromRounds ?? "—"} → ${item.toRounds ?? "—"}` })}`; + } + } + return `${prefix}${item.name} ${count}${rounds}`; +} + +function DiffChip(props: { polarity: DiffPolarity; children: JSX.Element; title?: string }): JSX.Element { + return ( + + {props.children} + + ); +} + +function ItemDeltaList(props: { items: ItemDelta[] }): JSX.Element { + const { t } = useI18n(); + return ( +
+ + {(item) => ( + + {itemLabel(item, t)} + + )} + +
+ ); +} + +function EquippedLine(props: { entry: EquippedDelta }): JSX.Element { + const { t } = useI18n(); + return ( +
+ {t(props.entry.slotKey)} + + {props.entry.fromName || t("profile_none")} → {props.entry.toName || t("profile_none")} + +
+ ); +} + +function WeaponDiff(props: { weapon: WeaponDelta }): JSX.Element { + const { t } = useI18n(); + const swapped = () => props.weapon.fromName !== props.weapon.toName; + return ( +
+ + {swapped() + ? `${props.weapon.fromName || t("profile_none")} → ${props.weapon.toName || t("profile_none")}` + : props.weapon.toName || props.weapon.fromName} + + {props.weapon.slot} + + + +
+ ); +} + +function MovedList(props: { items: MovedItem[] }): JSX.Element { + const { t } = useI18n(); + return ( +
+ + {(item) => ( + + {item.name} + {item.count > 1 ? ` ×${item.count}` : ""} ·{" "} + {t("profile_diff_moved_path", { + from: t(GEAR_LOCATION_LABEL_KEYS[item.from]), + to: t(GEAR_LOCATION_LABEL_KEYS[item.to]), + })} + + )} + +
+ ); +} + +export function GearDiffView(props: { diff: GearDiff }): JSX.Element { + const { t } = useI18n(); + return ( + <> + +
+
{t("profile_diff_net")}
+ +
+
+ +
+
{t("profile_diff_moved")}
+ +
+
+ +
+ {(entry) => } +
+
+ +
+
{t("profile_weapons")}
+ {(weapon) => } +
+
+ + {(location) => ( + +
+
+ {t(GEAR_LOCATION_LABEL_KEYS[location.location])} + + {(entry) => ( + + {entry().fromName || t("profile_none")} → {entry().toName || t("profile_none")} + + )} + +
+ + + +
+
+ )} +
+ + ); +} + +function FieldDiffGrid(props: { fields: FieldDelta[] }): JSX.Element { + const { t } = useI18n(); + return ( +
+ + {(entry) => { + const formatted = () => formatField(entry, t); + return ( + + {t(entry.labelKey)} + + {formatted().from} → {formatted().to} + + + ); + }} + +
+ ); +} + +function BodyPartDiff(props: { part: BodyPartDelta }): JSX.Element { + const { t } = useI18n(); + const label = () => (BODY_PART_KEYS[props.part.part] ? t(BODY_PART_KEYS[props.part.part]) : props.part.part); + const damage = () => (props.part.damage ? formatField(props.part.damage, t) : undefined); + return ( +
+ {label()} + + {(pair) => {t("profile_dmg", { value: `${pair().from} → ${pair().to}` })}} + + + + +
+ ); +} + +function LogDiff(props: { titleKey: string; entries: MedicalLogEntry[] }): JSX.Element { + const { t } = useI18n(); + return ( +
+
{t(props.titleKey)}
+ + {(entry) => ( +
+ + {entry.text} +
+ )} +
+
+ ); +} + +export function MedicalDiffView(props: { diff: MedicalDiff }): JSX.Element { + const { t } = useI18n(); + return ( + <> + + + + +
{t("profile_body_parts")}
+
+ {(part) => } +
+
+ +
+
{t("profile_in_system")}
+ +
+
+ +
+ + + + + + +
+
+ + ); +} + +export function StaminaDiffView(props: { fields: FieldDelta[] }): JSX.Element { + return ; +} + +export function RadioDiffView(props: { diff: RadioDiff }): JSX.Element { + return ( + + {(radio) => ( +
+
+ {radio.name} +
+ + + +
+ )} +
+ ); +} + +export function DiffEmpty(props: { sameFrame: boolean }): JSX.Element { + const { t } = useI18n(); + return ( +
{props.sameFrame ? t("profile_diff_same") : t("profile_diff_none")}
+ ); +} diff --git a/ui/src/pages/recording-playback/profileDiff.ts b/ui/src/pages/recording-playback/profileDiff.ts new file mode 100644 index 000000000..e26499f19 --- /dev/null +++ b/ui/src/pages/recording-playback/profileDiff.ts @@ -0,0 +1,678 @@ +import type { + GearContainer, + GearItem, + InventorySnapshot, + MedicalBodyPart, + MedicalLogEntry, + MedicalSnapshot, + RadioSnapshot, + RadioSnapshotEntry, + StaminaSnapshot, + TreatmentItem, +} from "../../data/types"; +import { BODY_PART_IDS } from "./medical/bodyImage"; +import { classifyGearItem, magazineClassSet, magazinesNotInContainers } from "./gearCategories"; + +export type DiffPolarity = "added" | "removed" | "changed"; +export type GearLocation = "uniform" | "vest" | "backpack" | "assigned" | "loaded"; + +export const GEAR_LOCATION_LABEL_KEYS: Record = { + uniform: "profile_uniform", + vest: "profile_vest", + backpack: "profile_backpack", + assigned: "profile_assigned", + loaded: "profile_weapons", +}; + +export interface ItemDelta { + polarity: DiffPolarity; + class: string; + name: string; + fromCount: number; + toCount: number; + fromRounds?: number; + toRounds?: number; +} + +export interface MovedItem { + class: string; + name: string; + count: number; + from: GearLocation; + to: GearLocation; +} + +export interface EquippedDelta { + polarity: DiffPolarity; + slotKey: "profile_head" | "profile_face" | "profile_uniform" | "profile_vest" | "profile_backpack"; + fromName: string; + toName: string; +} + +export interface WeaponDelta { + polarity: DiffPolarity; + slot: string; + fromName: string; + toName: string; + attachments: ItemDelta[]; +} + +export interface LocationDelta { + location: GearLocation; + container?: EquippedDelta; + items: ItemDelta[]; +} + +export interface GearDiff { + net: ItemDelta[]; + moved: MovedItem[]; + equipped: EquippedDelta[]; + weapons: WeaponDelta[]; + locations: LocationDelta[]; +} + +export interface FieldDelta { + labelKey: string; + kind: "text" | "number" | "percent" | "bool" | "bp" | "hemorrhage"; + from: unknown; + to: unknown; + digits?: number; + suffix?: string; +} + +export interface BodyPartDelta { + part: string; + damage?: FieldDelta; + items: ItemDelta[]; +} + +export interface MedicalDiff { + fields: FieldDelta[]; + bodyParts: BodyPartDelta[]; + treatments: ItemDelta[]; + activity: MedicalLogEntry[]; + quickView: MedicalLogEntry[]; +} + +export interface RadioDelta { + polarity: DiffPolarity; + name: string; + fields: FieldDelta[]; +} + +export interface RadioDiff { + radios: RadioDelta[]; +} + +export interface MetricDelta { + id: "kills" | "deaths" | "rounds" | "shot" | "weight" | "stamina" | "load"; + from: number | undefined; + to: number | undefined; +} + +const MASS_UNIT_KG = 0.0453592; +const STAMINA_DURATION = 60; + +interface StockEntry { + class: string; + name: string; + count: number; + location: GearLocation; +} + +function itemCount(item?: GearItem): number { + if (!item?.class) return 0; + return item.count ?? 1; +} + +function itemName(item?: GearItem, fallback = ""): string { + return item?.name || item?.class || fallback; +} + +function addStock(target: StockEntry[], location: GearLocation, items?: GearItem[]): void { + for (const item of items ?? []) { + if (!item.class) continue; + const existing = target.find((entry) => entry.location === location && entry.class === item.class); + if (existing) { + existing.count += itemCount(item); + if (!existing.name && item.name) existing.name = item.name; + } else { + target.push({ class: item.class, name: itemName(item), count: itemCount(item), location }); + } + } +} + +function stockFromInventory(inv?: InventorySnapshot): StockEntry[] { + if (!inv) return []; + const magClasses = magazineClassSet(inv.magazines); + const stock: StockEntry[] = []; + addStock(stock, "uniform", inv.uniform?.items); + addStock(stock, "vest", inv.vest?.items); + addStock(stock, "backpack", inv.backpack?.items); + addStock(stock, "assigned", inv.assignedItems); + addStock(stock, "loaded", magazinesNotInContainers(inv).filter((item) => magClasses.has(item.class))); + return stock; +} + +function ammoRounds( + cls: string, + name: string, + stats: Map, + magClasses: Set, +): number | undefined { + if (!magClasses.has(cls)) return undefined; + if (classifyGearItem({ class: cls, name }, magClasses) !== "magazines") return undefined; + return stats.get(cls)?.totalRounds; +} +function magazineStats( + inv?: InventorySnapshot, +): Map { + const stats = new Map(); + for (const mag of inv?.magazines ?? []) { + if (!mag.class) continue; + const prev = stats.get(mag.class); + const totalRounds = + typeof mag.totalRounds === "number" + ? (prev?.totalRounds ?? 0) + mag.totalRounds + : prev?.totalRounds; + stats.set(mag.class, { + name: itemName(mag), + count: (prev?.count ?? 0) + itemCount(mag), + totalRounds, + }); + } + return stats; +} + +function totalsByClass(stock: StockEntry[]): Map { + const totals = new Map(); + for (const entry of stock) { + const prev = totals.get(entry.class); + if (prev) prev.count += entry.count; + else totals.set(entry.class, { name: entry.name, count: entry.count }); + } + return totals; +} + +function locationsOf(stock: StockEntry[], cls: string): GearLocation[] { + return stock.filter((entry) => entry.class === cls && entry.count > 0).map((entry) => entry.location); +} + +function countAt(stock: StockEntry[], location: GearLocation, cls: string): number { + return stock.find((entry) => entry.location === location && entry.class === cls)?.count ?? 0; +} + +function polarityFor(fromCount: number, toCount: number): DiffPolarity { + if (fromCount <= 0 && toCount > 0) return "added"; + if (fromCount > 0 && toCount <= 0) return "removed"; + return "changed"; +} + +function makeItemDelta( + cls: string, + name: string, + fromCount: number, + toCount: number, + fromRounds?: number, + toRounds?: number, +): ItemDelta | undefined { + const roundsChanged = + fromRounds !== undefined && toRounds !== undefined ? fromRounds !== toRounds : fromRounds !== toRounds; + if (fromCount === toCount && !roundsChanged) return undefined; + return { + polarity: polarityFor(fromCount, toCount), + class: cls, + name, + fromCount, + toCount, + fromRounds, + toRounds, + }; +} + +function equippedName(item?: GearItem): string { + return item?.class ? itemName(item) : ""; +} + +function diffEquippedSlot( + slotKey: EquippedDelta["slotKey"], + fromItem?: GearItem, + toItem?: GearItem, +): EquippedDelta | undefined { + const fromName = equippedName(fromItem); + const toName = equippedName(toItem); + const fromClass = fromItem?.class ?? ""; + const toClass = toItem?.class ?? ""; + if (fromClass === toClass && fromName === toName) return undefined; + return { + polarity: polarityFor(fromClass ? 1 : 0, toClass ? 1 : 0), + slotKey, + fromName, + toName, + }; +} + +function diffAttachments(fromItems?: GearItem[], toItems?: GearItem[]): ItemDelta[] { + const classes = new Set(); + for (const item of [...(fromItems ?? []), ...(toItems ?? [])]) { + if (item.class) classes.add(item.class); + } + const deltas: ItemDelta[] = []; + for (const cls of classes) { + const from = fromItems?.find((item) => item.class === cls); + const to = toItems?.find((item) => item.class === cls); + const delta = makeItemDelta(cls, itemName(to ?? from), itemCount(from), itemCount(to)); + if (delta) deltas.push(delta); + } + return deltas; +} + +function weaponKey(weapon: GearItem & { slot: string }): string { + return weapon.slot || weapon.class; +} + +function diffWeapons( + fromWeapons: InventorySnapshot["weapons"] | undefined, + toWeapons: InventorySnapshot["weapons"] | undefined, +): WeaponDelta[] { + const slots = new Set(); + for (const weapon of [...(fromWeapons ?? []), ...(toWeapons ?? [])]) { + slots.add(weaponKey(weapon)); + } + const deltas: WeaponDelta[] = []; + for (const slot of slots) { + const from = fromWeapons?.find((weapon) => weaponKey(weapon) === slot); + const to = toWeapons?.find((weapon) => weaponKey(weapon) === slot); + const attachments = diffAttachments(from?.attachments, to?.attachments); + const fromClass = from?.class ?? ""; + const toClass = to?.class ?? ""; + if (fromClass === toClass && attachments.length === 0) continue; + deltas.push({ + polarity: polarityFor(fromClass ? 1 : 0, toClass ? 1 : 0), + slot, + fromName: itemName(from), + toName: itemName(to), + attachments, + }); + } + return deltas; +} + +export function diffGear(from?: InventorySnapshot, to?: InventorySnapshot): GearDiff { + const fromStock = stockFromInventory(from); + const toStock = stockFromInventory(to); + const fromTotals = totalsByClass(fromStock); + const toTotals = totalsByClass(toStock); + const fromMags = magazineStats(from); + const toMags = magazineStats(to); + + const magClasses = magazineClassSet([...(from?.magazines ?? []), ...(to?.magazines ?? [])]); + const classes = new Set([...fromTotals.keys(), ...toTotals.keys(), ...fromMags.keys(), ...toMags.keys()]); + const net: ItemDelta[] = []; + const moved: MovedItem[] = []; + const movedClasses = new Set(); + + for (const cls of classes) { + const fromCount = fromMags.get(cls)?.count ?? fromTotals.get(cls)?.count ?? 0; + const toCount = toMags.get(cls)?.count ?? toTotals.get(cls)?.count ?? 0; + const name = + toTotals.get(cls)?.name || + fromTotals.get(cls)?.name || + toMags.get(cls)?.name || + fromMags.get(cls)?.name || + cls; + const delta = makeItemDelta( + cls, + name, + fromCount, + toCount, + ammoRounds(cls, name, fromMags, magClasses), + ammoRounds(cls, name, toMags, magClasses), + ); + + const fromLocs = locationsOf(fromStock, cls); + const toLocs = locationsOf(toStock, cls); + const stockFrom = fromTotals.get(cls)?.count ?? 0; + const stockTo = toTotals.get(cls)?.count ?? 0; + const pureMove = + stockFrom === stockTo && + stockFrom > 0 && + fromLocs.length === 1 && + toLocs.length === 1 && + fromLocs[0] !== toLocs[0] && + fromMags.get(cls)?.totalRounds === toMags.get(cls)?.totalRounds; + + if (pureMove) { + moved.push({ class: cls, name, count: fromCount, from: fromLocs[0], to: toLocs[0] }); + movedClasses.add(cls); + continue; + } + if (delta) net.push(delta); + } + + net.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); + moved.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); + + const equipped = [ + diffEquippedSlot("profile_head", from?.headgear, to?.headgear), + diffEquippedSlot("profile_face", from?.goggles, to?.goggles), + diffEquippedSlot("profile_uniform", from?.uniform, to?.uniform), + diffEquippedSlot("profile_vest", from?.vest, to?.vest), + diffEquippedSlot("profile_backpack", from?.backpack, to?.backpack), + ].filter((entry): entry is EquippedDelta => !!entry); + + const locationIds: GearLocation[] = ["loaded", "uniform", "vest", "backpack", "assigned"]; + const locations: LocationDelta[] = []; + for (const location of locationIds) { + const container = + location === "uniform" + ? equipped.find((entry) => entry.slotKey === "profile_uniform") + : location === "vest" + ? equipped.find((entry) => entry.slotKey === "profile_vest") + : location === "backpack" + ? equipped.find((entry) => entry.slotKey === "profile_backpack") + : undefined; + const locClasses = new Set(); + for (const entry of [...fromStock, ...toStock]) { + if (entry.location === location) locClasses.add(entry.class); + } + const items: ItemDelta[] = []; + for (const cls of locClasses) { + if (movedClasses.has(cls)) continue; + const fromCount = countAt(fromStock, location, cls); + const toCount = countAt(toStock, location, cls); + if (fromCount === toCount) continue; + const changedLocations = locationIds.filter( + (id) => countAt(fromStock, id, cls) !== countAt(toStock, id, cls), + ); + if (net.some((entry) => entry.class === cls) && changedLocations.length <= 1) continue; + const name = + toStock.find((entry) => entry.class === cls)?.name || + fromStock.find((entry) => entry.class === cls)?.name || + cls; + const delta = makeItemDelta(cls, name, fromCount, toCount); + if (delta) items.push(delta); + } + items.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); + if (container || items.length) { + locations.push({ location, container: location === "loaded" ? undefined : container, items }); + } + } + + return { + net, + moved, + equipped: equipped.filter((entry) => + entry.slotKey === "profile_head" || entry.slotKey === "profile_face", + ), + weapons: diffWeapons(from?.weapons, to?.weapons), + locations, + }; +} + +export function gearDiffIsEmpty(diff: GearDiff): boolean { + return ( + diff.net.length === 0 && + diff.moved.length === 0 && + diff.equipped.length === 0 && + diff.weapons.length === 0 && + diff.locations.every((location) => location.items.length === 0 && !location.container) + ); +} + +function rounded(value: unknown, digits = 0): number | undefined { + if (typeof value !== "number" || Number.isNaN(value)) return undefined; + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +} + +function roundedPercent(value: unknown): number | undefined { + if (typeof value !== "number" || Number.isNaN(value)) return undefined; + return Math.round(value * 100); +} + +function valuesEqual(kind: FieldDelta["kind"], from: unknown, to: unknown, digits = 0): boolean { + if (kind === "percent") return roundedPercent(from) === roundedPercent(to); + if (kind === "number") return rounded(from, digits) === rounded(to, digits); + if (kind === "bp") { + const fromBp = Array.isArray(from) ? `${from[0]}:${from[1]}` : String(from ?? ""); + const toBp = Array.isArray(to) ? `${to[0]}:${to[1]}` : String(to ?? ""); + return fromBp === toBp; + } + return from === to; +} + +function field( + labelKey: string, + kind: FieldDelta["kind"], + from: unknown, + to: unknown, + extra?: { digits?: number; suffix?: string }, +): FieldDelta | undefined { + if (from === undefined && to === undefined) return undefined; + if (valuesEqual(kind, from, to, extra?.digits ?? 0)) return undefined; + return { labelKey, kind, from, to, digits: extra?.digits, suffix: extra?.suffix }; +} + +function pickVital(...layers: unknown[]): number | undefined { + let picked: number | undefined; + for (const layer of layers) { + if (typeof layer === "number") picked = layer; + } + return picked; +} + +function treatmentKey(item: TreatmentItem): string { + return `${item.kind}:${item.name}:${item.detail ?? ""}`; +} + +function diffTreatments(fromItems?: TreatmentItem[], toItems?: TreatmentItem[]): ItemDelta[] { + const keys = new Set(); + const fromMap = new Map(); + const toMap = new Map(); + for (const item of fromItems ?? []) { + const key = treatmentKey(item); + keys.add(key); + fromMap.set(key, item); + } + for (const item of toItems ?? []) { + const key = treatmentKey(item); + keys.add(key); + toMap.set(key, item); + } + const deltas: ItemDelta[] = []; + for (const key of keys) { + const from = fromMap.get(key); + const to = toMap.get(key); + const delta = makeItemDelta( + key, + to?.name || from?.name || key, + from ? (from.count ?? 1) : 0, + to ? (to.count ?? 1) : 0, + ); + if (delta) deltas.push(delta); + } + return deltas; +} + +function newLogEntries(fromEntries?: MedicalLogEntry[], toEntries?: MedicalLogEntry[]): MedicalLogEntry[] { + const seen = new Set((fromEntries ?? []).map((entry) => `${entry.time}|${entry.text}`)); + return (toEntries ?? []).filter((entry) => !seen.has(`${entry.time}|${entry.text}`)); +} + +function partById(parts: MedicalBodyPart[] | undefined, id: string): MedicalBodyPart { + return parts?.find((part) => part.part === id) ?? { part: id, damage: 0, items: [] }; +} + +export function diffMedical(from?: MedicalSnapshot, to?: MedicalSnapshot): MedicalDiff { + const fromVanilla = from?.vanilla ?? {}; + const toVanilla = to?.vanilla ?? {}; + const fromAce = from?.ace; + const toAce = to?.ace; + const fromKat = from?.kat; + const toKat = to?.kat; + + const fields = [ + field("profile_blood", "number", fromAce?.bloodVolume, toAce?.bloodVolume, { digits: 2, suffix: " L" }), + field("profile_heart_rate", "number", fromAce?.heartRate, toAce?.heartRate, { suffix: " bpm" }), + field("profile_blood_pressure", "bp", fromAce?.bloodPressure, toAce?.bloodPressure), + field("profile_spo2", "number", pickVital(fromVanilla.spo2, fromAce?.spo2, fromKat?.spo2), pickVital(toVanilla.spo2, toAce?.spo2, toKat?.spo2), { digits: 1, suffix: "%" }), + field("profile_pain", "percent", fromAce?.pain, toAce?.pain), + field("profile_bleeding", "number", fromAce?.bleedingRate, toAce?.bleedingRate, { digits: 2 }), + field("profile_hemorrhage", "hemorrhage", fromAce?.hemorrhage, toAce?.hemorrhage), + field("profile_unconscious", "bool", fromAce?.unconscious, toAce?.unconscious), + field("profile_cardiac_arrest", "bool", fromAce?.cardiacArrest, toAce?.cardiacArrest), + field("profile_life_state", "text", fromVanilla.lifeState, toVanilla.lifeState), + field("profile_damage", "percent", fromVanilla.damage, toVanilla.damage), + field("profile_incapacitated", "bool", fromVanilla.incapacitated, toVanilla.incapacitated), + field("profile_etco2", "number", fromKat?.etco2, toKat?.etco2, { digits: 1 }), + field("profile_breath_rate", "number", fromKat?.breathRate, toKat?.breathRate, { digits: 1 }), + field("profile_airway_secured", "bool", fromKat?.airwaySecured, toKat?.airwaySecured), + field("profile_airway_item", "text", fromKat?.airwayItem, toKat?.airwayItem), + field("profile_obstruction", "bool", fromKat?.airwayObstruction, toKat?.airwayObstruction), + field("profile_occluded", "bool", fromKat?.airwayOccluded, toKat?.airwayOccluded), + field("profile_pneumothorax", "number", fromKat?.pneumothorax, toKat?.pneumothorax), + field("profile_chest_seal", "bool", fromKat?.chestSeal, toKat?.chestSeal), + field("profile_hemopneumothorax", "bool", fromKat?.hemopneumothorax, toKat?.hemopneumothorax), + field("profile_tension_ptx", "bool", fromKat?.tensionPneumothorax, toKat?.tensionPneumothorax), + field("profile_internal_bleed", "number", fromKat?.internalBleeding, toKat?.internalBleeding, { digits: 2 }), + ].filter((entry): entry is FieldDelta => !!entry); + + const bodyParts: BodyPartDelta[] = []; + for (const id of BODY_PART_IDS) { + const fromPart = partById(from?.bodyParts, id); + const toPart = partById(to?.bodyParts, id); + const damage = field("profile_damage", "percent", fromPart.damage, toPart.damage); + const items = diffTreatments(fromPart.items, toPart.items); + if (damage || items.length) bodyParts.push({ part: id, damage, items }); + } + + return { + fields, + bodyParts, + treatments: diffTreatments(from?.treatments, to?.treatments), + activity: newLogEntries(from?.activity, to?.activity), + quickView: newLogEntries(from?.quickView, to?.quickView), + }; +} + +export function medicalDiffIsEmpty(diff: MedicalDiff): boolean { + return ( + diff.fields.length === 0 && + diff.bodyParts.length === 0 && + diff.treatments.length === 0 && + diff.activity.length === 0 && + diff.quickView.length === 0 + ); +} + +function carriedWeightKg(source?: { massUnits?: number }): number | undefined { + if (typeof source?.massUnits === "number") return source.massUnits * MASS_UNIT_KG; + return undefined; +} + +function sprintSecondsMax(stamina?: StaminaSnapshot, inventory?: InventorySnapshot): number | undefined { + const max = stamina?.vanilla?.staminaMax; + if (typeof max === "number" && max > 0) return max; + const load = stamina?.vanilla?.load ?? inventory?.load; + return typeof load === "number" ? STAMINA_DURATION * Math.max(0, 1 - load) : undefined; +} + +function sprintReserve(stamina?: StaminaSnapshot, inventory?: InventorySnapshot): number | undefined { + const seconds = stamina?.vanilla?.stamina; + const max = sprintSecondsMax(stamina, inventory); + if (typeof seconds !== "number" || !max) return undefined; + return Math.min(1, seconds / max); +} + +function staminaReserve(stamina?: StaminaSnapshot, inventory?: InventorySnapshot): number | undefined { + if (stamina?.ace) return stamina.ace.anaerobicReserve; + const seconds = stamina?.vanilla?.stamina; + const max = sprintSecondsMax(stamina, inventory); + if (typeof seconds !== "number" || !max || seconds > max + 0.5) return undefined; + return sprintReserve(stamina, inventory); +} + +export function diffStamina( + from?: StaminaSnapshot, + to?: StaminaSnapshot, + fromInv?: InventorySnapshot, + toInv?: InventorySnapshot, +): FieldDelta[] { + const fromAce = from?.ace; + const toAce = to?.ace; + return [ + field("profile_carried_weight", "number", carriedWeightKg(from?.vanilla) ?? carriedWeightKg(fromInv), carriedWeightKg(to?.vanilla) ?? carriedWeightKg(toInv), { digits: 1, suffix: " kg" }), + field("profile_load", "percent", from?.vanilla?.load ?? fromInv?.load, to?.vanilla?.load ?? toInv?.load), + field("profile_sprint_reserve", "percent", fromAce ? undefined : sprintReserve(from, fromInv), toAce ? undefined : sprintReserve(to, toInv)), + field("profile_fatigue", "percent", fromAce ? undefined : from?.vanilla?.fatigue, toAce ? undefined : to?.vanilla?.fatigue), + field("profile_anaerobic", "percent", fromAce?.anaerobicReserve, toAce?.anaerobicReserve), + field("profile_aerobic", "percent", fromAce?.aerobicReserve, toAce?.aerobicReserve), + field("profile_muscle_damage", "percent", fromAce?.muscleDamage, toAce?.muscleDamage), + field("profile_performance", "number", fromAce?.performanceFactor, toAce?.performanceFactor, { digits: 2, suffix: "×" }), + ].filter((entry): entry is FieldDelta => !!entry); +} + +function radioIdentity(entry: RadioSnapshotEntry, index: number): string { + return `${entry.mod}:${entry.type}:${entry.additional ? 1 : 0}:${entry.class || index}`; +} + +export function diffRadios(from?: RadioSnapshot, to?: RadioSnapshot): RadioDiff { + const fromRadios = from?.radios ?? []; + const toRadios = to?.radios ?? []; + const fromMap = new Map(fromRadios.map((entry, index) => [radioIdentity(entry, index), entry])); + const toMap = new Map(toRadios.map((entry, index) => [radioIdentity(entry, index), entry])); + const radios: RadioDelta[] = []; + + for (const [key, toEntry] of toMap) { + const fromEntry = fromMap.get(key); + if (!fromEntry) { + radios.push({ + polarity: "added", + name: toEntry.name || toEntry.class, + fields: [ + field("profile_diff_channel", "text", undefined, toEntry.channel), + field("profile_diff_frequency", "number", undefined, toEntry.frequency, { digits: 3, suffix: " MHz" }), + ].filter((entry): entry is FieldDelta => !!entry), + }); + continue; + } + const fields = [ + field("profile_diff_channel", "text", fromEntry.channel, toEntry.channel), + field("profile_diff_frequency", "number", fromEntry.frequency, toEntry.frequency, { digits: 3, suffix: " MHz" }), + field("profile_diff_code", "text", fromEntry.code, toEntry.code), + field("profile_radio_active", "bool", fromEntry.active, toEntry.active), + field("profile_radio_additional", "bool", fromEntry.additional, toEntry.additional), + ].filter((entry): entry is FieldDelta => !!entry); + if (fields.length) { + radios.push({ polarity: "changed", name: toEntry.name || toEntry.class, fields }); + } + } + + for (const [key, fromEntry] of fromMap) { + if (toMap.has(key)) continue; + radios.push({ + polarity: "removed", + name: fromEntry.name || fromEntry.class, + fields: [ + field("profile_diff_frequency", "number", fromEntry.frequency, undefined, { digits: 3, suffix: " MHz" }), + ].filter((entry): entry is FieldDelta => !!entry), + }); + } + + return { radios }; +} + +export function radioDiffIsEmpty(diff: RadioDiff): boolean { + return diff.radios.length === 0; +} + +export function metricDelta(from: number | undefined, to: number | undefined, digits = 0): number | undefined { + if (from === undefined && to === undefined) return undefined; + const fromValue = from ?? 0; + const toValue = to ?? 0; + const delta = rounded(toValue - fromValue, digits); + return delta === 0 ? undefined : delta; +} + +export { carriedWeightKg, staminaReserve }; From 2ab900a9cf5e2a152dcb697f1758206ad3e4c77d Mon Sep 17 00:00:00 2001 From: Yethe Samartaka Date: Mon, 31 Aug 2026 22:21:35 +0200 Subject: [PATCH 05/39] add arsenal export buttons --- ui/src/i18n/locales.ts | 5 + .../__tests__/PlayerProfileCard.test.tsx | 34 +- .../__tests__/arsenalExport.test.ts | 210 +++++++++ .../pages/recording-playback/arsenalExport.ts | 402 ++++++++++++++++++ .../components/PlayerProfileCard.module.css | 26 ++ .../components/PlayerProfileCard.tsx | 27 ++ 6 files changed, 703 insertions(+), 1 deletion(-) create mode 100644 ui/src/pages/recording-playback/__tests__/arsenalExport.test.ts create mode 100644 ui/src/pages/recording-playback/arsenalExport.ts diff --git a/ui/src/i18n/locales.ts b/ui/src/i18n/locales.ts index 93bda0b6c..876153f64 100644 --- a/ui/src/i18n/locales.ts +++ b/ui/src/i18n/locales.ts @@ -2622,6 +2622,11 @@ export const translations: Record> = { profile_stamina: { en: "Stamina", ru: "Выносливость", de: "Ausdauer", cs: "Výdrž", it: "Stamina", fr: "Endurance", fi: "Kestävyys", uk: "Витривалість" }, profile_load: { en: "Load", ru: "Нагрузка", de: "Last", cs: "Zátěž", it: "Carico", fr: "Charge", fi: "Kuorma", uk: "Навантаження" }, profile_tab_gear: { en: "Gear", ru: "Снаряжение", de: "Ausrüstung", cs: "Výstroj", it: "Equipaggiamento", fr: "Équipement", fi: "Varusteet", uk: "Спорядження" }, + profile_export_arsenal: { en: "Export to Arsenal", ru: "Экспорт в Arsenal", de: "In Arsenal exportieren", cs: "Exportovat do Arsenalu", it: "Esporta in Arsenal", fr: "Exporter vers Arsenal", fi: "Vie Arsenaliin", uk: "Експорт до Arsenal" }, + profile_export_ace_arsenal: { en: "Export to ACE Arsenal", ru: "Экспорт в ACE Arsenal", de: "In ACE Arsenal exportieren", cs: "Exportovat do ACE Arsenalu", it: "Esporta in ACE Arsenal", fr: "Exporter vers ACE Arsenal", fi: "Vie ACE Arsenaliin", uk: "Експорт до ACE Arsenal" }, + profile_exported_vanilla: { en: "Arsenal loadout copied to clipboard.", ru: "Снаряжение Arsenal скопировано.", de: "Arsenal-Ausrüstung kopiert.", cs: "Výstroj pro Arsenal byl zkopírován.", it: "Equipaggiamento Arsenal copiato.", fr: "Équipement Arsenal copié.", fi: "Arsenal-varustus kopioitu.", uk: "Спорядження Arsenal скопійовано." }, + profile_exported_ace: { en: "ACE Arsenal loadout copied to clipboard.", ru: "Снаряжение ACE Arsenal скопировано.", de: "ACE-Arsenal-Ausrüstung kopiert.", cs: "Výstroj pro ACE Arsenal byl zkopírován.", it: "Equipaggiamento ACE Arsenal copiato.", fr: "Équipement ACE Arsenal copié.", fi: "ACE Arsenal -varustus kopioitu.", uk: "Спорядження ACE Arsenal скопійовано." }, + profile_export_failed: { en: "Could not copy the loadout.", ru: "Не удалось скопировать снаряжение.", de: "Ausrüstung konnte nicht kopiert werden.", cs: "Výstroj se nepodařilo zkopírovat.", it: "Impossibile copiare l'equipaggiamento.", fr: "Impossible de copier l'équipement.", fi: "Varustusta ei voitu kopioida.", uk: "Не вдалося скопіювати спорядження." }, profile_tab_medical: { en: "Medical", ru: "Медицина", de: "Medizin", cs: "Medicína", it: "Medico", fr: "Médical", fi: "Lääkintä", uk: "Медицина" }, profile_tab_stamina: { en: "Stamina", ru: "Выносливость", de: "Ausdauer", cs: "Výdrž", it: "Stamina", fr: "Endurance", fi: "Kestävyys", uk: "Витривалість" }, profile_tab_radios: { en: "Radios", ru: "Рации", de: "Funkgeräte", cs: "Vysílačky", it: "Radio", fr: "Radios", fi: "Radiot", uk: "Рації" }, diff --git a/ui/src/pages/recording-playback/__tests__/PlayerProfileCard.test.tsx b/ui/src/pages/recording-playback/__tests__/PlayerProfileCard.test.tsx index 280312814..03a94be31 100644 --- a/ui/src/pages/recording-playback/__tests__/PlayerProfileCard.test.tsx +++ b/ui/src/pages/recording-playback/__tests__/PlayerProfileCard.test.tsx @@ -5,7 +5,10 @@ import { Unit } from "../../../playback/entities/unit"; import type { EventDef, WorldConfig } from "../../../data/types"; import { createTestEngine, TestProviders, unitDef, makeManifest } from "./testHelpers"; -afterEach(cleanup); +afterEach(() => { + cleanup(); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined }); +}); function snapshotEvent( frameNum: number, @@ -64,6 +67,35 @@ function renderCard( } describe("PlayerProfileCard", () => { + it("exports the visible gear from buttons shown only on the gear tab", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + renderCard([ + snapshotEvent(5, "inventorySnapshot", { + uniform: { class: "U_B_CombatUniform_mcam", name: "Uniform", items: [] }, + vest: { class: "", name: "", items: [] }, + backpack: { class: "", name: "", items: [] }, + headgear: { class: "", name: "" }, + goggles: { class: "", name: "" }, + weapons: [], + magazines: [], + assignedItems: [], + }), + snapshotEvent(5, "medicalSnapshot", { vanilla: { damage: 0 } }), + ]); + + fireEvent.click(screen.getByRole("button", { name: "Export to Arsenal" })); + await vi.waitFor(() => expect(writeText).toHaveBeenCalledOnce()); + expect(writeText.mock.calls[0][0]).toContain('this forceAddUniform "U_B_CombatUniform_mcam";'); + + fireEvent.click(screen.getByRole("tab", { name: "Medical" })); + expect(screen.queryByRole("button", { name: "Export to Arsenal" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Export to ACE Arsenal" })).toBeNull(); + }); + it("converts Arma mass units to kilograms", () => { renderCard([ snapshotEvent(5, "staminaSnapshot", { diff --git a/ui/src/pages/recording-playback/__tests__/arsenalExport.test.ts b/ui/src/pages/recording-playback/__tests__/arsenalExport.test.ts new file mode 100644 index 000000000..7f6a6283c --- /dev/null +++ b/ui/src/pages/recording-playback/__tests__/arsenalExport.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from "vitest"; +import type { InventorySnapshot } from "../../../data/types"; +import { exportAceArsenal, exportVanillaArsenal } from "../arsenalExport"; + +const inventory: InventorySnapshot = { + unitId: 1, + load: 0.5, + uniform: { + class: "U_B_CombatUniform_mcam", + name: "Combat Fatigues", + items: [ + { class: "FirstAidKit", name: "First Aid Kit", count: 2 }, + { class: "30Rnd_65x39_caseless_mag", name: "6.5 mm magazine", count: 3 }, + ], + }, + vest: { class: "V_PlateCarrier1_rgr", name: "Carrier Rig", items: [] }, + backpack: { class: "", name: "", items: [] }, + headgear: { class: "H_HelmetB", name: "Combat Helmet" }, + goggles: { class: "G_Tactical_Clear", name: "Tactical Glasses" }, + weapons: [{ + class: "arifle_MX_F", + name: "MX", + slot: "primary", + attachments: [ + { class: "muzzle_snds_H", name: "Sound Suppressor" }, + { class: "optic_Hamr", name: "RCO" }, + ], + }], + magazines: [{ + class: "30Rnd_65x39_caseless_mag", + name: "6.5 mm magazine", + count: 4, + totalRounds: 107, + loadedCount: 1, + }], + assignedItems: [{ class: "ItemMap", name: "Map" }], +}; + +describe("Arsenal exports", () => { + it("builds importable vanilla Arsenal SQF", () => { + const exported = exportVanillaArsenal(inventory); + expect(exported).toContain('this forceAddUniform "U_B_CombatUniform_mcam";'); + expect(exported).toContain('for "_i" from 1 to 3 do {this addItemToUniform "30Rnd_65x39_caseless_mag";};'); + expect(exported).toContain('this addMagazine "30Rnd_65x39_caseless_mag";'); + expect(exported).toContain('this addPrimaryWeaponItem "optic_Hamr";'); + expect(exported).toContain('this linkItem "ItemMap";'); + }); + + /** + * The Arsenal's Import accepts this layout and rejected every "closer to what + * the Arsenal itself exports" variant that was tried. Treat it as fixed: the + * `Exported from OCAP` header, no blank lines, `addMagazine` for magazines + * carried in a weapon, and a `linkItem` for every assigned item the recorder saw. + */ + it("keeps the layout the Arsenal's Import is known to accept", () => { + const exported = exportVanillaArsenal(inventory); + const lines = exported.split("\n"); + expect(lines[0]).toBe('comment "Exported from OCAP";'); + expect(lines[1]).toBe('comment "Remove existing items";'); + expect(lines).not.toContain(""); + expect(lines.indexOf('comment "Add containers";')).toBeGreaterThan(0); + expect(lines.indexOf('comment "Add weapons";')).toBeGreaterThan(0); + expect(lines.indexOf('comment "Add items";')).toBeGreaterThan(0); + }); + + it("builds an ACE/CBA extended loadout array", () => { + const exported = exportAceArsenal(inventory); + expect(exported.startsWith("[[[")).toBe(true); + expect(exported).toContain('["arifle_MX_F","muzzle_snds_H","","optic_Hamr",["30Rnd_65x39_caseless_mag",27],[],""]'); + expect(exported).toContain('["U_B_CombatUniform_mcam",[["FirstAidKit",2],["30Rnd_65x39_caseless_mag",3,27]]]'); + expect(exported.endsWith(",[\"ItemMap\",\"\",\"\",\"\",\"\",\"\"]],[]]")).toBe(true); + }); + + it("escapes quotes using SQF string rules", () => { + const exported = exportVanillaArsenal({ + ...inventory, + headgear: { class: 'Hat_"Quoted"', name: "Quoted hat" }, + }); + expect(exported).toContain('this addHeadgear "Hat_""Quoted""";'); + }); + + it("assigns an inferred loaded magazine when only a handgun is present", () => { + const exported = exportAceArsenal({ + ...inventory, + uniform: { ...inventory.uniform, items: [] }, + weapons: [{ class: "hgun_P07_F", name: "P07", slot: "handgun", attachments: [] }], + magazines: [{ class: "16Rnd_9x21_Mag", name: "9 mm magazine", count: 1, totalRounds: 12, loadedCount: 1 }], + }); + expect(exported).toContain('["hgun_P07_F","","","",["16Rnd_9x21_Mag",12],[],""]'); + }); + + it("does not mistake selected throwables for weapon magazines", () => { + const withGrenades: InventorySnapshot = { + ...inventory, + uniform: { + ...inventory.uniform, + items: [{ class: "HandGrenade", name: "RGO Grenade", count: 1 }], + }, + magazines: [{ class: "HandGrenade", name: "RGO Grenade", count: 1, totalRounds: 1, loadedCount: 1 }], + }; + const vanilla = exportVanillaArsenal(withGrenades); + const ace = exportAceArsenal(withGrenades); + expect(vanilla).not.toContain('this addMagazine "HandGrenade";'); + expect(ace).not.toContain('["HandGrenade",1],[],""]'); + }); + + it("places rifle grenades in the primary weapon's second muzzle", () => { + const exported = exportAceArsenal({ + ...inventory, + uniform: { ...inventory.uniform, items: [] }, + weapons: [{ class: "arifle_MX_GL_F", name: "MX 3GL", slot: "primary", attachments: [] }], + magazines: [ + { class: "30Rnd_65x39_caseless_mag", name: "6.5 mm magazine", count: 1, totalRounds: 23, loadedCount: 1 }, + { class: "1Rnd_HE_Grenade_shell", name: "40 mm HE Grenade Shell", count: 1, totalRounds: 1, loadedCount: 1 }, + ], + }); + expect(exported).toContain( + '["arifle_MX_GL_F","","","",["30Rnd_65x39_caseless_mag",23],["1Rnd_HE_Grenade_shell",1],""]', + ); + }); + + it("writes assigned items in setUnitLoadout's fixed slot order", () => { + const exported = exportAceArsenal({ + ...inventory, + assignedItems: [ + { class: "ItemWatch", name: "Watch" }, + { class: "ItemRadio", name: "Radio" }, + { class: "NVGoggles", name: "Night Vision Goggles" }, + { class: "ItemMap", name: "Map" }, + { class: "ItemCompass", name: "Compass" }, + { class: "B_UavTerminal", name: "UAV Terminal" }, + ], + }); + expect(exported).toContain( + '["ItemMap","B_UavTerminal","ItemRadio","ItemCompass","ItemWatch","NVGoggles"]', + ); + }); + + /** The exact assigned items a TFAR + cTab + SIMC recording produced. */ + const moddedAssignedItems: InventorySnapshot = { + ...inventory, + weapons: [ + ...inventory.weapons, + { class: "Rangefinder", name: "Rangefinder", slot: "binocular", attachments: [] }, + ], + assignedItems: [ + { class: "ItemMap", name: "Map" }, + { class: "ItemCompass", name: "Compass" }, + { class: "ItemWatch", name: "Watch" }, + { class: "TFAR_anprc152_1", name: "AN/PRC-152 1" }, + { class: "ItemAndroid", name: "S7 Android" }, + { class: "Simc_PVS7", name: "AN/PVS-7" }, + { class: "Rangefinder", name: "Rangefinder" }, + ], + }; + + it("puts modded radios and GPS devices in their own slots", () => { + const exported = exportAceArsenal(moddedAssignedItems); + expect(exported).toContain( + '["ItemMap","ItemAndroid","TFAR_anprc152","ItemCompass","ItemWatch","Simc_PVS7"]', + ); + }); + + it("keeps the binocular out of the ACE assigned item slots", () => { + const exported = exportAceArsenal(moddedAssignedItems); + expect(exported).not.toContain('"Rangefinder","ItemCompass"'); + // The vanilla side still emits it: `linkItem` ignores a weapon class, and + // leaving the line out was one of the changes that broke the Arsenal Import. + const vanilla = exportVanillaArsenal(moddedAssignedItems); + expect(vanilla).toContain('this linkItem "Rangefinder";'); + expect(vanilla).toContain('this addWeapon "Rangefinder";'); + }); + + /** + * Verified in a live Arsenal: two samples differing only in this class were + * pasted, and only the one carrying the prototype imported. TFAR's clone is + * `scope = 1` / `scopeArsenal = 1`, so no Arsenal will ever offer it. + */ + it("trades TFAR instance classes for the prototype in both exports", () => { + const withSpareRadio: InventorySnapshot = { + ...moddedAssignedItems, + vest: { + ...inventory.vest, + items: [{ class: "TFAR_anprc154_12", name: "AN/PRC-154 12", count: 1 }], + }, + }; + const vanilla = exportVanillaArsenal(withSpareRadio); + expect(vanilla).toContain('this linkItem "TFAR_anprc152";'); + expect(vanilla).not.toContain("TFAR_anprc152_1"); + expect(vanilla).toContain('this addItemToVest "TFAR_anprc154";'); + + const ace = exportAceArsenal(withSpareRadio); + expect(ace).toContain("TFAR_anprc152"); + expect(ace).not.toContain("TFAR_anprc152_1"); + expect(ace).toContain('["TFAR_anprc154",1]'); + }); + + it("orders unrecognised assigned items by the slots assignedItems walks", () => { + const exported = exportAceArsenal({ + ...inventory, + assignedItems: [ + { class: "ItemMap", name: "Map" }, + { class: "mod_thing_a", name: "Thing A" }, + { class: "mod_thing_b", name: "Thing B" }, + ], + }); + // assignedItems reports map, compass, watch, radio, gps, NVG in that order. + expect(exported).toContain('["ItemMap","","","mod_thing_a","mod_thing_b",""]'); + }); +}); diff --git a/ui/src/pages/recording-playback/arsenalExport.ts b/ui/src/pages/recording-playback/arsenalExport.ts new file mode 100644 index 000000000..2b3b3fb9a --- /dev/null +++ b/ui/src/pages/recording-playback/arsenalExport.ts @@ -0,0 +1,402 @@ +import type { GearContainer, GearItem, InventorySnapshot } from "../../data/types"; + +type Weapon = InventorySnapshot["weapons"][number]; +type Magazine = InventorySnapshot["magazines"][number]; + +const itemCount = (item: GearItem): number => Math.max(1, Math.floor(item.count ?? 1)); +const sqfString = (value: string): string => `"${value.replaceAll('"', '""')}"`; + +/** + * TFAR clones every handheld radio into a per-instance class that carries the + * radio's channel state (`TFAR_anprc152_1`). Those clones are `scope = 1` with + * `scopeArsenal = 1` and `ace_arsenal_hide = 1`, so the vanilla Arsenal never + * offers them and ACE's `verifyLoadout` drops them as unavailable — the recorded + * class has to be traded back for the prototype it was cloned from, which is the + * class name without the trailing instance index. + */ +function arsenalClass(className: string): string { + return /^(?:tfar|tf)_/i.test(className) ? className.replace(/^(.+?)_\d+$/, "$1") : className; +} + +const classString = (className: string): string => sqfString(arsenalClass(className)); + +function repeatCommand(command: string, className: string, count: number): string { + const statement = `this ${command} ${classString(className)}`; + return count === 1 + ? `${statement};` + : `for "_i" from 1 to ${count} do {${statement};};`; +} + +function containerLines(container: GearContainer | undefined, command: string): string[] { + if (!container?.class) return []; + const lines = [`this ${command} ${classString(container.class)};`]; + const destination = command === "forceAddUniform" + ? "addItemToUniform" + : command === "addVest" + ? "addItemToVest" + : "addItemToBackpack"; + for (const item of container.items ?? []) { + if (item.class) lines.push(repeatCommand(destination, item.class, itemCount(item))); + } + return lines; +} + +function containerMagazineCounts(inventory: InventorySnapshot): Map { + const counts = new Map(); + for (const container of [inventory.uniform, inventory.vest, inventory.backpack]) { + for (const item of container?.items ?? []) { + counts.set(item.class, (counts.get(item.class) ?? 0) + itemCount(item)); + } + } + return counts; +} + +/** Magazines reported by magazinesAmmoFull but absent from cargo are loaded in weapons. */ +function loadedMagazines(inventory: InventorySnapshot): Magazine[] { + const cargo = containerMagazineCounts(inventory); + const loaded: Magazine[] = []; + for (const magazine of inventory.magazines ?? []) { + // `isLoaded` from magazinesAmmoFull is also true for selected throwables. + // Firearm magazines, unlike throwables, disappear from container item lists. + const inferred = Math.max(0, itemCount(magazine) - (cargo.get(magazine.class) ?? 0)); + for (let index = 0; index < inferred; index += 1) loaded.push(magazine); + } + return loaded; +} + +const weaponCommand: Record = { + primary: "addPrimaryWeaponItem", + handgun: "addHandgunItem", + launcher: "addSecondaryWeaponItem", + secondary: "addSecondaryWeaponItem", + binocular: "addBinocularItem", +}; + +/** + * Only change this against a live Arsenal, one thing at a time. Four "corrections" + * that all looked right broke the import outright: the Arsenal's own + * `Exported from Arsenal by ` header, a blank line before each section, + * folding a weapon's loaded magazine into a container instead of `addMagazine`, + * and dropping the binocular's `linkItem`. `fn_arsenal.sqf` is not published, so + * the Ctrl + V parser's rules cannot be looked up — this layout is empirical. + * + * The one deliberate departure from the recorded data is `arsenalClass`, and it + * was confirmed the same way: two samples differing only in TFAR's radio class + * were pasted into an Arsenal, and only the one carrying the prototype imported. + * + * Note the Arsenal's Ctrl + V cannot read the clipboard in multiplayer at all + * (`copyFromClipboard` is disabled there), so test this in the editor or the + * main-menu Virtual Arsenal. On a server, `exportAceArsenal` is the only option. + */ +export function exportVanillaArsenal(inventory: InventorySnapshot): string { + const lines = [ + 'comment "Exported from OCAP";', + 'comment "Remove existing items";', + "removeAllWeapons this;", + "removeAllItems this;", + "removeAllAssignedItems this;", + "removeUniform this;", + "removeVest this;", + "removeBackpack this;", + "removeHeadgear this;", + "removeGoggles this;", + 'comment "Add containers";', + ...containerLines(inventory.uniform, "forceAddUniform"), + ...containerLines(inventory.vest, "addVest"), + ...containerLines(inventory.backpack, "addBackpack"), + ]; + + if (inventory.headgear?.class) lines.push(`this addHeadgear ${classString(inventory.headgear.class)};`); + if (inventory.goggles?.class) lines.push(`this addGoggles ${classString(inventory.goggles.class)};`); + + // Arsenal adds carried magazines before weapons so compatible ones are loaded. + for (const magazine of loadedMagazines(inventory)) { + lines.push(`this addMagazine ${classString(magazine.class)};`); + } + + lines.push('comment "Add weapons";'); + for (const weapon of inventory.weapons ?? []) { + if (!weapon.class) continue; + lines.push(`this addWeapon ${classString(weapon.class)};`); + const command = weaponCommand[weapon.slot]; + if (!command) continue; + for (const attachment of weapon.attachments ?? []) { + if (attachment.class) lines.push(`this ${command} ${classString(attachment.class)};`); + } + } + + // Every assigned item, including the binocular the engine reports here: + // `linkItem` places each one by its config, and filtering this list is one of + // the changes that broke the import. + lines.push('comment "Add items";'); + for (const item of inventory.assignedItems ?? []) { + if (item.class) lines.push(repeatCommand("linkItem", item.class, itemCount(item))); + } + return lines.join("\n"); +} + +function averageRounds(magazine: Magazine): number { + if (typeof magazine.totalRounds !== "number") return 1; + return Math.max(0, Math.round(magazine.totalRounds / itemCount(magazine))); +} + +function attachmentSlots(weapon: Weapon): [string, string, string, string] { + const slots: [string, string, string, string] = ["", "", "", ""]; + for (const attachment of weapon.attachments ?? []) { + const value = `${attachment.class} ${attachment.name}`.toLowerCase(); + const index = value.includes("muzzle") || value.includes("suppress") || value.includes("silencer") + ? 0 + : value.includes("optic") || value.includes("scope") || value.includes("sight") + ? 2 + : value.includes("bipod") + ? 3 + : 1; + if (!slots[index]) slots[index] = arsenalClass(attachment.class); + } + return slots; +} + +function findWeapon(inventory: InventorySnapshot, ...slots: string[]): Weapon | undefined { + return inventory.weapons?.find((weapon) => slots.includes(weapon.slot)); +} + +function weaponArray( + weapon: Weapon | undefined, + primaryMagazine: Magazine | undefined, + secondaryMagazine?: Magazine, +): unknown[] { + if (!weapon?.class) return []; + const [muzzle, pointer, optic, bipod] = attachmentSlots(weapon); + const primary = primaryMagazine + ? [arsenalClass(primaryMagazine.class), averageRounds(primaryMagazine)] + : []; + const secondary = secondaryMagazine + ? [arsenalClass(secondaryMagazine.class), averageRounds(secondaryMagazine)] + : []; + return [arsenalClass(weapon.class), muzzle, pointer, optic, primary, secondary, bipod]; +} + +function containerArray( + container: GearContainer | undefined, + magazines: Map, +): unknown[] { + if (!container?.class) return []; + const items = (container.items ?? []).filter((item) => item.class).map((item) => { + const magazine = magazines.get(item.class); + return magazine + ? [arsenalClass(item.class), itemCount(item), averageRounds(magazine)] + : [arsenalClass(item.class), itemCount(item)]; + }); + return [arsenalClass(container.class), items]; +} + +function sqfValue(value: unknown): string { + if (typeof value === "string") return sqfString(value); + if (typeof value === "number") return String(value); + if (Array.isArray(value)) return `[${value.map(sqfValue).join(",")}]`; + return "nil"; +} + +interface WeaponMagazines { + primary?: Magazine; + secondary?: Magazine; +} + +function magazineText(magazine: Magazine): string { + return `${magazine.class} ${magazine.name}`.toLowerCase(); +} + +function looksLikeLauncherMagazine(magazine: Magazine): boolean { + return /(?:titan|rpg|maaws|mraws|nlaw|missile|rocket|\bheat\b|[_ -](?:at|aa)(?:[_ -]|$))/.test( + magazineText(magazine), + ); +} + +function looksLikeGlMagazine(magazine: Magazine): boolean { + return /(?:(?:^|[_ ])(?:1|2|3)rnd|ugl|vog|grenade shell|flare|gl[_ -])/.test( + magazineText(magazine), + ); +} + +function looksLikeHandgunMagazine(magazine: Magazine): boolean { + return /(?:9x\d+|\.45|45acp|pistol|handgun|\b(?:6|7|8|10|11|12|13|15|16|17|18|20|21)rnd\b)/.test( + magazineText(magazine), + ); +} + +function looksLikeBinocularMagazine(magazine: Magazine): boolean { + return /(?:laser.?batter|designator batter)/.test(magazineText(magazine)); +} + +function takeMagazine( + pool: Magazine[], + predicate: (magazine: Magazine) => boolean, +): Magazine | undefined { + const index = pool.findIndex(predicate); + if (index < 0) return undefined; + return pool.splice(index, 1)[0]; +} + +function assignLoadedMagazines(inventory: InventorySnapshot): Map { + const pool = loadedMagazines(inventory); + const primary = findWeapon(inventory, "primary"); + const launcher = findWeapon(inventory, "launcher", "secondary"); + const handgun = findWeapon(inventory, "handgun"); + const binocular = findWeapon(inventory, "binocular"); + const assignments = new Map(); + + const setPrimary = (weapon: Weapon | undefined, magazine: Magazine | undefined) => { + if (weapon && magazine) assignments.set(weapon, { primary: magazine }); + }; + + if (binocular) setPrimary(binocular, takeMagazine(pool, looksLikeBinocularMagazine)); + if (launcher) setPrimary(launcher, takeMagazine(pool, looksLikeLauncherMagazine)); + if (handgun) setPrimary(handgun, takeMagazine(pool, looksLikeHandgunMagazine)); + + if (primary) { + const primaryMag = takeMagazine( + pool, + (magazine) => !looksLikeGlMagazine(magazine) && !looksLikeLauncherMagazine(magazine), + ); + const secondaryMag = takeMagazine(pool, looksLikeGlMagazine); + assignments.set(primary, { primary: primaryMag, secondary: secondaryMag }); + } + + // Modded class names do not always expose their role. Preserve them by filling + // any remaining empty weapon magazine slots in canonical getUnitLoadout order. + for (const weapon of [primary, launcher, handgun, binocular]) { + if (!weapon || pool.length === 0) continue; + const current = assignments.get(weapon) ?? {}; + if (!current.primary) current.primary = pool.shift(); + else if (weapon === primary && !current.secondary) current.secondary = pool.shift(); + assignments.set(weapon, current); + } + + return assignments; +} + +type AssignedSlot = "map" | "gps" | "radio" | "compass" | "watch" | "nvg"; + +/** Index of each slot inside the assigned items array `getUnitLoadout` returns. */ +const ASSIGNED_SLOT_INDEX: Record = { + map: 0, + gps: 1, + radio: 2, + compass: 3, + watch: 4, + nvg: 5, +}; + +/** `assignedItems` walks the slots in this order, which is not the loadout order. */ +const ASSIGNED_REPORT_ORDER: AssignedSlot[] = ["map", "compass", "watch", "radio", "gps", "nvg"]; + +/** + * Recordings only carry a class name and a display name, so the slot has to be + * recognised from those. Modded gear rarely spells its role out in English — + * a TFAR radio is `TFAR_anprc152_1` "AN/PRC-152 1", a cTab GPS is `ItemAndroid` + * "S7 Android" — so match the families that actually turn up. Order matters: + * TFAR's MicroDAGR is a GPS even though its class carries the radio prefix. + */ +const ASSIGNED_SLOT_PATTERNS: Array<[AssignedSlot, RegExp]> = [ + ["gps", /gps|dagr|ctab|android|tablet|garmin|uav|terminal|\bbft\b/], + ["nvg", /nvg|goggle|night.?vision|nachtsicht|\bpvs\b|pvs-?\d|gpnvg|hmnvs|\b\dpn\d/], + ["radio", /radio|funk|\bprc\b|prc-?\d|acre|tfar|anarc|rt.?1523|sem.?\d|fadak|pnr.?1000|rf.?7800|mr.?[36]000|harris/], + ["compass", /compass|kompass|bussole/], + ["watch", /watch|altimeter|chrono|\buhr\b/], + ["map", /(?:^|[_ ])map(?:$|[_ ])|itemmap/], +]; + +/** + * `assignedItems` also reports the binocular, which belongs to the loadout's own + * binocular slot and to `addWeapon`, never to an assigned item slot. Anything the + * snapshot already lists as a weapon is filtered on identity; the pattern only + * covers recordings whose weapon list is empty. + */ +const BINOCULAR_PATTERN = /binocular|\bbino\b|rangefinder|designator|vector ?21|yardage|lerca/; + +interface AssignedEntry { + class: string; + count: number; + slot?: AssignedSlot; +} + +function assignedEntries(inventory: InventorySnapshot): AssignedEntry[] { + const weaponClasses = new Set( + (inventory.weapons ?? []).map((weapon) => weapon.class).filter(Boolean), + ); + const entries: AssignedEntry[] = []; + for (const item of inventory.assignedItems ?? []) { + if (!item.class || weaponClasses.has(item.class)) continue; + const text = `${item.class} ${item.name ?? ""}`.toLowerCase(); + if (BINOCULAR_PATTERN.test(text)) continue; + entries.push({ + class: arsenalClass(item.class), + count: itemCount(item), + slot: ASSIGNED_SLOT_PATTERNS.find(([, pattern]) => pattern.test(text))?.[0], + }); + } + return entries; +} + +function assignedItemSlots(inventory: InventorySnapshot): string[] { + const entries = assignedEntries(inventory); + const slots = ["", "", "", "", "", ""]; + const unplaced: AssignedEntry[] = []; + + for (const entry of entries) { + const index = entry.slot === undefined ? -1 : ASSIGNED_SLOT_INDEX[entry.slot]; + if (index >= 0 && !slots[index]) slots[index] = entry.class; + else unplaced.push(entry); + } + + // Unrecognised gear keeps the order `assignedItems` reported it in, and that + // order follows the engine's slots, so fill forwards from the last slot a + // recognised item claimed instead of dropping it into the first empty index. + let cursor = 0; + const freeSlot = (from: number): number => + ASSIGNED_REPORT_ORDER.findIndex( + (slot, order) => order >= from && !slots[ASSIGNED_SLOT_INDEX[slot]], + ); + for (const entry of entries) { + if (!unplaced.includes(entry)) { + cursor = Math.max(cursor, ASSIGNED_REPORT_ORDER.indexOf(entry.slot as AssignedSlot) + 1); + continue; + } + const forward = freeSlot(cursor); + const order = forward >= 0 ? forward : freeSlot(0); + if (order < 0) continue; + slots[ASSIGNED_SLOT_INDEX[ASSIGNED_REPORT_ORDER[order]]] = entry.class; + cursor = order + 1; + } + return slots; +} + +/** + * ACE Arsenal exports a CBA extended loadout: [getUnitLoadout array, extended info]. + * Empty array metadata is accepted by CBA and keeps this parseSimpleArray-compatible. + */ +export function exportAceArsenal(inventory: InventorySnapshot): string { + const magazines = new Map((inventory.magazines ?? []).map((magazine) => [magazine.class, magazine])); + const primary = findWeapon(inventory, "primary"); + const launcher = findWeapon(inventory, "launcher", "secondary"); + const handgun = findWeapon(inventory, "handgun"); + const binocular = findWeapon(inventory, "binocular"); + const loadedByWeapon = assignLoadedMagazines(inventory); + const weaponLoadout = (weapon: Weapon | undefined): unknown[] => { + const loaded = weapon && loadedByWeapon.get(weapon); + return weaponArray(weapon, loaded?.primary, loaded?.secondary); + }; + const loadout = [ + weaponLoadout(primary), + weaponLoadout(launcher), + weaponLoadout(handgun), + containerArray(inventory.uniform, magazines), + containerArray(inventory.vest, magazines), + containerArray(inventory.backpack, magazines), + inventory.headgear?.class ? arsenalClass(inventory.headgear.class) : "", + inventory.goggles?.class ? arsenalClass(inventory.goggles.class) : "", + weaponLoadout(binocular), + assignedItemSlots(inventory), + ]; + return sqfValue([loadout, []]); +} diff --git a/ui/src/pages/recording-playback/components/PlayerProfileCard.module.css b/ui/src/pages/recording-playback/components/PlayerProfileCard.module.css index b84dddc17..eb81c3a20 100644 --- a/ui/src/pages/recording-playback/components/PlayerProfileCard.module.css +++ b/ui/src/pages/recording-playback/components/PlayerProfileCard.module.css @@ -168,6 +168,32 @@ font-size: 10px; } +.exportActions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 10px; +} + +.exportActions button { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 6px 9px; + color: var(--text-muted); + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 6px; + font: inherit; + font-size: 10px; + cursor: pointer; +} + +.exportActions button:hover { + color: var(--text-secondary); + background: rgba(255, 255, 255, 0.08); +} + .gearBlock { margin-bottom: 9px; padding: 8px; diff --git a/ui/src/pages/recording-playback/components/PlayerProfileCard.tsx b/ui/src/pages/recording-playback/components/PlayerProfileCard.tsx index 75d388a7e..f6b69ed0a 100644 --- a/ui/src/pages/recording-playback/components/PlayerProfileCard.tsx +++ b/ui/src/pages/recording-playback/components/PlayerProfileCard.tsx @@ -1,5 +1,6 @@ import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"; import type { JSX } from "solid-js"; +import { CopyIcon } from "../../../components/Icons"; import type { GearContainer, GearItem, @@ -26,6 +27,7 @@ import { loadWorldDem, worldHasElevation, type DemGrid } from "../../../playback import { marchAcreCoverage } from "../../../playback/radioRange/acreCoverage"; import { marchCoverage } from "../../../playback/radioRange/tfarCoverage"; import { MedicalBodyImage } from "./MedicalBodyImage"; +import { showHint } from "./Hint"; import { DiffEmpty, formatSigned, @@ -55,6 +57,7 @@ import { staminaReserve as reserveAt, } from "../profileDiff"; import styles from "./PlayerProfileCard.module.css"; +import { exportAceArsenal, exportVanillaArsenal } from "../arsenalExport"; type RangeMode = "simple" | "approx"; @@ -393,6 +396,18 @@ export function PlayerProfileCard(props: Props): JSX.Element { diffStamina(fromStamina(), toStamina(), fromInventory(), toInventory()), ); const radioChanges = createMemo(() => diffRadios(fromRadio(), toRadio())); + const copyGear = (format: "vanilla" | "ace") => { + const gear = diffing() ? toInventory() : inventory(); + if (!gear || !navigator.clipboard?.writeText) { + showHint(t("profile_export_failed")); + return; + } + const exported = format === "ace" ? exportAceArsenal(gear) : exportVanillaArsenal(gear); + void navigator.clipboard.writeText(exported).then( + () => showHint(t(format === "ace" ? "profile_exported_ace" : "profile_exported_vanilla")), + () => showHint(t("profile_export_failed")), + ); + }; const frameLabel = (frame: number) => formatTime(frame, "elapsed", engine.timeConfig); const toggleDiff = () => { if (diffing()) { @@ -818,6 +833,18 @@ export function PlayerProfileCard(props: Props): JSX.Element {
+ +
+ + +
+
Date: Thu, 3 Sep 2026 20:51:48 +0200 Subject: [PATCH 06/39] inventory tracking optimizations --- internal/storage/converted_recording_test.go | 124 +++++ .../convertedRecording.integration.test.ts | 454 ++++++++++++++++++ ui/src/data/__tests__/gearDisplayName.test.ts | 66 +++ ui/src/data/__tests__/snapshotDiff.test.ts | 123 ++++- ui/src/data/gearDisplayName.ts | 86 ++++ ui/src/data/snapshotDiff.ts | 73 ++- ui/src/data/types.ts | 32 +- .../__tests__/PlayerProfileCard.test.tsx | 138 +++--- .../__tests__/arsenalExport.test.ts | 73 ++- .../__tests__/gearCategories.test.ts | 113 ++--- .../__tests__/profileDiff.test.ts | 39 +- .../pages/recording-playback/arsenalExport.ts | 17 +- .../components/PlayerProfileCard.tsx | 44 +- .../recording-playback/gearCategories.ts | 89 ++-- .../pages/recording-playback/profileDiff.ts | 45 +- .../playback/__tests__/eventManager.test.ts | 18 +- 16 files changed, 1207 insertions(+), 327 deletions(-) create mode 100644 internal/storage/converted_recording_test.go create mode 100644 ui/src/data/__tests__/convertedRecording.integration.test.ts create mode 100644 ui/src/data/__tests__/gearDisplayName.test.ts create mode 100644 ui/src/data/gearDisplayName.ts diff --git a/internal/storage/converted_recording_test.go b/internal/storage/converted_recording_test.go new file mode 100644 index 000000000..6e69dbe7e --- /dev/null +++ b/internal/storage/converted_recording_test.go @@ -0,0 +1,124 @@ +package storage + +import ( + "compress/gzip" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// The converter in python/convert_recording_gear.py rewrites a recording's +// player snapshots. The server has to keep parsing it, because a recording is +// converted to protobuf in the background and served in chunks after that. +// +// The file is a large local artifact, so the test skips when it is absent. +// Point it elsewhere with OCAP_TEST_RECORDING_V3. +func convertedRecordingPath(t *testing.T) string { + t.Helper() + if env := os.Getenv("OCAP_TEST_RECORDING_V3"); env != "" { + return env + } + return filepath.Join("..", "..", "..", "No20Wonder20Conwoy_20260824_210930.v3.json.gz") +} + +func TestParserV1_ParsesConvertedRecording(t *testing.T) { + path := convertedRecordingPath(t) + file, err := os.Open(path) + if err != nil { + t.Skipf("converted recording not present at %s", path) + } + defer file.Close() + + reader, err := gzip.NewReader(file) + require.NoError(t, err) + defer reader.Close() + + var data map[string]interface{} + require.NoError(t, json.NewDecoder(reader).Decode(&data)) + + result, err := (&ParserV1{}).Parse(data, 1000) + require.NoError(t, err) + require.NotNil(t, result) + require.NotEmpty(t, result.Entities) + require.NotEmpty(t, result.Events) + + byType := map[string]int{} + for _, event := range result.Events { + byType[event.Type]++ + + switch event.Type { + case "inventorySnapshot", "medicalSnapshot", "staminaSnapshot", "radioSnapshot", "serverFps": + // The payload survives as JSON in the generic message field, which is + // what the web decoder parses back out on the other side. + require.NotEmpty(t, event.Message, "%s at frame %d lost its payload", event.Type, event.FrameNum) + var payload map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(event.Message), &payload), + "%s at frame %d is not valid JSON", event.Type, event.FrameNum) + if event.Type != "serverFps" { + require.Contains(t, payload, "unitId", + "%s at frame %d has no unitId to key it by", event.Type, event.FrameNum) + } + } + } + + for _, kind := range []string{"inventorySnapshot", "medicalSnapshot", "staminaSnapshot", "radioSnapshot", "serverFps"} { + require.Greater(t, byType[kind], 0, "no %s events survived parsing", kind) + } +} + +// FrameNum is a uint32, so a sample stamped at frame -1 wrapped to 4294967295 and +// landed past the end of the recording. The converter moves the serverFps sample +// that did this to frame 0. +// +// Upstream "connected" events are still written at -1 and are left alone: that +// predates the player-detail work and changing it is not this change's business. +func TestParserV1_ConvertedRecordingSnapshotFramesAreInRange(t *testing.T) { + path := convertedRecordingPath(t) + file, err := os.Open(path) + if err != nil { + t.Skipf("converted recording not present at %s", path) + } + defer file.Close() + + reader, err := gzip.NewReader(file) + require.NoError(t, err) + defer reader.Close() + + var data map[string]interface{} + require.NoError(t, json.NewDecoder(reader).Decode(&data)) + + events, ok := data["events"].([]interface{}) + require.True(t, ok) + + endFrame, ok := data["endFrame"].(float64) + require.True(t, ok) + + tracked := map[string]bool{ + "inventorySnapshot": true, + "medicalSnapshot": true, + "staminaSnapshot": true, + "radioSnapshot": true, + "serverFps": true, + } + + checked := 0 + for _, raw := range events { + arr, ok := raw.([]interface{}) + if !ok || len(arr) < 2 { + continue + } + kind, _ := arr[1].(string) + if !tracked[kind] { + continue + } + frame, ok := arr[0].(float64) + require.True(t, ok) + require.GreaterOrEqual(t, frame, float64(0), "%s sits before frame 0", kind) + require.LessOrEqual(t, frame, endFrame, "%s sits past endFrame", kind) + checked++ + } + require.Greater(t, checked, 0) +} diff --git a/ui/src/data/__tests__/convertedRecording.integration.test.ts b/ui/src/data/__tests__/convertedRecording.integration.test.ts new file mode 100644 index 000000000..5bdf92235 --- /dev/null +++ b/ui/src/data/__tests__/convertedRecording.integration.test.ts @@ -0,0 +1,454 @@ +/** + * End-to-end check that a recording converted by `python/convert_recording_gear.py` + * decodes and replays through the real UI code path: the JSON decoder, the event + * manager's diff reconstruction, gear categorisation, the display-name derivation + * and both Arsenal exports. + * + * It also rebuilds the *original* recording's snapshot chains and compares them + * against the converted ones, so a conversion that silently lost gear fails here + * rather than in the browser. + * + * Both files are large local artifacts, so the suite skips itself when they are + * not present. Point it somewhere else with OCAP_TEST_RECORDING / _V2. + */ +import { describe, expect, it } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { gunzipSync } from "node:zlib"; +import { resolve } from "node:path"; + +import { JsonDecoder } from "../decoders/jsonDecoder"; +import { applySnapshotDiff, isSnapshotDiff } from "../snapshotDiff"; +import { gearDisplayName, gearItemName } from "../gearDisplayName"; +import type { + EventDef, + InventorySnapshot, + MedicalSnapshot, + PlayerSnapshotPayload, + PlayerSnapshotType, + RadioSnapshot, + RawPlayerSnapshot, +} from "../types"; +import { + categorizeGearItems, + carriedMagazineRounds, + filledGearCategories, +} from "../../pages/recording-playback/gearCategories"; +import { + exportAceArsenal, + exportVanillaArsenal, +} from "../../pages/recording-playback/arsenalExport"; + +const REPO = resolve(__dirname, "../../../../.."); +const ORIGINAL = + process.env.OCAP_TEST_RECORDING ?? + resolve(REPO, "No20Wonder20Conwoy_20260824_210930.json.gz"); +const CONVERTED = + process.env.OCAP_TEST_RECORDING_V3 ?? + resolve(REPO, "No20Wonder20Conwoy_20260824_210930.v3.json.gz"); + +const SNAPSHOT_TYPES: PlayerSnapshotType[] = [ + "inventorySnapshot", + "medicalSnapshot", + "staminaSnapshot", + "radioSnapshot", +]; + +const available = existsSync(ORIGINAL) && existsSync(CONVERTED); + +function decode(path: string) { + const buffer = gunzipSync(readFileSync(path)); + return new JsonDecoder().decodeManifest( + buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer, + ); +} + +interface Sample { + frame: number; + payload: PlayerSnapshotPayload; +} + +/** The same rebuild EventManager.reconstructPlayerSnapshots does, per unit and type. */ +function rebuildChains(events: EventDef[]): Map { + const chains = new Map(); + const previous = new Map(); + for (const event of events) { + if (!SNAPSHOT_TYPES.includes(event.type as PlayerSnapshotType)) continue; + const raw = (event as unknown as { payload: RawPlayerSnapshot }).payload; + const key = `${raw.unitId}:${event.type}`; + const base = previous.get(key); + const payload = isSnapshotDiff(raw) + ? base + ? applySnapshotDiff(base, raw) + : ({ unitId: raw.unitId, ...raw.set } as PlayerSnapshotPayload) + : (raw as PlayerSnapshotPayload); + previous.set(key, payload); + const series = chains.get(key) ?? []; + series.push({ frame: event.frameNum, payload }); + chains.set(key, series); + } + return chains; +} + +const isAscii = (text: string): boolean => + [...text].every((character) => character.charCodeAt(0) < 128); + +/** What the profile card shows at a frame: the latest snapshot at or before it. */ +function stateAt(series: Sample[] | undefined, frame: number): PlayerSnapshotPayload | undefined { + let found: PlayerSnapshotPayload | undefined; + for (const sample of series ?? []) { + if (sample.frame > frame) break; + found = sample.payload; + } + return found; +} + +/** What a gear list means, independent of how the fields were spelled. */ +function gearFingerprint(inventory: InventorySnapshot) { + const items = (list: Array<{ class: string; count?: number }> | undefined) => + (list ?? []) + .map((i) => `${i.class}x${i.count ?? 1}`) + .sort() + .join(","); + return [ + inventory.uniform?.class ?? "", + items(inventory.uniform?.items), + inventory.vest?.class ?? "", + items(inventory.vest?.items), + inventory.backpack?.class ?? "", + items(inventory.backpack?.items), + inventory.headgear?.class ?? "", + inventory.goggles?.class ?? "", + (inventory.weapons ?? []) + .map((w) => `${w.slot}:${w.class}:${items(w.attachments)}`) + .sort() + .join("|"), + (inventory.magazines ?? []) + .map((m) => `${m.class}x${m.count ?? 1}:${m.totalRounds ?? ""}:${m.loadedCount ?? ""}`) + .sort() + .join("|"), + items(inventory.assignedItems), + String(inventory.load ?? ""), + String(inventory.massUnits ?? ""), + ].join("~"); +} + +describe.skipIf(!available)("a converted recording replays through the real UI path", () => { + const original = decode(ORIGINAL); + const converted = decode(CONVERTED); + + it("decodes to the same mission, entities, markers and non-snapshot events", () => { + expect(converted.missionName).toBe(original.missionName); + expect(converted.worldName).toBe(original.worldName); + expect(converted.endFrame).toBe(original.endFrame); + expect(converted.entities.length).toBe(original.entities.length); + expect(converted.markers.length).toBe(original.markers.length); + expect(converted.times.length).toBe(original.times.length); + + const others = (events: EventDef[]) => + events.filter((e) => !SNAPSHOT_TYPES.includes(e.type as PlayerSnapshotType)).length; + expect(others(converted.events)).toBe(others(original.events)); + }); + + it("stamps the first serverFps sample on a real frame", () => { + const before = original.events.filter((e) => e.type === "serverFps"); + const after = converted.events.filter((e) => e.type === "serverFps"); + expect(after.length).toBe(before.length); + expect(before.some((e) => e.frameNum < 0)).toBe(true); + expect(after.every((e) => e.frameNum >= 0)).toBe(true); + }); + + it("shows the same gear at every frame the original recorded one", () => { + const before = rebuildChains(original.events); + const after = rebuildChains(converted.events); + + const inventoryChains = [...before.keys()].filter((k) => k.endsWith("inventorySnapshot")); + expect(inventoryChains.length).toBeGreaterThan(0); + + let compared = 0; + for (const key of inventoryChains) { + const from = before.get(key) ?? []; + const to = after.get(key); + for (const { frame } of from) { + // What matters is the playhead lookup, not the event list. The converter + // drops a snapshot whose payload repeats what the previous one already + // said, and a recording can hold two snapshots of one kind on one frame, + // so both sides are resolved the way the card resolves them. + const want = stateAt(from, frame) as InventorySnapshot; + const got = stateAt(to, frame) as InventorySnapshot | undefined; + expect(got, `${key} has nothing at frame ${frame}`).toBeDefined(); + expect(gearFingerprint(got!), `${key} at frame ${frame}`).toBe(gearFingerprint(want)); + compared++; + } + } + expect(compared).toBeGreaterThan(1000); + }); + + it("only ever drops a snapshot that repeated what the file already said", () => { + const before = rebuildChains(original.events); + const after = rebuildChains(converted.events); + for (const [key, from] of before) { + if (key.endsWith("radioSnapshot")) continue; // has its own case below + const kept = new Set((after.get(key) ?? []).map((s) => s.frame)); + for (let i = 0; i < from.length; i++) { + if (kept.has(from[i].frame)) continue; + expect(i, `${key} dropped its first snapshot`).toBeGreaterThan(0); + expect( + JSON.stringify(from[i].payload), + `${key} dropped a snapshot at frame ${from[i].frame} that changed something`, + ).toBe(JSON.stringify(from[i - 1].payload)); + } + } + }); + + it("keeps every medical vital and body part at every recorded frame", () => { + const before = rebuildChains(original.events); + const after = rebuildChains(converted.events); + for (const key of [...before.keys()].filter((k) => k.endsWith("medicalSnapshot"))) { + const series = before.get(key) ?? []; + for (const { frame } of series) { + const from = stateAt(series, frame) as MedicalSnapshot; + const to = stateAt(after.get(key), frame) as MedicalSnapshot | undefined; + expect(to, `${key} has nothing at frame ${frame}`).toBeDefined(); + expect(to!.ace, `${key} ace at frame ${frame}`).toEqual(from.ace); + expect(to!.kat, `${key} kat at frame ${frame}`).toEqual(from.kat); + expect(to!.vanilla, `${key} vanilla at frame ${frame}`).toEqual(from.vanilla); + expect( + to!.bodyParts?.map((p) => `${p.part}:${p.damage ?? 0}:${p.items.length}`), + `${key} bodyParts at frame ${frame}`, + ).toEqual(from.bodyParts?.map((p) => `${p.part}:${p.damage ?? 0}:${p.items.length}`)); + } + } + }); + + it("keeps every stamina reading at every recorded frame", () => { + const before = rebuildChains(original.events); + const after = rebuildChains(converted.events); + for (const key of [...before.keys()].filter((k) => k.endsWith("staminaSnapshot"))) { + const series = before.get(key) ?? []; + for (const { frame } of series) { + const from = stateAt(series, frame) as unknown as Record; + const to = stateAt(after.get(key), frame) as unknown as Record | undefined; + expect(to, `${key} has nothing at frame ${frame}`).toBeDefined(); + expect(to!.vanilla, `${key} vanilla at frame ${frame}`).toEqual(from.vanilla); + expect(to!.ace, `${key} ace at frame ${frame}`).toEqual(from.ace); + } + } + }); + + it("shows the same radios at every frame, bar the leading empty ones it drops", () => { + const before = rebuildChains(original.events); + const after = rebuildChains(converted.events); + const ident = (snapshot: RadioSnapshot | undefined) => + (snapshot?.radios ?? []).map( + (r) => `${r.class}:${r.frequency}:${r.channel}:${r.rangeMeters}:${r.active ?? false}`, + ); + + for (const [key, series] of before) { + if (!key.endsWith("radioSnapshot")) continue; + for (const { frame } of series) { + const from = stateAt(series, frame) as RadioSnapshot; + const to = stateAt(after.get(key), frame) as RadioSnapshot | undefined; + if (to === undefined) { + // The only snapshot the converter withholds is one written before the + // unit had ever carried a radio, and those are empty by definition. + expect(from.radios ?? [], `${key} withheld a real radio at frame ${frame}`).toEqual([]); + continue; + } + expect(ident(to), `${key} at frame ${frame}`).toEqual(ident(from)); + } + } + }); + + it("keeps every radio, its frequency, channel and range", () => { + const after = rebuildChains(converted.events); + const radios = [...after.entries()].filter(([k]) => k.endsWith("radioSnapshot")); + expect(radios.length).toBeGreaterThan(0); + + let seen = 0; + for (const [, series] of radios) { + for (const { payload } of series) { + const snapshot = payload as RadioSnapshot; + for (const radio of snapshot.radios ?? []) { + seen++; + expect(typeof radio.class).toBe("string"); + expect(radio.class.length).toBeGreaterThan(0); + expect(typeof radio.frequency).toBe("number"); + expect(typeof radio.channel).toBe("number"); + expect(typeof radio.rangeMeters).toBe("number"); + // The card labels a radio from its class now, so it must resolve. + expect(gearDisplayName(radio.class).length).toBeGreaterThan(0); + } + } + } + expect(seen).toBeGreaterThan(0); + // A radio snapshot is never written before the unit carried one. + for (const [key, series] of radios) { + expect((series[0].payload as RadioSnapshot).radios?.length ?? 0, key).toBeGreaterThan(0); + } + }); + + it("carries no icon path anywhere in a snapshot", () => { + const after = rebuildChains(converted.events); + const offenders: string[] = []; + const walk = (node: unknown, path: string): void => { + if (Array.isArray(node)) { + node.forEach((child, i) => walk(child, `${path}[${i}]`)); + return; + } + if (!node || typeof node !== "object") return; + const record = node as Record; + if ("picture" in record) offenders.push(`${path}.picture`); + for (const [key, value] of Object.entries(record)) walk(value, `${path}.${key}`); + }; + for (const [key, series] of after) walk(series.map((s) => s.payload), key); + expect(offenders.slice(0, 10)).toEqual([]); + }); + + it("keeps the recorded display names and never invents a localized one", () => { + const after = rebuildChains(converted.events); + const entries: Array<{ class: string; name?: string }> = []; + for (const [key, series] of after) { + if (!key.endsWith("inventorySnapshot")) continue; + for (const { payload } of series) { + const snapshot = payload as InventorySnapshot; + for (const container of [snapshot.uniform, snapshot.vest, snapshot.backpack]) { + if (container) entries.push(container); + for (const item of container?.items ?? []) entries.push(item); + } + for (const item of snapshot.magazines ?? []) entries.push(item); + for (const item of snapshot.assignedItems ?? []) entries.push(item); + for (const weapon of snapshot.weapons ?? []) { + entries.push(weapon); + for (const attachment of weapon.attachments ?? []) entries.push(attachment); + } + if (snapshot.headgear) entries.push(snapshot.headgear); + if (snapshot.goggles) entries.push(snapshot.goggles); + } + } + // An empty equipment slot is recorded as a blank class and has no label. + const worn = entries.filter((entry) => entry.class); + expect(worn.length).toBeGreaterThan(1000); + + // The point of the exercise: a display name, not a class name. + const named = worn.filter((entry) => entry.name); + expect(named.length / worn.length).toBeGreaterThan(0.9); + expect(named.some((entry) => entry.name === "5.56mm 30rnd Tracer Mag")).toBe(true); + + const localized = new Set(); + for (const entry of worn) { + const label = gearItemName(entry); + expect(label.length, entry.class).toBeGreaterThan(0); + if (!isAscii(label)) { + // A label is only ever non-ASCII because the recording held a name the + // mods' stringtables had no English for, and the converter keeps those + // rather than dropping the name. Nothing is introduced by the UI. + expect(entry.name, `${entry.class} produced a non-ASCII derived label`).toBe(label); + localized.add(label); + } + } + // A handful of pure-vanilla strings that no bundled stringtable covers. + expect(localized.size).toBeLessThan(20); + }); + + it("groups gear into the categories the card renders", () => { + const after = rebuildChains(converted.events); + let checked = 0; + let sawMagazines = false; + let sawGrenades = false; + let sawMedical = false; + + for (const [key, series] of after) { + if (!key.endsWith("inventorySnapshot")) continue; + for (const { payload } of series) { + const snapshot = payload as InventorySnapshot; + const groups = categorizeGearItems(snapshot.backpack?.items); + const filled = filledGearCategories(groups); + // Order is fixed and every returned group is non-empty. + expect(filled).toEqual( + ["magazines", "grenades", "medical", "items"].filter((k) => filled.includes(k as never)), + ); + sawMagazines ||= groups.magazines.length > 0; + sawGrenades ||= groups.grenades.length > 0; + sawMedical ||= groups.medical.length > 0; + // Rounds must still add up only over firearm magazines. + const rounds = carriedMagazineRounds(snapshot.magazines); + if (rounds !== undefined) expect(rounds).toBeGreaterThanOrEqual(0); + checked++; + } + } + expect(checked).toBeGreaterThan(0); + expect({ sawMagazines, sawGrenades, sawMedical }).toEqual({ + sawMagazines: true, + sawGrenades: true, + sawMedical: true, + }); + }); + + it("derives an ASCII English label for every class it sees", () => { + const after = rebuildChains(converted.events); + const nonAscii: string[] = []; + const classes = new Set(); + for (const [key, series] of after) { + if (!key.endsWith("inventorySnapshot")) continue; + for (const { payload } of series) { + const snapshot = payload as InventorySnapshot; + for (const container of [snapshot.uniform, snapshot.vest, snapshot.backpack]) { + for (const item of container?.items ?? []) classes.add(item.class); + } + for (const magazine of snapshot.magazines ?? []) classes.add(magazine.class); + for (const item of snapshot.assignedItems ?? []) classes.add(item.class); + for (const weapon of snapshot.weapons ?? []) classes.add(weapon.class); + } + } + expect(classes.size).toBeGreaterThan(50); + for (const cls of classes) { + const label = gearDisplayName(cls); + expect(label.length, cls).toBeGreaterThan(0); + if (!isAscii(label)) nonAscii.push(`${cls} -> ${label}`); + } + // The fallback is built from the class, so it cannot be anything but ASCII. + expect(nonAscii).toEqual([]); + }); + + it("still produces both Arsenal exports from a converted loadout", () => { + const after = rebuildChains(converted.events); + const loadouts = [...after.entries()] + .filter(([k]) => k.endsWith("inventorySnapshot")) + .flatMap(([, series]) => series.map((s) => s.payload as InventorySnapshot)) + .filter((s) => s.weapons?.length && s.assignedItems?.length); + expect(loadouts.length).toBeGreaterThan(0); + + const loadout = loadouts[0]; + const vanilla = exportVanillaArsenal(loadout); + expect(vanilla.split("\n")[0]).toBe('comment "Exported from OCAP";'); + expect(vanilla.split("\n")).not.toContain(""); + expect(vanilla).toContain("this addWeapon "); + expect(vanilla).toContain("this linkItem "); + // TFAR per-instance clones are traded for the prototype in both exports. + expect(vanilla).not.toMatch(/"TFAR_\w+_\d+"/); + + const ace = exportAceArsenal(loadout); + expect(ace.startsWith("[[")).toBe(true); + expect(ace.endsWith("]]")).toBe(true); + expect(ace).not.toMatch(/"TFAR_\w+_\d+"/); + // A CBA extended loadout is [, ], and the + // text has to stay parseSimpleArray-compatible, which is also valid JSON here. + const parsed = JSON.parse(ace) as [unknown[], unknown[]]; + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toHaveLength(2); + expect(parsed[1]).toEqual([]); + const loadoutArray = parsed[0]; + expect(loadoutArray).toHaveLength(10); + // Index 9 is [map, GPS/UAV terminal, radio, compass, watch, NVG/HMD]. + const slots = loadoutArray[9] as string[]; + expect(slots).toHaveLength(6); + expect(slots.every((slot) => typeof slot === "string")).toBe(true); + expect(slots[0]).toBe("ItemMap"); + expect(slots.some((slot) => slot !== "")).toBe(true); + + for (const other of loadouts.slice(0, 200)) { + expect(() => exportVanillaArsenal(other)).not.toThrow(); + expect(() => exportAceArsenal(other)).not.toThrow(); + } + }); +}); diff --git a/ui/src/data/__tests__/gearDisplayName.test.ts b/ui/src/data/__tests__/gearDisplayName.test.ts new file mode 100644 index 000000000..7ec05574f --- /dev/null +++ b/ui/src/data/__tests__/gearDisplayName.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { baseGearClass, gearDisplayName, gearItemName } from "../gearDisplayName"; + +describe("gearDisplayName", () => { + it("splits camelCase class names into English words", () => { + expect(gearDisplayName("ACE_elasticBandage")).toBe("Elastic Bandage"); + expect(gearDisplayName("ACE_packingBandage")).toBe("Packing Bandage"); + expect(gearDisplayName("ACE_EntrenchingTool")).toBe("Entrenching Tool"); + expect(gearDisplayName("FirstAidKit")).toBe("First Aid Kit"); + expect(gearDisplayName("SmokeShell")).toBe("Smoke Shell"); + expect(gearDisplayName("HandGrenade")).toBe("Hand Grenade"); + }); + + it("keeps an existing acronym as one word", () => { + expect(gearDisplayName("ACE_salineIV_500")).toBe("Saline IV 500"); + expect(gearDisplayName("ItemGPS")).toBe("GPS"); + }); + + it("drops the engine's Item prefix from vanilla assigned items", () => { + expect(gearDisplayName("ItemMap")).toBe("Map"); + expect(gearDisplayName("ItemCompass")).toBe("Compass"); + expect(gearDisplayName("ItemWatch")).toBe("Watch"); + expect(gearDisplayName("ItemRadio")).toBe("Radio"); + expect(gearDisplayName("Binocular")).toBe("Binocular"); + }); + + it("never splits a designator between its letters and its digits", () => { + expect(gearDisplayName("UK3CB_BAF_L85A2_UGLLAD_LDSR2D_IR")).toBe("L85A2 UGLLAD LDSR2D IR"); + expect(gearDisplayName("UK3CB_BAF_556_30Rnd_T")).toBe("556 30Rnd T"); + }); + + it("trades a TFAR per-instance radio clone for its prototype", () => { + expect(baseGearClass("TFAR_anprc152_54")).toBe("TFAR_anprc152"); + expect(baseGearClass("TFAR_anprc152")).toBe("TFAR_anprc152"); + expect(baseGearClass("ACE_salineIV_500")).toBe("ACE_salineIV_500"); + expect(gearDisplayName("TFAR_anprc152_54")).toBe("ANPRC152"); + }); + + it("falls back to the class when nothing is left to show", () => { + expect(gearDisplayName("")).toBe(""); + expect(gearDisplayName(undefined)).toBe(""); + expect(gearDisplayName("_")).toBe("_"); + expect(gearDisplayName("ACE_")).toBe("ACE"); + }); + + it("prefers the display name the recorder read from config", () => { + expect(gearItemName({ class: "UK3CB_BAF_556_30Rnd_T", name: "5.56mm 30rnd Tracer Mag" })) + .toBe("5.56mm 30rnd Tracer Mag"); + expect(gearItemName({ class: "TFAR_anprc152_54", name: "AN/PRC-152" })).toBe("AN/PRC-152"); + }); + + it("derives one for an entry that carries no name", () => { + // The recorder applies the same derivation when config only had a + // "$STR_..." reference, so this is what such an item is named there too. + expect(gearItemName({ class: "ACE_elasticBandage" })).toBe("Elastic Bandage"); + expect(gearItemName({ class: "ItemMap", name: " " })).toBe("Map"); + expect(gearItemName(undefined)).toBe(""); + }); + + it("gives the same label to a class regardless of the recording server's language", () => { + // The point of the whole exercise: a Czech server used to write "Mapa" here. + expect(gearDisplayName("ItemMap")).toBe(gearDisplayName("ItemMap")); + expect(gearDisplayName("kat_Pulseoximeter")).toBe("Pulseoximeter"); + expect(gearDisplayName("ACE_morphine")).toBe("Morphine"); + }); +}); diff --git a/ui/src/data/__tests__/snapshotDiff.test.ts b/ui/src/data/__tests__/snapshotDiff.test.ts index 92ea9ca0d..a07a6a43a 100644 --- a/ui/src/data/__tests__/snapshotDiff.test.ts +++ b/ui/src/data/__tests__/snapshotDiff.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { applySnapshotDiff, isSnapshotDiff } from "../snapshotDiff"; -import type { MedicalSnapshot, PlayerSnapshotDiff } from "../types"; +import type { InventorySnapshot, MedicalSnapshot, PlayerSnapshotDiff } from "../types"; const base = (): MedicalSnapshot => ({ unitId: 3, @@ -36,7 +36,7 @@ describe("applySnapshotDiff", () => { expect(result.playerUid).toBe("76561198000000000"); }); - it("replaces arrays wholesale instead of merging them", () => { + it("replaces an array wholesale when the diff carries a plain array", () => { const diff: PlayerSnapshotDiff = { unitId: 3, diffOf: 40, @@ -54,6 +54,125 @@ describe("applySnapshotDiff", () => { expect(result.bodyParts?.[1].items[0].name).toBe("Bandaged wound"); }); + it("patches an array entry by entry when the diff names a key", () => { + const inventory: InventorySnapshot = { + unitId: 3, + load: 0.5, + uniform: { class: "u", items: [] }, + vest: { class: "v", items: [] }, + backpack: { class: "b", items: [] }, + headgear: { class: "" }, + goggles: { class: "" }, + weapons: [], + assignedItems: [], + magazines: [ + { class: "30Rnd", cat: "magazines", count: 5, totalRounds: 150 }, + { class: "Chemlight_green", cat: "grenades", count: 1, totalRounds: 1 }, + { class: "9Rnd", cat: "magazines", count: 2, totalRounds: 18 }, + ], + }; + + const result = applySnapshotDiff(inventory, { + unitId: 3, + diffOf: 40, + set: { + magazines: { + $key: "class", + put: [ + { class: "30Rnd", totalRounds: 149 }, + { class: "SmokeShell", cat: "grenades", count: 1, totalRounds: 1 }, + ], + del: ["Chemlight_green"], + }, + }, + }) as InventorySnapshot; + + expect(result.magazines).toEqual([ + // Untouched fields survive; the dropped entry is gone; the new one is appended. + { class: "30Rnd", cat: "magazines", count: 5, totalRounds: 149 }, + { class: "9Rnd", cat: "magazines", count: 2, totalRounds: 18 }, + { class: "SmokeShell", cat: "grenades", count: 1, totalRounds: 1 }, + ]); + // The snapshot the diff was built on is left alone. + expect(inventory.magazines[0].totalRounds).toBe(150); + expect(inventory.magazines).toHaveLength(3); + }); + + it("restores the sampled order when ord is present", () => { + const inventory: InventorySnapshot = { + unitId: 3, + load: 0.5, + uniform: { class: "u", items: [] }, + vest: { class: "v", items: [] }, + backpack: { class: "b", items: [] }, + headgear: { class: "" }, + goggles: { class: "" }, + weapons: [], + magazines: [], + // assignedItems is read positionally by the Arsenal export, so a radio + // swapped in has to land where the engine walks that slot, not at the end. + assignedItems: [ + { class: "ItemMap", count: 1 }, + { class: "ItemCompass", count: 1 }, + { class: "ItemRadio", count: 1 }, + { class: "ItemGPS", count: 1 }, + ], + }; + + const result = applySnapshotDiff(inventory, { + unitId: 3, + diffOf: 40, + set: { + assignedItems: { + $key: "class", + put: [{ class: "TFAR_anprc152_3", count: 1 }], + del: ["ItemRadio"], + ord: ["ItemMap", "ItemCompass", "TFAR_anprc152_3", "ItemGPS"], + }, + }, + }) as InventorySnapshot; + + expect(result.assignedItems.map((item) => item.class)).toEqual([ + "ItemMap", + "ItemCompass", + "TFAR_anprc152_3", + "ItemGPS", + ]); + }); + + it("patches an array nested inside an object", () => { + const inventory: InventorySnapshot = { + unitId: 3, + load: 0.5, + uniform: { class: "u", items: [{ class: "ACE_CableTie", count: 2 }] }, + vest: { class: "v", items: [] }, + backpack: { class: "b", items: [] }, + headgear: { class: "" }, + goggles: { class: "" }, + weapons: [{ class: "arifle_MX", slot: "primary", attachments: [] }], + assignedItems: [], + magazines: [], + }; + + const result = applySnapshotDiff(inventory, { + unitId: 3, + diffOf: 40, + set: { + uniform: { items: { $key: "class", put: [{ class: "ACE_CableTie", count: 1 }] } }, + weapons: { + $key: "slot", + put: [{ slot: "primary", attachments: [{ class: "optic_Hamr" }] }], + }, + }, + }) as InventorySnapshot; + + expect(result.uniform.items).toEqual([{ class: "ACE_CableTie", count: 1 }]); + expect(result.uniform.class).toBe("u"); + expect(result.weapons).toEqual([ + { class: "arifle_MX", slot: "primary", attachments: [{ class: "optic_Hamr" }] }, + ]); + }); + it("removes keys listed in unset, including nested paths", () => { const diff: PlayerSnapshotDiff = { unitId: 3, diff --git a/ui/src/data/gearDisplayName.ts b/ui/src/data/gearDisplayName.ts new file mode 100644 index 000000000..ff79105b8 --- /dev/null +++ b/ui/src/data/gearDisplayName.ts @@ -0,0 +1,86 @@ +/** + * How an item is labelled in the profile card. + * + * The recorder writes an English display name for every item. It reads config + * through `getTextRaw`, so what it gets is the mod author's own text rather than + * `getText`'s translation into the *recording server's* language — that is what + * froze "Mapa" and "Obvaz (Elastický)" into a file shown to viewers in eight + * locales. Where the config only held a `$STR_...` reference, which SQF cannot + * read in another language, the recorder builds the name from the class with the + * same derivation implemented below. + * + * So `gearItemName` almost always just returns what was recorded. The derivation + * is the fallback for a recording that carries no name for an entry, and it is + * kept in step with `OCAP_recorder_client_deriveNameFromClass` in the addon's + * `fnc_installPlayerTrackingClients.sqf`. + */ + +/** Mod families whose prefix says nothing about the item. Longest first. */ +const MOD_PREFIXES = [ + "uk3cb_baf_", + "immersion_", + "rhsgref_", + "rhsusf_", + "rhssaf_", + "uk3cb_", + "acex_", + "rksl_", + "acre_", + "tfar_", + "ace_", + "kat_", + "cba_", + "rhs_", + "cup_", + "tfr_", +]; + +/** + * TFAR clones every carried handheld into a per-instance class that holds the + * radio's channel state (`TFAR_anprc152_54`). The prototype is the radio. + */ +export function baseGearClass(className: string): string { + return /^(?:tfar|tf)_/i.test(className) ? className.replace(/^(.+?)_\d+$/, "$1") : className; +} + +/** A letter run followed by a digit run is a designator: `anprc152` -> `ANPRC152`. */ +const DESIGNATOR = /^[A-Za-z]{2,6}\d{2,4}$/; + +const cache = new Map(); + +export function gearDisplayName(className: string | undefined): string { + if (!className) return ""; + const cached = cache.get(className); + if (cached !== undefined) return cached; + + let base = baseGearClass(className); + const lower = base.toLowerCase(); + const prefix = MOD_PREFIXES.find((p) => lower.startsWith(p) && base.length > p.length); + if (prefix) base = base.slice(prefix.length); + + // "ItemMap", "ItemGPS": the Item prefix is engine bookkeeping, not a name. + base = base.replace(/^Item(?=[A-Z])/, ""); + + // Split on separators and at every lowercase-to-uppercase step, but never + // between a digit and a letter, so L85A2 stays a single word. + const words = base + .split(/[_\s-]+/) + .flatMap((token) => token.split(/(?<=[a-z])(?=[A-Z])/)) + .filter(Boolean) + .map((word) => { + // A word the mod author already capitalised is left exactly as written. + if (word !== word.toLowerCase()) return word; + return DESIGNATOR.test(word) ? word.toUpperCase() : word.charAt(0).toUpperCase() + word.slice(1); + }); + + const name = words.join(" ") || className; + cache.set(className, name); + return name; +} + +/** The name to show for a gear entry: what was recorded, else derived. */ +export function gearItemName(item: { class?: string; name?: string } | undefined): string { + const recorded = item?.name?.trim(); + if (recorded) return recorded; + return gearDisplayName(item?.class); +} diff --git a/ui/src/data/snapshotDiff.ts b/ui/src/data/snapshotDiff.ts index 47f7d2291..805aaf918 100644 --- a/ui/src/data/snapshotDiff.ts +++ b/ui/src/data/snapshotDiff.ts @@ -1,9 +1,68 @@ -import type { PlayerSnapshotDiff, PlayerSnapshotPayload, RawPlayerSnapshot } from "./types"; +import type { + KeyedArrayPatch, + PlayerSnapshotDiff, + PlayerSnapshotPayload, + RawPlayerSnapshot, +} from "./types"; function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function isKeyedArrayPatch(value: unknown): value is KeyedArrayPatch { + return isPlainObject(value) && typeof value.$key === "string"; +} + +/** + * Apply an entry-wise array patch. Rewriting a whole magazine list because one + * round was fired was the single largest cost in a recording, so a follow-up + * snapshot names only the entries that moved. + */ +function mergedArray(base: unknown, patch: KeyedArrayPatch): unknown[] { + const key = patch.$key; + const deleted = new Set(patch.del ?? []); + const result: unknown[] = []; + const positions = new Map(); + + for (const entry of Array.isArray(base) ? base : []) { + const id = isPlainObject(entry) ? entry[key] : undefined; + if (typeof id === "string") { + if (deleted.has(id)) continue; + positions.set(id, result.length); + } + result.push(entry); + } + + for (const entry of patch.put ?? []) { + const id = entry?.[key]; + if (typeof id !== "string") continue; + const at = positions.get(id); + if (at === undefined) { + positions.set(id, result.length); + result.push(entry); + } else { + result[at] = merged(result[at] as Record, entry); + } + } + + // `ord` is present only when put and del alone would leave the entries in the + // wrong order. Position is data here: the Arsenal export reads assignedItems + // by the order the engine walks the slots in. + if (patch.ord) { + const rank = new Map(patch.ord.map((id, at) => [id, at])); + const fallback = patch.ord.length; + return result + .map((entry, at) => { + const id = isPlainObject(entry) ? entry[key] : undefined; + return { entry, at, rank: typeof id === "string" ? rank.get(id) ?? fallback : fallback }; + }) + .sort((a, b) => a.rank - b.rank || a.at - b.at) + .map((row) => row.entry); + } + + return result; +} + /** * A follow-up snapshot carries only what changed since the frame named by `diffOf`, * which is what keeps recordings small when a player's gear or radios sit unchanged @@ -13,7 +72,10 @@ export function isSnapshotDiff(payload: RawPlayerSnapshot): payload is PlayerSna return typeof (payload as PlayerSnapshotDiff).diffOf === "number"; } -/** Deep merge, replacing arrays wholesale. Never mutates `target` or `patch`. */ +/** + * Deep merge. An array arrives either as a whole replacement or as a keyed patch + * naming just the entries that changed. Never mutates `target` or `patch`. + */ function merged( target: Record, patch: Record, @@ -21,8 +83,11 @@ function merged( const result = { ...target }; for (const [key, value] of Object.entries(patch)) { const existing = result[key]; - result[key] = - isPlainObject(value) && isPlainObject(existing) ? merged(existing, value) : value; + result[key] = isKeyedArrayPatch(value) + ? mergedArray(existing, value) + : isPlainObject(value) && isPlainObject(existing) + ? merged(existing, value) + : value; } return result; } diff --git a/ui/src/data/types.ts b/ui/src/data/types.ts index 455ed8363..f11648cdd 100644 --- a/ui/src/data/types.ts +++ b/ui/src/data/types.ts @@ -108,11 +108,25 @@ export type PlayerSnapshotType = | "staminaSnapshot" | "radioSnapshot"; +/** How the profile card groups an item. Absent means "items", the default. */ +export type GearCategoryTag = "magazines" | "grenades" | "medical"; + export interface GearItem { class: string; - name: string; - picture?: string; + /** + * The item's display name as the recorder read it from config, in English. + * Absent when the config only had a stringtable reference, which cannot be + * read in another language from SQF; `gearItemName` derives one from the class. + */ + name?: string; count?: number; + /** + * Resolved by the recorder from config. Recordings used to carry the item's + * `.paa` icon path so the UI could substring-match it into a group, which cost + * megabytes for a path no browser can load. Only the lists the card groups — + * container cargo and magazines — carry it. + */ + cat?: GearCategoryTag; } export interface GearContainer extends GearItem { @@ -143,6 +157,7 @@ export interface InventorySnapshot { */ export interface TreatmentItem { kind: string; + /** An English label the recorder writes; never a localized config name. */ name: string; count?: number; detail?: string; @@ -222,6 +237,19 @@ export interface PlayerSnapshotDiff { unset?: string[]; } +/** + * An array patched entry by entry instead of replaced. `$key` names the field + * that identifies an entry, `put` upserts (merging onto the entry already there) + * and `del` removes. Entries the patch does not mention keep their place. + */ +export interface KeyedArrayPatch { + $key: string; + put?: Array>; + del?: string[]; + /** Present only when put and del alone would leave the entries out of order. */ + ord?: string[]; +} + export type RawPlayerSnapshot = PlayerSnapshotPayload | PlayerSnapshotDiff; export interface PlayerSnapshotEventDef { diff --git a/ui/src/pages/recording-playback/__tests__/PlayerProfileCard.test.tsx b/ui/src/pages/recording-playback/__tests__/PlayerProfileCard.test.tsx index 03a94be31..0508ff6c8 100644 --- a/ui/src/pages/recording-playback/__tests__/PlayerProfileCard.test.tsx +++ b/ui/src/pages/recording-playback/__tests__/PlayerProfileCard.test.tsx @@ -75,11 +75,11 @@ describe("PlayerProfileCard", () => { }); renderCard([ snapshotEvent(5, "inventorySnapshot", { - uniform: { class: "U_B_CombatUniform_mcam", name: "Uniform", items: [] }, - vest: { class: "", name: "", items: [] }, - backpack: { class: "", name: "", items: [] }, - headgear: { class: "", name: "" }, - goggles: { class: "", name: "" }, + uniform: { class: "U_B_CombatUniform_mcam", items: [] }, + vest: { class: "", items: [] }, + backpack: { class: "", items: [] }, + headgear: { class: "" }, + goggles: { class: "" }, weapons: [], magazines: [], assignedItems: [], @@ -341,7 +341,6 @@ describe("PlayerProfileCard", () => { radios: [ { class: "TFAR_anprc152_1", - name: "AN/PRC-152", mod: "TFAR", type: "SW", channel: 1, @@ -354,7 +353,7 @@ describe("PlayerProfileCard", () => { }), snapshotEvent(20, "radioSnapshot", { diffOf: 5, - set: { radios: [{ class: "TFAR_anprc152_1", name: "AN/PRC-152", mod: "TFAR", type: "SW", channel: 3, frequency: 101, code: "", rangeMeters: 2000, additional: false }] }, + set: { radios: [{ class: "TFAR_anprc152_1", mod: "TFAR", type: "SW", channel: 3, frequency: 101, code: "", rangeMeters: 2000, additional: false }] }, }), ], 20, @@ -372,7 +371,6 @@ describe("PlayerProfileCard", () => { radios: [ { class: "TFAR_anprc152_1", - name: "AN/PRC-152", mod: "TFAR", type: "SW", channel: 1, @@ -411,7 +409,6 @@ describe("PlayerProfileCard", () => { radios: [ { class: "TFAR_anprc152_1", - name: "AN/PRC-152", mod: "TFAR", type: "SW", channel: 1, @@ -444,7 +441,6 @@ describe("PlayerProfileCard", () => { radios: [ { class: "ACRE_PRC152", - name: "AN/PRC-152", mod: "ACRE", type: "SR", channel: 1, @@ -542,41 +538,37 @@ describe("PlayerProfileCard", () => { weapons: [ { class: "hlc_pistol_P226R_Combat", - name: "SigSauer P226R Combat", slot: "handgun", attachments: [], }, ], magazines: [ - { class: "rhs_mag_30Rnd_556x45_Mk318_PMAG", name: "30rnd PMAG Mk318 Mod 0", count: 5 }, - { class: "ACE_16Rnd_9x19_mag", name: "9x19 mm 16Rnd Mag", count: 2 }, - { class: "hlc_15Rnd_9x19_B_P226", name: "9mm FMJ 15rnd P226 Magazine", count: 1, loadedCount: 1 }, - { class: "kat_Painkiller", name: "Painkillers", count: 3 }, + { class: "rhs_mag_30Rnd_556x45_Mk318_PMAG", cat: "magazines", count: 5 }, + { class: "ACE_16Rnd_9x19_mag", cat: "magazines", count: 2 }, + { class: "hlc_15Rnd_9x19_B_P226", cat: "magazines", count: 1, loadedCount: 1 }, + { class: "kat_Painkiller", cat: "medical", count: 3 }, ], assignedItems: [], uniform: { class: "UK3CB_BAF_U_CombatUniform_MTP_RM", - name: "Combat Uniform MTP RM [BAF]", items: [ - { class: "ACE_MapTools", name: "Map Tools", count: 1 }, - { class: "rhs_mag_30Rnd_556x45_Mk318_PMAG", name: "30rnd PMAG Mk318 Mod 0", count: 1 }, + { class: "ACE_MapTools", count: 1 }, + { class: "rhs_mag_30Rnd_556x45_Mk318_PMAG", cat: "magazines", count: 1 }, ], }, vest: { class: "UK3CB_BAF_V_Osprey_SL_A", - name: "Osprey Mk4 MTP SL (A) [BAF]", items: [ - { class: "ACE_CableTie", name: "Cable Tie", count: 2 }, - { class: "ACE_16Rnd_9x19_mag", name: "9x19 mm 16Rnd Mag", count: 2 }, - { class: "rhs_mag_30Rnd_556x45_Mk318_PMAG", name: "30rnd PMAG Mk318 Mod 0", count: 4 }, + { class: "ACE_CableTie", count: 2 }, + { class: "ACE_16Rnd_9x19_mag", cat: "magazines", count: 2 }, + { class: "rhs_mag_30Rnd_556x45_Mk318_PMAG", cat: "magazines", count: 4 }, ], }, backpack: { class: "UK3CB_BAF_B_Bergen_MTP_SL_L_A", - name: "Bergen MTP SL L [BAF]", items: [ - { class: "ACE_packingBandage", name: "Bandage (Packing)", count: 17 }, - { class: "kat_Painkiller", name: "Painkillers", count: 3 }, + { class: "ACE_packingBandage", cat: "medical", count: 17 }, + { class: "kat_Painkiller", cat: "medical", count: 3 }, ], }, }), @@ -584,25 +576,25 @@ describe("PlayerProfileCard", () => { const weaponsBlock = screen.getByText("Weapons").closest("[class*='gearBlock']"); expect(weaponsBlock?.nextElementSibling?.textContent).toContain("Uniform"); - expect(weaponsBlock?.textContent).toContain("9mm FMJ 15rnd P226 Magazine"); + expect(weaponsBlock?.textContent).toContain("Hlc 15Rnd 9x19 B P226"); - const uniformBlock = screen.getByText("Combat Uniform MTP RM [BAF]").closest("[class*='gearBlock']"); + const uniformBlock = screen.getByText("U Combat Uniform MTP RM").closest("[class*='gearBlock']"); expect(uniformBlock?.textContent).toContain("Magazines"); expect(uniformBlock?.textContent).toContain("Items"); - expect(uniformBlock?.textContent).toContain("30rnd PMAG Mk318 Mod 0"); + expect(uniformBlock?.textContent).toContain("Mag 30Rnd 556x45 Mk318 PMAG"); expect(uniformBlock?.textContent).toContain("Map Tools"); - const vestBlock = screen.getByText("Osprey Mk4 MTP SL (A) [BAF]").closest("[class*='gearBlock']"); + const vestBlock = screen.getByText("V Osprey SL A").closest("[class*='gearBlock']"); expect(vestBlock?.textContent).toContain("Magazines"); - expect(vestBlock?.textContent).toContain("9x19 mm 16Rnd Mag ×2"); - expect(vestBlock?.textContent).toContain("30rnd PMAG Mk318 Mod 0 ×4"); + expect(vestBlock?.textContent).toContain("16Rnd 9x19 Mag ×2"); + expect(vestBlock?.textContent).toContain("Mag 30Rnd 556x45 Mk318 PMAG ×4"); expect(vestBlock?.textContent).toContain("Cable Tie ×2"); - const backpackBlock = screen.getByText("Bergen MTP SL L [BAF]").closest("[class*='gearBlock']"); + const backpackBlock = screen.getByText("B Bergen MTP SL L A").closest("[class*='gearBlock']"); expect(backpackBlock?.textContent).toContain("Medical"); expect(backpackBlock?.textContent).not.toContain("Magazines"); - expect(backpackBlock?.textContent).toContain("Painkillers ×3"); - expect(backpackBlock?.textContent).toContain("Bandage (Packing) ×17"); + expect(backpackBlock?.textContent).toContain("Painkiller ×3"); + expect(backpackBlock?.textContent).toContain("Packing Bandage ×17"); expect(screen.getAllByText("Magazines").every((el) => el.className.includes("sectionLabel"))).toBe(true); }); @@ -614,18 +606,17 @@ describe("PlayerProfileCard", () => { weapons: [], magazines: [], assignedItems: [], - vest: { class: "vest", name: "Osprey", items: [] }, + vest: { class: "Osprey", items: [] }, }), snapshotEvent(20, "inventorySnapshot", { weapons: [], - magazines: [{ class: "30Rnd", name: "30rnd mag", count: 4 }], + magazines: [{ class: "30Rnd", cat: "magazines", count: 4 }], assignedItems: [], vest: { - class: "vest", - name: "Osprey", + class: "Osprey", items: [ - { class: "ACE_CableTie", name: "Cable Tie", count: 2 }, - { class: "30Rnd", name: "30rnd mag", count: 4 }, + { class: "ACE_CableTie", count: 2 }, + { class: "30Rnd", cat: "magazines", count: 4 }, ], }, }), @@ -634,14 +625,14 @@ describe("PlayerProfileCard", () => { ); expect(screen.queryByText("Magazines")).toBeNull(); - expect(screen.queryByText("30rnd mag ×4")).toBeNull(); + expect(screen.queryByText("30Rnd ×4")).toBeNull(); engine.seekTo(20); const vestBlock = screen.getByText("Osprey").closest("[class*='gearBlock']"); expect(vestBlock?.textContent).toContain("Magazines"); expect(vestBlock?.textContent).toContain("Items"); - expect(vestBlock?.textContent).toContain("30rnd mag ×4"); + expect(vestBlock?.textContent).toContain("30Rnd ×4"); expect(vestBlock?.textContent).toContain("Cable Tie ×2"); }); @@ -650,23 +641,22 @@ describe("PlayerProfileCard", () => { snapshotEvent(5, "inventorySnapshot", { weapons: [], magazines: [ - { class: "HandGrenade", name: "M67 Fragmentation Grenade", count: 1 }, - { class: "SmokeShell", name: "M83 Smoke Grenade (White)", count: 2 }, - { class: "rhs_mag_30Rnd_556x45_Mk318_PMAG", name: "30rnd PMAG Mk318 Mod 0", count: 4 }, - { class: "kat_Painkiller", name: "Painkillers", count: 3 }, + { class: "HandGrenade", cat: "grenades", count: 1 }, + { class: "SmokeShell", cat: "grenades", count: 2 }, + { class: "rhs_mag_30Rnd_556x45_Mk318_PMAG", cat: "magazines", count: 4 }, + { class: "kat_Painkiller", cat: "medical", count: 3 }, ], assignedItems: [], vest: { - class: "vest", - name: "Osprey", + class: "Osprey", items: [ - { class: "ACE_CableTie", name: "Cable Tie", count: 2 }, - { class: "SmokeShell", name: "M83 Smoke Grenade (White)", count: 2 }, - { class: "ACE_splint", name: "Splint", count: 2 }, - { class: "rhs_mag_30Rnd_556x45_Mk318_PMAG", name: "30rnd PMAG Mk318 Mod 0", count: 4 }, - { class: "HandGrenade", name: "M67 Fragmentation Grenade", count: 1 }, - { class: "ACE_adenosine", name: "Adenosine Autoinjector", count: 1 }, - { class: "ACE_MapTools", name: "Map Tools", count: 1 }, + { class: "ACE_CableTie", count: 2 }, + { class: "SmokeShell", cat: "grenades", count: 2 }, + { class: "ACE_splint", cat: "medical", count: 2 }, + { class: "rhs_mag_30Rnd_556x45_Mk318_PMAG", cat: "magazines", count: 4 }, + { class: "HandGrenade", cat: "grenades", count: 1 }, + { class: "ACE_adenosine", cat: "medical", count: 1 }, + { class: "ACE_MapTools", count: 1 }, ], }, }), @@ -681,8 +671,8 @@ describe("PlayerProfileCard", () => { expect(text.indexOf("Magazines")).toBeLessThan(text.indexOf("Grenades")); expect(text.indexOf("Grenades")).toBeLessThan(text.indexOf("Medical")); expect(text.indexOf("Medical")).toBeLessThan(text.indexOf("Items")); - expect(text.indexOf("M67 Fragmentation Grenade")).toBeLessThan(text.indexOf("M83 Smoke Grenade (White)")); - expect(text.indexOf("Adenosine Autoinjector")).toBeLessThan(text.indexOf("Splint")); + expect(text.indexOf("Hand Grenade")).toBeLessThan(text.indexOf("Smoke Shell")); + expect(text.indexOf("Adenosine")).toBeLessThan(text.indexOf("Splint")); expect(text.indexOf("Cable Tie")).toBeLessThan(text.indexOf("Map Tools")); }); @@ -691,10 +681,10 @@ describe("PlayerProfileCard", () => { snapshotEvent(5, "inventorySnapshot", { weapons: [], magazines: [ - { class: "rhs_mag_30Rnd_556x45_Mk318_PMAG", name: "30rnd PMAG", count: 5, totalRounds: 120 }, - { class: "ACE_16Rnd_9x19_mag", name: "9x19 mm 16Rnd Mag", count: 2, totalRounds: 34 }, - { class: "HandGrenade", name: "M67 Fragmentation Grenade", count: 1, totalRounds: 1 }, - { class: "kat_Painkiller", name: "Painkillers", count: 3, totalRounds: 20 }, + { class: "rhs_mag_30Rnd_556x45_Mk318_PMAG", cat: "magazines", count: 5, totalRounds: 120 }, + { class: "ACE_16Rnd_9x19_mag", cat: "magazines", count: 2, totalRounds: 34 }, + { class: "HandGrenade", cat: "grenades", count: 1, totalRounds: 1 }, + { class: "kat_Painkiller", cat: "medical", count: 3, totalRounds: 20 }, ], assignedItems: [], }), @@ -711,7 +701,7 @@ describe("PlayerProfileCard", () => { [ snapshotEvent(5, "inventorySnapshot", { weapons: [], - magazines: [{ class: "30Rnd", name: "30rnd mag", count: 4, totalRounds: 90 }], + magazines: [{ class: "30Rnd", count: 4, totalRounds: 90 }], assignedItems: [], }), ], @@ -738,22 +728,20 @@ describe("PlayerProfileCard", () => { [ snapshotEvent(5, "inventorySnapshot", { weapons: [], - magazines: [{ class: "30Rnd", name: "30rnd mag", count: 5, totalRounds: 120 }], + magazines: [{ class: "30Rnd", cat: "magazines", count: 5, totalRounds: 120 }], assignedItems: [], vest: { class: "v", - name: "Vest", - items: [{ class: "30Rnd", name: "30rnd mag", count: 5 }], + items: [{ class: "30Rnd", cat: "magazines", count: 5 }], }, }), snapshotEvent(20, "inventorySnapshot", { weapons: [], - magazines: [{ class: "30Rnd", name: "30rnd mag", count: 2, totalRounds: 40 }], + magazines: [{ class: "30Rnd", cat: "magazines", count: 2, totalRounds: 40 }], assignedItems: [], vest: { class: "v", - name: "Vest", - items: [{ class: "30Rnd", name: "30rnd mag", count: 2 }], + items: [{ class: "30Rnd", cat: "magazines", count: 2 }], }, }), ], @@ -766,8 +754,8 @@ describe("PlayerProfileCard", () => { engine.seekTo(20); expect(screen.getByText("Net changes")).toBeTruthy(); - expect(screen.getByText("30rnd mag ×5 → ×2 · 120 → 40 rds")).toBeTruthy(); - expect(screen.queryByText("30rnd mag ×5 → ×2")).toBeNull(); + expect(screen.getByText("30Rnd ×5 → ×2 · 120 → 40 rds")).toBeTruthy(); + expect(screen.queryByText("30Rnd ×5 → ×2")).toBeNull(); }); it("reports a vest-to-backpack shuffle as moved instead of gained and lost", () => { @@ -779,20 +767,18 @@ describe("PlayerProfileCard", () => { assignedItems: [], vest: { class: "v", - name: "Vest", - items: [{ class: "ACE_MapTools", name: "Map Tools", count: 1 }], + items: [{ class: "ACE_MapTools", count: 1 }], }, - backpack: { class: "b", name: "Pack", items: [] }, + backpack: { class: "b", items: [] }, }), snapshotEvent(20, "inventorySnapshot", { weapons: [], magazines: [], assignedItems: [], - vest: { class: "v", name: "Vest", items: [] }, + vest: { class: "v", items: [] }, backpack: { class: "b", - name: "Pack", - items: [{ class: "ACE_MapTools", name: "Map Tools", count: 1 }], + items: [{ class: "ACE_MapTools", count: 1 }], }, }), ], diff --git a/ui/src/pages/recording-playback/__tests__/arsenalExport.test.ts b/ui/src/pages/recording-playback/__tests__/arsenalExport.test.ts index 7f6a6283c..73743cd0b 100644 --- a/ui/src/pages/recording-playback/__tests__/arsenalExport.test.ts +++ b/ui/src/pages/recording-playback/__tests__/arsenalExport.test.ts @@ -7,33 +7,30 @@ const inventory: InventorySnapshot = { load: 0.5, uniform: { class: "U_B_CombatUniform_mcam", - name: "Combat Fatigues", items: [ - { class: "FirstAidKit", name: "First Aid Kit", count: 2 }, - { class: "30Rnd_65x39_caseless_mag", name: "6.5 mm magazine", count: 3 }, + { class: "FirstAidKit", count: 2 }, + { class: "30Rnd_65x39_caseless_mag", count: 3 }, ], }, - vest: { class: "V_PlateCarrier1_rgr", name: "Carrier Rig", items: [] }, - backpack: { class: "", name: "", items: [] }, - headgear: { class: "H_HelmetB", name: "Combat Helmet" }, - goggles: { class: "G_Tactical_Clear", name: "Tactical Glasses" }, + vest: { class: "V_PlateCarrier1_rgr", items: [] }, + backpack: { class: "", items: [] }, + headgear: { class: "H_HelmetB" }, + goggles: { class: "G_Tactical_Clear" }, weapons: [{ class: "arifle_MX_F", - name: "MX", slot: "primary", attachments: [ - { class: "muzzle_snds_H", name: "Sound Suppressor" }, - { class: "optic_Hamr", name: "RCO" }, + { class: "muzzle_snds_H" }, + { class: "optic_Hamr" }, ], }], magazines: [{ class: "30Rnd_65x39_caseless_mag", - name: "6.5 mm magazine", count: 4, totalRounds: 107, loadedCount: 1, }], - assignedItems: [{ class: "ItemMap", name: "Map" }], + assignedItems: [{ class: "ItemMap" }], }; describe("Arsenal exports", () => { @@ -74,7 +71,7 @@ describe("Arsenal exports", () => { it("escapes quotes using SQF string rules", () => { const exported = exportVanillaArsenal({ ...inventory, - headgear: { class: 'Hat_"Quoted"', name: "Quoted hat" }, + headgear: { class: 'Hat_"Quoted"' }, }); expect(exported).toContain('this addHeadgear "Hat_""Quoted""";'); }); @@ -83,8 +80,8 @@ describe("Arsenal exports", () => { const exported = exportAceArsenal({ ...inventory, uniform: { ...inventory.uniform, items: [] }, - weapons: [{ class: "hgun_P07_F", name: "P07", slot: "handgun", attachments: [] }], - magazines: [{ class: "16Rnd_9x21_Mag", name: "9 mm magazine", count: 1, totalRounds: 12, loadedCount: 1 }], + weapons: [{ class: "hgun_P07_F", slot: "handgun", attachments: [] }], + magazines: [{ class: "16Rnd_9x21_Mag", count: 1, totalRounds: 12, loadedCount: 1 }], }); expect(exported).toContain('["hgun_P07_F","","","",["16Rnd_9x21_Mag",12],[],""]'); }); @@ -94,9 +91,9 @@ describe("Arsenal exports", () => { ...inventory, uniform: { ...inventory.uniform, - items: [{ class: "HandGrenade", name: "RGO Grenade", count: 1 }], + items: [{ class: "HandGrenade", count: 1 }], }, - magazines: [{ class: "HandGrenade", name: "RGO Grenade", count: 1, totalRounds: 1, loadedCount: 1 }], + magazines: [{ class: "HandGrenade", count: 1, totalRounds: 1, loadedCount: 1 }], }; const vanilla = exportVanillaArsenal(withGrenades); const ace = exportAceArsenal(withGrenades); @@ -108,10 +105,10 @@ describe("Arsenal exports", () => { const exported = exportAceArsenal({ ...inventory, uniform: { ...inventory.uniform, items: [] }, - weapons: [{ class: "arifle_MX_GL_F", name: "MX 3GL", slot: "primary", attachments: [] }], + weapons: [{ class: "arifle_MX_GL_F", slot: "primary", attachments: [] }], magazines: [ - { class: "30Rnd_65x39_caseless_mag", name: "6.5 mm magazine", count: 1, totalRounds: 23, loadedCount: 1 }, - { class: "1Rnd_HE_Grenade_shell", name: "40 mm HE Grenade Shell", count: 1, totalRounds: 1, loadedCount: 1 }, + { class: "30Rnd_65x39_caseless_mag", count: 1, totalRounds: 23, loadedCount: 1 }, + { class: "1Rnd_HE_Grenade_shell", count: 1, totalRounds: 1, loadedCount: 1 }, ], }); expect(exported).toContain( @@ -123,12 +120,12 @@ describe("Arsenal exports", () => { const exported = exportAceArsenal({ ...inventory, assignedItems: [ - { class: "ItemWatch", name: "Watch" }, - { class: "ItemRadio", name: "Radio" }, - { class: "NVGoggles", name: "Night Vision Goggles" }, - { class: "ItemMap", name: "Map" }, - { class: "ItemCompass", name: "Compass" }, - { class: "B_UavTerminal", name: "UAV Terminal" }, + { class: "ItemWatch" }, + { class: "ItemRadio" }, + { class: "NVGoggles" }, + { class: "ItemMap" }, + { class: "ItemCompass" }, + { class: "B_UavTerminal" }, ], }); expect(exported).toContain( @@ -141,16 +138,16 @@ describe("Arsenal exports", () => { ...inventory, weapons: [ ...inventory.weapons, - { class: "Rangefinder", name: "Rangefinder", slot: "binocular", attachments: [] }, + { class: "Rangefinder", slot: "binocular", attachments: [] }, ], assignedItems: [ - { class: "ItemMap", name: "Map" }, - { class: "ItemCompass", name: "Compass" }, - { class: "ItemWatch", name: "Watch" }, - { class: "TFAR_anprc152_1", name: "AN/PRC-152 1" }, - { class: "ItemAndroid", name: "S7 Android" }, - { class: "Simc_PVS7", name: "AN/PVS-7" }, - { class: "Rangefinder", name: "Rangefinder" }, + { class: "ItemMap" }, + { class: "ItemCompass" }, + { class: "ItemWatch" }, + { class: "TFAR_anprc152_1" }, + { class: "ItemAndroid" }, + { class: "Simc_PVS7" }, + { class: "Rangefinder" }, ], }; @@ -181,7 +178,7 @@ describe("Arsenal exports", () => { ...moddedAssignedItems, vest: { ...inventory.vest, - items: [{ class: "TFAR_anprc154_12", name: "AN/PRC-154 12", count: 1 }], + items: [{ class: "TFAR_anprc154_12", count: 1 }], }, }; const vanilla = exportVanillaArsenal(withSpareRadio); @@ -199,9 +196,9 @@ describe("Arsenal exports", () => { const exported = exportAceArsenal({ ...inventory, assignedItems: [ - { class: "ItemMap", name: "Map" }, - { class: "mod_thing_a", name: "Thing A" }, - { class: "mod_thing_b", name: "Thing B" }, + { class: "ItemMap" }, + { class: "mod_thing_a" }, + { class: "mod_thing_b" }, ], }); // assignedItems reports map, compass, watch, radio, gps, NVG in that order. diff --git a/ui/src/pages/recording-playback/__tests__/gearCategories.test.ts b/ui/src/pages/recording-playback/__tests__/gearCategories.test.ts index 01e49e653..42f055e30 100644 --- a/ui/src/pages/recording-playback/__tests__/gearCategories.test.ts +++ b/ui/src/pages/recording-playback/__tests__/gearCategories.test.ts @@ -7,89 +7,63 @@ import { filledGearCategories, isGrenadeItem, isMedicalItem, - magazineClassSet, shouldLabelGearCategory, sortGearItems, + weaponsInSlotOrder, } from "../gearCategories"; -const mag = (item: GearItem): GearItem => item; -const classes = (...names: string[]) => new Set(names); - describe("gearCategories", () => { - it("treats ACE and KAT treatment items as medical even when they are magazines", () => { - expect(isMedicalItem({ class: "ACE_packingBandage", name: "Bandage (Packing)" })).toBe(true); - expect(isMedicalItem({ class: "ACE_salineIV_250", name: "Saline IV (250ml)" })).toBe(true); - expect(isMedicalItem({ class: "kat_Painkiller", name: "Painkillers" })).toBe(true); - expect(isMedicalItem({ class: "kat_chestSeal", name: "Chest Seal" })).toBe(true); - expect(isMedicalItem({ class: "FirstAidKit", name: "First Aid Kit" })).toBe(true); - expect( - classifyGearItem({ class: "kat_Painkiller", name: "Painkillers" }, classes("kat_Painkiller")), - ).toBe("medical"); - }); - - it("does not treat ACE tools as medical", () => { - expect(isMedicalItem({ class: "ACE_MapTools", name: "Map Tools" })).toBe(false); - expect(isMedicalItem({ class: "ACE_CableTie", name: "Cable Tie" })).toBe(false); - expect(isMedicalItem({ class: "ACE_EarPlugs", name: "Earplugs" })).toBe(false); - }); - - it("classifies hand grenades and smokes as grenades", () => { - expect(isGrenadeItem({ class: "HandGrenade", name: "M67 Fragmentation Grenade" })).toBe(true); - expect(isGrenadeItem({ class: "SmokeShell", name: "M83 Smoke Grenade (White)" })).toBe(true); - expect(isGrenadeItem({ class: "SmokeShellRed", name: "M18 Smoke Grenade (Red)" })).toBe(true); - expect( - isGrenadeItem({ - class: "rhs_mag_m67", - name: "M67 Frag", - picture: "\\rhsusf\\addons\\rhsusf_weapons\\icons\\grenade.paa", - }), - ).toBe(true); - expect(isGrenadeItem({ class: "ACE_Chemlight_HiWhite", name: "Chemlight (Hi White)" })).toBe(true); + it("takes the category the recorder resolved from config", () => { + expect(isMedicalItem({ class: "ACE_packingBandage", cat: "medical" })).toBe(true); + expect(isGrenadeItem({ class: "HandGrenade", cat: "grenades" })).toBe(true); + expect(classifyGearItem({ class: "rhs_mag_30Rnd_556x45_Mk318_PMAG", cat: "magazines" })).toBe( + "magazines", + ); }); - it("keeps rifle magazines and UGL shells out of grenades", () => { - expect(isGrenadeItem({ class: "rhs_mag_30Rnd_556x45_Mk318_PMAG", name: "30rnd PMAG Mk318 Mod 0" })).toBe( - false, - ); - expect(isGrenadeItem({ class: "ACE_16Rnd_9x19_mag", name: "9x19 mm 16Rnd Mag" })).toBe(false); - expect(isGrenadeItem({ class: "1Rnd_Smoke_Grenade_shell", name: "Smoke Grenade 40 mm" })).toBe(false); + it("treats an item without a category as a leftover item", () => { + expect(classifyGearItem({ class: "ACE_CableTie" })).toBe("items"); + expect(isMedicalItem({ class: "ACE_MapTools" })).toBe(false); + expect(isGrenadeItem({ class: "ACE_EarPlugs" })).toBe(false); }); it("splits a vest into magazines, grenades, medical and leftover items in that order", () => { const items: GearItem[] = [ - mag({ class: "ACE_CableTie", name: "Cable Tie", count: 2 }), - mag({ class: "HandGrenade", name: "M67 Fragmentation Grenade", count: 1 }), - mag({ class: "ACE_packingBandage", name: "Bandage (Packing)", count: 8 }), - mag({ class: "rhs_mag_30Rnd_556x45_Mk318_PMAG", name: "30rnd PMAG Mk318 Mod 0", count: 4 }), - mag({ class: "SmokeShell", name: "M83 Smoke Grenade (White)", count: 2 }), - mag({ class: "ACE_16Rnd_9x19_mag", name: "9x19 mm 16Rnd Mag", count: 2 }), + { class: "ACE_CableTie", count: 2 }, + { class: "HandGrenade", cat: "grenades", count: 1 }, + { class: "ACE_packingBandage", cat: "medical", count: 8 }, + { class: "rhs_mag_30Rnd_556x45_Mk318_PMAG", cat: "magazines", count: 4 }, + { class: "SmokeShell", cat: "grenades", count: 2 }, + { class: "ACE_16Rnd_9x19_mag", cat: "magazines", count: 2 }, ]; - const groups = categorizeGearItems( - items, - classes( - "HandGrenade", - "SmokeShell", - "rhs_mag_30Rnd_556x45_Mk318_PMAG", - "ACE_16Rnd_9x19_mag", - ), - ); + const groups = categorizeGearItems(items); expect(filledGearCategories(groups)).toEqual(["magazines", "grenades", "medical", "items"]); expect(groups.magazines.map((item) => item.class)).toEqual([ - "rhs_mag_30Rnd_556x45_Mk318_PMAG", "ACE_16Rnd_9x19_mag", + "rhs_mag_30Rnd_556x45_Mk318_PMAG", ]); expect(groups.grenades.map((item) => item.class)).toEqual(["HandGrenade", "SmokeShell"]); expect(groups.medical.map((item) => item.class)).toEqual(["ACE_packingBandage"]); expect(groups.items.map((item) => item.class)).toEqual(["ACE_CableTie"]); }); - it("sorts chips alphabetically by display name", () => { + it("sorts chips alphabetically by the label shown, recorded name included", () => { expect( sortGearItems([ - { class: "c", name: "Splint" }, - { class: "a", name: "Bandage (Packing)" }, - { class: "b", name: "Adenosine Autoinjector" }, + { class: "ACE_splint" }, + { class: "ACE_packingBandage" }, + { class: "ACE_adenosine" }, + ]).map((item) => item.class), + ).toEqual(["ACE_adenosine", "ACE_packingBandage", "ACE_splint"]); + + // "Bandage (Packing)" sorts before "Splint" on the recorded name, not on the + // label the class would otherwise be reduced to. + expect( + sortGearItems([ + { class: "ACE_splint", name: "Splint" }, + { class: "ACE_packingBandage", name: "Bandage (Packing)" }, + { class: "ACE_adenosine", name: "Adenosine Autoinjector" }, ]).map((item) => item.name), ).toEqual(["Adenosine Autoinjector", "Bandage (Packing)", "Splint"]); }); @@ -104,15 +78,26 @@ describe("gearCategories", () => { it("sums totalRounds from ammo magazines and skips grenades and medical", () => { expect( carriedMagazineRounds([ - { class: "rhs_mag_30Rnd_556x45_Mk318_PMAG", name: "30rnd PMAG", totalRounds: 120 }, - { class: "ACE_16Rnd_9x19_mag", name: "9x19 mm 16Rnd Mag", totalRounds: 34 }, - { class: "HandGrenade", name: "M67 Fragmentation Grenade", totalRounds: 1 }, - { class: "kat_Painkiller", name: "Painkillers", totalRounds: 20 }, + { class: "rhs_mag_30Rnd_556x45_Mk318_PMAG", cat: "magazines", totalRounds: 120 }, + { class: "ACE_16Rnd_9x19_mag", cat: "magazines", totalRounds: 34 }, + { class: "HandGrenade", cat: "grenades", totalRounds: 1 }, + { class: "kat_Painkiller", cat: "medical", totalRounds: 20 }, ]), ).toBe(154); expect(carriedMagazineRounds([])).toBeUndefined(); expect( - carriedMagazineRounds([{ class: "SmokeShell", name: "M83 Smoke Grenade (White)", totalRounds: 2 }]), + carriedMagazineRounds([{ class: "SmokeShell", cat: "grenades", totalRounds: 2 }]), ).toBeUndefined(); }); + + it("restores the recorder's weapon order after a keyed diff appended a pickup", () => { + expect( + weaponsInSlotOrder([ + { class: "a", slot: "handgun" }, + { class: "b", slot: "binocular" }, + { class: "c", slot: "primary" }, + { class: "d", slot: "launcher" }, + ]).map((weapon) => weapon.slot), + ).toEqual(["primary", "handgun", "launcher", "binocular"]); + }); }); diff --git a/ui/src/pages/recording-playback/__tests__/profileDiff.test.ts b/ui/src/pages/recording-playback/__tests__/profileDiff.test.ts index 0de3d4c17..ecc49d20b 100644 --- a/ui/src/pages/recording-playback/__tests__/profileDiff.test.ts +++ b/ui/src/pages/recording-playback/__tests__/profileDiff.test.ts @@ -16,11 +16,11 @@ function inventory(overrides: Partial = {}): InventorySnapsho weapons: [], magazines: [], assignedItems: [], - uniform: { class: "u", name: "Uniform", items: [] }, - vest: { class: "v", name: "Vest", items: [] }, - backpack: { class: "b", name: "Pack", items: [] }, - headgear: { class: "", name: "" }, - goggles: { class: "", name: "" }, + uniform: { class: "u", items: [] }, + vest: { class: "v", items: [] }, + backpack: { class: "b", items: [] }, + headgear: { class: "" }, + goggles: { class: "" }, ...overrides, }; } @@ -28,19 +28,17 @@ function inventory(overrides: Partial = {}): InventorySnapsho describe("diffGear", () => { it("summarizes ammo spent as a net magazine change with round counts", () => { const from = inventory({ - magazines: [{ class: "30Rnd", name: "30rnd mag", count: 5, totalRounds: 120 }], + magazines: [{ class: "30Rnd", cat: "magazines", count: 5, totalRounds: 120 }], vest: { class: "v", - name: "Vest", - items: [{ class: "30Rnd", name: "30rnd mag", count: 4 }], + items: [{ class: "30Rnd", count: 4 }], }, }); const to = inventory({ - magazines: [{ class: "30Rnd", name: "30rnd mag", count: 3, totalRounds: 47 }], + magazines: [{ class: "30Rnd", cat: "magazines", count: 3, totalRounds: 47 }], vest: { class: "v", - name: "Vest", - items: [{ class: "30Rnd", name: "30rnd mag", count: 2 }], + items: [{ class: "30Rnd", count: 2 }], }, }); @@ -62,15 +60,13 @@ describe("diffGear", () => { const from = inventory({ vest: { class: "v", - name: "Vest", - items: [{ class: "ACE_MapTools", name: "Map Tools", count: 1 }], + items: [{ class: "ACE_MapTools", count: 1 }], }, }); const to = inventory({ backpack: { class: "b", - name: "Pack", - items: [{ class: "ACE_MapTools", name: "Map Tools", count: 1 }], + items: [{ class: "ACE_MapTools", count: 1 }], }, }); @@ -85,18 +81,18 @@ describe("diffGear", () => { it("reports a weapon swap on the same slot", () => { const diff = diffGear( inventory({ - weapons: [{ class: "arifle_MX", name: "MX", slot: "primary", attachments: [] }], + weapons: [{ class: "arifle_MX", slot: "primary", attachments: [] }], }), inventory({ - weapons: [{ class: "arifle_AK12", name: "AK-12", slot: "primary", attachments: [] }], + weapons: [{ class: "arifle_AK12", slot: "primary", attachments: [] }], }), ); expect(diff.weapons).toEqual([ expect.objectContaining({ slot: "primary", - fromName: "MX", - toName: "AK-12", + fromName: "Arifle MX", + toName: "Arifle AK12", polarity: "changed", }), ]); @@ -104,8 +100,8 @@ describe("diffGear", () => { it("is empty when the reconstructed kit did not change", () => { const kit = inventory({ - magazines: [{ class: "30Rnd", name: "30rnd mag", count: 2, totalRounds: 60 }], - vest: { class: "v", name: "Vest", items: [{ class: "30Rnd", name: "30rnd mag", count: 2 }] }, + magazines: [{ class: "30Rnd", cat: "magazines", count: 2, totalRounds: 60 }], + vest: { class: "v", items: [{ class: "30Rnd", count: 2 }] }, }); expect(gearDiffIsEmpty(diffGear(kit, kit))).toBe(true); }); @@ -176,7 +172,6 @@ describe("diffRadios", () => { radios: [ { class: "TFAR_anprc152_1", - name: "AN/PRC-152", mod: "TFAR", type: "SW", channel: 1, diff --git a/ui/src/pages/recording-playback/arsenalExport.ts b/ui/src/pages/recording-playback/arsenalExport.ts index 2b3b3fb9a..e8e3aad5f 100644 --- a/ui/src/pages/recording-playback/arsenalExport.ts +++ b/ui/src/pages/recording-playback/arsenalExport.ts @@ -1,4 +1,5 @@ import type { GearContainer, GearItem, InventorySnapshot } from "../../data/types"; +import { gearItemName } from "../../data/gearDisplayName"; type Weapon = InventorySnapshot["weapons"][number]; type Magazine = InventorySnapshot["magazines"][number]; @@ -143,7 +144,7 @@ function averageRounds(magazine: Magazine): number { function attachmentSlots(weapon: Weapon): [string, string, string, string] { const slots: [string, string, string, string] = ["", "", "", ""]; for (const attachment of weapon.attachments ?? []) { - const value = `${attachment.class} ${attachment.name}`.toLowerCase(); + const value = `${attachment.class} ${gearItemName(attachment)}`.toLowerCase(); const index = value.includes("muzzle") || value.includes("suppress") || value.includes("silencer") ? 0 : value.includes("optic") || value.includes("scope") || value.includes("sight") @@ -203,7 +204,7 @@ interface WeaponMagazines { } function magazineText(magazine: Magazine): string { - return `${magazine.class} ${magazine.name}`.toLowerCase(); + return `${magazine.class} ${gearItemName(magazine)}`.toLowerCase(); } function looksLikeLauncherMagazine(magazine: Magazine): boolean { @@ -291,11 +292,11 @@ const ASSIGNED_SLOT_INDEX: Record = { const ASSIGNED_REPORT_ORDER: AssignedSlot[] = ["map", "compass", "watch", "radio", "gps", "nvg"]; /** - * Recordings only carry a class name and a display name, so the slot has to be - * recognised from those. Modded gear rarely spells its role out in English — - * a TFAR radio is `TFAR_anprc152_1` "AN/PRC-152 1", a cTab GPS is `ItemAndroid` - * "S7 Android" — so match the families that actually turn up. Order matters: - * TFAR's MicroDAGR is a GPS even though its class carries the radio prefix. + * Recordings only carry a class name, so the slot has to be recognised from that + * and from the label derived from it. Modded gear rarely spells its role out — + * a TFAR radio is `TFAR_anprc152_1`, a cTab GPS is `ItemAndroid` — so match the + * families that actually turn up. Order matters: TFAR's MicroDAGR is a GPS even + * though its class carries the radio prefix. */ const ASSIGNED_SLOT_PATTERNS: Array<[AssignedSlot, RegExp]> = [ ["gps", /gps|dagr|ctab|android|tablet|garmin|uav|terminal|\bbft\b/], @@ -327,7 +328,7 @@ function assignedEntries(inventory: InventorySnapshot): AssignedEntry[] { const entries: AssignedEntry[] = []; for (const item of inventory.assignedItems ?? []) { if (!item.class || weaponClasses.has(item.class)) continue; - const text = `${item.class} ${item.name ?? ""}`.toLowerCase(); + const text = `${item.class} ${gearItemName(item)}`.toLowerCase(); if (BINOCULAR_PATTERN.test(text)) continue; entries.push({ class: arsenalClass(item.class), diff --git a/ui/src/pages/recording-playback/components/PlayerProfileCard.tsx b/ui/src/pages/recording-playback/components/PlayerProfileCard.tsx index f6b69ed0a..6622a4329 100644 --- a/ui/src/pages/recording-playback/components/PlayerProfileCard.tsx +++ b/ui/src/pages/recording-playback/components/PlayerProfileCard.tsx @@ -40,12 +40,13 @@ import { categorizeGearItems, filledGearCategories, GEAR_CATEGORY_LABEL_KEYS, - magazineClassSet, magazinesNotInContainers, carriedMagazineRounds, shouldLabelGearCategory, + weaponsInSlotOrder, type GearCategory, } from "../gearCategories"; +import { gearItemName } from "../../../data/gearDisplayName"; import { diffGear, diffMedical, @@ -178,7 +179,7 @@ function ItemList(props: { items?: GearItem[] }): JSX.Element { {(item) => ( - {item.name || item.class} + {gearItemName(item)} 1}> ×{item.count} )} @@ -243,11 +244,10 @@ function ContainerSection(props: { label?: string; items: GearItem[] }): JSX.Ele function CategorizedItemSections(props: { items?: GearItem[]; - magazineClasses: Set; alwaysLabel?: boolean; }): JSX.Element { const { t } = useI18n(); - const groups = () => categorizeGearItems(props.items, props.magazineClasses); + const groups = () => categorizeGearItems(props.items); const filled = () => filledGearCategories(groups()); return ( @@ -268,21 +268,20 @@ function CategorizedItemSections(props: { function Container(props: { label: string; container?: GearContainer; - magazineClasses: Set; }): JSX.Element { const { t } = useI18n(); - const groups = () => categorizeGearItems(props.container?.items, props.magazineClasses); + const groups = () => categorizeGearItems(props.container?.items); return (
{props.label} - {props.container?.name || t("profile_none")} + {gearItemName(props.container) || t("profile_none")}
{t("profile_empty")}} > - +
); @@ -859,18 +858,18 @@ export function PlayerProfileCard(props: Props): JSX.Element {
- {t("profile_head")}: {gear().headgear?.name || t("profile_none")} + {t("profile_head")}: {gearItemName(gear().headgear) || t("profile_none")} - {t("profile_face")}: {gear().goggles?.name || t("profile_none")} + {t("profile_face")}: {gearItemName(gear().goggles) || t("profile_none")}
{t("profile_weapons")}
- + {(weapon) => (
- {weapon.name} + {gearItemName(weapon)} {weapon.slot}
@@ -881,25 +880,12 @@ export function PlayerProfileCard(props: Props): JSX.Element {
- - - + + +
{t("profile_assigned")}
@@ -1251,7 +1237,7 @@ export function PlayerProfileCard(props: Props): JSX.Element { return (
- {entry.name || entry.class} + {gearItemName(entry)} {entry.mod} {entry.type} {entry.additional ? ` ${t("profile_radio_additional")}` : ""} diff --git a/ui/src/pages/recording-playback/gearCategories.ts b/ui/src/pages/recording-playback/gearCategories.ts index 06fa69db6..90997ba2e 100644 --- a/ui/src/pages/recording-playback/gearCategories.ts +++ b/ui/src/pages/recording-playback/gearCategories.ts @@ -1,4 +1,5 @@ import type { GearItem } from "../../data/types"; +import { gearItemName } from "../../data/gearDisplayName"; export type GearCategory = "magazines" | "grenades" | "medical" | "items"; @@ -18,64 +19,46 @@ export interface CategorizedGear { items: GearItem[]; } -const ACE_MEDICAL_CLASS = - /^ace_(fielddressing|packingbandage|elasticbandage|quikclot|tourniquet|morphine|epinephrine|adenosine|splint|surgicalkit|personalaidkit|bodybag|salineiv|bloodiv|plasmaiv|painkillers|suture)/; - -function classNameOf(item: GearItem): string { - return (item.class || "").toLowerCase(); -} - -function displayNameOf(item: GearItem): string { - return (item.name || "").toLowerCase(); -} - -function pictureOf(item: GearItem): string { - return (item.picture || "").replace(/\\/g, "/").toLowerCase(); -} - -/** 40 mm / UGL grenade *shells* stay with magazines; they are ammo, not throwables. */ -function isLauncherGrenadeAmmo(item: GearItem): boolean { - const cls = classNameOf(item); - return /\d+rnd/.test(cls) && /(shell|ugl|40mm|grenade_shell)/.test(cls); +/** + * The recorder resolves the category from config — ACE's `ACE_isMedicalItem` + * flag, the engine's Throw muzzles, and `CfgMagazines` membership — and writes it + * as `cat`. It used to be guessed here by substring-matching the `.paa` icon path + * the snapshot carried, which cost about 3 MB of unloadable texture paths per + * recording. "items" is the default and is left off the wire. + */ +export function classifyGearItem(item: GearItem): GearCategory { + const category = item.cat; + return category === "magazines" || category === "grenades" || category === "medical" + ? category + : "items"; } export function isMedicalItem(item: GearItem): boolean { - const cls = classNameOf(item); - const picture = pictureOf(item); - if (cls.startsWith("kat_")) return true; - if (cls === "firstaidkit" || cls === "medikit") return true; - if (ACE_MEDICAL_CLASS.test(cls)) return true; - if (picture.includes("medical_treatment") || picture.includes("/kat/addons/")) return true; - if (picture.includes("firstaid") || picture.includes("medikit")) return true; - return false; + return classifyGearItem(item) === "medical"; } export function isGrenadeItem(item: GearItem): boolean { - if (isLauncherGrenadeAmmo(item)) return false; - if (isMedicalItem(item)) return false; - const cls = classNameOf(item); - const name = displayNameOf(item); - const picture = pictureOf(item); - if (/^(smokeshell|handgrenade|minigrenade)/.test(cls)) return true; - if (/(chemlight|handflare|ir_grenade|flashbang|ace_m84|ace_cts9)/.test(cls)) return true; - if (/rhs_mag_(m67|m18|an_m8|mk84|mk3a2|rgd5|rgn|rgo|rdg2|nspd|fakel|zarya)/.test(cls)) return true; - if (picture.includes("smokegrenade") || picture.includes("chemlight")) return true; - if (picture.includes("grenade") && !picture.includes("grenade_shell")) return true; - if (/\bgrenade\b/.test(name) && !/(launcher|40\s*mm)/.test(name)) return true; - if (/\bsmoke grenade\b/.test(name) || /\bchemlight\b/.test(name) || /\bstun grenade\b/.test(name)) return true; - return false; + return classifyGearItem(item) === "grenades"; } -export function classifyGearItem(item: GearItem, magazineClasses: Set): GearCategory { - if (isMedicalItem(item)) return "medical"; - if (isGrenadeItem(item)) return "grenades"; - if (item.class && magazineClasses.has(item.class)) return "magazines"; - return "items"; +/** + * The recorder builds the weapon list in this order. A follow-up snapshot patches + * the list by `slot`, which appends a newly picked-up weapon at the end, so the + * card restores the order rather than showing a launcher above the rifle. + */ +const WEAPON_SLOT_ORDER = ["primary", "handgun", "launcher", "binocular"]; + +export function weaponsInSlotOrder(weapons?: T[]): T[] { + const rank = (slot: string): number => { + const at = WEAPON_SLOT_ORDER.indexOf(slot); + return at === -1 ? WEAPON_SLOT_ORDER.length : at; + }; + return [...(weapons ?? [])].sort((a, b) => rank(a.slot) - rank(b.slot)); } export function sortGearItems(items: GearItem[]): GearItem[] { return [...items].sort((a, b) => { - const byName = (a.name || a.class || "").localeCompare(b.name || b.class || "", undefined, { + const byName = gearItemName(a).localeCompare(gearItemName(b), undefined, { sensitivity: "base", }); if (byName !== 0) return byName; @@ -83,13 +66,10 @@ export function sortGearItems(items: GearItem[]): GearItem[] { }); } -export function categorizeGearItems( - items: GearItem[] | undefined, - magazineClasses: Set, -): CategorizedGear { +export function categorizeGearItems(items: GearItem[] | undefined): CategorizedGear { const groups: CategorizedGear = { magazines: [], grenades: [], medical: [], items: [] }; for (const item of items ?? []) { - groups[classifyGearItem(item, magazineClasses)].push(item); + groups[classifyGearItem(item)].push(item); } for (const key of GEAR_CATEGORY_ORDER) { groups[key] = sortGearItems(groups[key]); @@ -112,10 +92,6 @@ export function shouldLabelGearCategory( return true; } -export function magazineClassSet(magazines?: Array<{ class: string }>): Set { - return new Set((magazines ?? []).map((item) => item.class).filter(Boolean)); -} - export function magazinesNotInContainers(gear: { magazines?: GearItem[]; uniform?: { items?: GearItem[] }; @@ -135,11 +111,10 @@ export function magazinesNotInContainers(gear: { export function carriedMagazineRounds( magazines?: Array, ): number | undefined { - const magClasses = magazineClassSet(magazines); let total = 0; let have = false; for (const mag of magazines ?? []) { - if (classifyGearItem(mag, magClasses) !== "magazines") continue; + if (classifyGearItem(mag) !== "magazines") continue; if (typeof mag.totalRounds !== "number") continue; total += mag.totalRounds; have = true; diff --git a/ui/src/pages/recording-playback/profileDiff.ts b/ui/src/pages/recording-playback/profileDiff.ts index e26499f19..0d7eb10e0 100644 --- a/ui/src/pages/recording-playback/profileDiff.ts +++ b/ui/src/pages/recording-playback/profileDiff.ts @@ -11,7 +11,13 @@ import type { TreatmentItem, } from "../../data/types"; import { BODY_PART_IDS } from "./medical/bodyImage"; -import { classifyGearItem, magazineClassSet, magazinesNotInContainers } from "./gearCategories"; +import { classifyGearItem, magazinesNotInContainers, type GearCategory } from "./gearCategories"; +import { gearItemName } from "../../data/gearDisplayName"; + +/** Classes the snapshot reports as carried magazines, wherever they sit. */ +function magazineClassSet(magazines?: Array<{ class: string }>): Set { + return new Set((magazines ?? []).map((item) => item.class).filter(Boolean)); +} export type DiffPolarity = "added" | "removed" | "changed"; export type GearLocation = "uniform" | "vest" | "backpack" | "assigned" | "loaded"; @@ -126,7 +132,7 @@ function itemCount(item?: GearItem): number { } function itemName(item?: GearItem, fallback = ""): string { - return item?.name || item?.class || fallback; + return gearItemName(item) || fallback; } function addStock(target: StockEntry[], location: GearLocation, items?: GearItem[]): void { @@ -135,7 +141,6 @@ function addStock(target: StockEntry[], location: GearLocation, items?: GearItem const existing = target.find((entry) => entry.location === location && entry.class === item.class); if (existing) { existing.count += itemCount(item); - if (!existing.name && item.name) existing.name = item.name; } else { target.push({ class: item.class, name: itemName(item), count: itemCount(item), location }); } @@ -154,20 +159,27 @@ function stockFromInventory(inv?: InventorySnapshot): StockEntry[] { return stock; } +interface MagazineStat { + name: string; + count: number; + totalRounds?: number; + category: GearCategory; +} + +/** Rounds only make sense for firearm magazines, not for grenades or medical mags. */ function ammoRounds( cls: string, - name: string, - stats: Map, + stats: Map, magClasses: Set, ): number | undefined { if (!magClasses.has(cls)) return undefined; - if (classifyGearItem({ class: cls, name }, magClasses) !== "magazines") return undefined; - return stats.get(cls)?.totalRounds; + const stat = stats.get(cls); + if (stat?.category !== "magazines") return undefined; + return stat.totalRounds; } -function magazineStats( - inv?: InventorySnapshot, -): Map { - const stats = new Map(); + +function magazineStats(inv?: InventorySnapshot): Map { + const stats = new Map(); for (const mag of inv?.magazines ?? []) { if (!mag.class) continue; const prev = stats.get(mag.class); @@ -179,6 +191,7 @@ function magazineStats( name: itemName(mag), count: (prev?.count ?? 0) + itemCount(mag), totalRounds, + category: classifyGearItem(mag), }); } return stats; @@ -326,8 +339,8 @@ export function diffGear(from?: InventorySnapshot, to?: InventorySnapshot): Gear name, fromCount, toCount, - ammoRounds(cls, name, fromMags, magClasses), - ammoRounds(cls, name, toMags, magClasses), + ammoRounds(cls, fromMags, magClasses), + ammoRounds(cls, toMags, magClasses), ); const fromLocs = locationsOf(fromStock, cls); @@ -629,7 +642,7 @@ export function diffRadios(from?: RadioSnapshot, to?: RadioSnapshot): RadioDiff if (!fromEntry) { radios.push({ polarity: "added", - name: toEntry.name || toEntry.class, + name: gearItemName(toEntry), fields: [ field("profile_diff_channel", "text", undefined, toEntry.channel), field("profile_diff_frequency", "number", undefined, toEntry.frequency, { digits: 3, suffix: " MHz" }), @@ -645,7 +658,7 @@ export function diffRadios(from?: RadioSnapshot, to?: RadioSnapshot): RadioDiff field("profile_radio_additional", "bool", fromEntry.additional, toEntry.additional), ].filter((entry): entry is FieldDelta => !!entry); if (fields.length) { - radios.push({ polarity: "changed", name: toEntry.name || toEntry.class, fields }); + radios.push({ polarity: "changed", name: gearItemName(toEntry), fields }); } } @@ -653,7 +666,7 @@ export function diffRadios(from?: RadioSnapshot, to?: RadioSnapshot): RadioDiff if (toMap.has(key)) continue; radios.push({ polarity: "removed", - name: fromEntry.name || fromEntry.class, + name: gearItemName(fromEntry), fields: [ field("profile_diff_frequency", "number", fromEntry.frequency, undefined, { digits: 3, suffix: " MHz" }), ].filter((entry): entry is FieldDelta => !!entry), diff --git a/ui/src/playback/__tests__/eventManager.test.ts b/ui/src/playback/__tests__/eventManager.test.ts index 68adaadcc..80a10347f 100644 --- a/ui/src/playback/__tests__/eventManager.test.ts +++ b/ui/src/playback/__tests__/eventManager.test.ts @@ -371,20 +371,20 @@ describe("EventManager", () => { unitId: 7, playerUid: "uid", reason: "periodic", - weightKg: 12, + massUnits: 120, load: 0.2, - uniform: { class: "", name: "", items: [] }, - vest: { class: "", name: "", items: [] }, - backpack: { class: "", name: "", items: [] }, - headgear: { class: "", name: "" }, - goggles: { class: "", name: "" }, + uniform: { class: "", items: [] }, + vest: { class: "", items: [] }, + backpack: { class: "", items: [] }, + headgear: { class: "" }, + goggles: { class: "" }, weapons: [], magazines: [], assignedItems: [], }); const second = new PlayerSnapshotEvent(20, "inventorySnapshot", 2, { ...first.payload, - weightKg: 18, + massUnits: 180, }); mgr.addEvent(first); @@ -467,7 +467,7 @@ describe("EventManager", () => { const orphan = new PlayerSnapshotEvent(20, "radioSnapshot", 1, { unitId: 7, diffOf: 10, - set: { radios: [{ class: "TFAR_anprc152_1", name: "AN/PRC-152" }] }, + set: { radios: [{ class: "TFAR_anprc152_1" }] }, }); mgr.addEvent(orphan); @@ -475,7 +475,7 @@ describe("EventManager", () => { expect(orphan.payload).toEqual({ unitId: 7, - radios: [{ class: "TFAR_anprc152_1", name: "AN/PRC-152" }], + radios: [{ class: "TFAR_anprc152_1" }], }); }); From db05dcb647897dec360138ef186b47e3d5389ede Mon Sep 17 00:00:00 2001 From: Yethe Samartaka Date: Thu, 3 Sep 2026 21:42:42 +0200 Subject: [PATCH 07/39] new data protobuff support --- README.md | 17 +- internal/storage/player_snapshots.go | 126 +++++++++++ internal/storage/player_snapshots_test.go | 149 +++++++++++++ internal/storage/streaming_converter.go | 13 +- .../streaming_converter_integration_test.go | 27 +++ internal/storage/writer_protobuf_v1.go | 12 +- pkg/schemas/protobuf/v1/ocap.pb.go | 197 ++++++++++++------ pkg/schemas/protobuf/v1/ocap.proto | 15 ++ ui/src/data/__tests__/chunkManager.test.ts | 55 +++++ ui/src/data/apiClient.ts | 12 ++ ui/src/data/chunkManager.ts | 34 ++- .../__tests__/protobufDecoder.test.ts | 80 +++++++ ui/src/data/decoders/decoder.interface.ts | 7 +- ui/src/data/decoders/generated/ocap.pb.ts | 134 +++++++++++- ui/src/data/decoders/jsonDecoder.ts | 8 + ui/src/data/decoders/protobufDecoder.ts | 13 ++ ui/src/data/loaders/__tests__/loader.test.ts | 5 +- .../loaders/__tests__/loaderRegistry.test.ts | 5 +- ui/src/data/types.ts | 6 + ui/src/i18n/locales.ts | 1 + .../components/PlayerProfileCard.module.css | 9 + .../components/PlayerProfileCard.tsx | 19 +- ui/src/playback/__tests__/engine.test.ts | 72 +++++++ .../playback/__tests__/eventManager.test.ts | 42 ++++ ui/src/playback/engine.ts | 63 ++++++ ui/src/playback/eventManager.ts | 44 ++-- 26 files changed, 1078 insertions(+), 87 deletions(-) create mode 100644 internal/storage/player_snapshots.go create mode 100644 internal/storage/player_snapshots_test.go diff --git a/README.md b/README.md index 41133d955..460a0f56f 100644 --- a/README.md +++ b/README.md @@ -316,8 +316,19 @@ data/ ├── mission_name.gz # Original JSON (preserved) └── mission_name/ # Chunked binary format ├── manifest.pb # Metadata, entities, events - └── chunks/ - ├── 0000.pb # Frames 0-299 - ├── 0001.pb # Frames 300-599 + ├── chunks/ + │ ├── 0000.pb # Frames 0-299 + │ ├── 0001.pb # Frames 300-599 + │ └── ... + └── snapshots/ # Per-player detail, fetched on demand + ├── 7.pb # Every snapshot recorded for unit 7 └── ... ``` + +### Player detail snapshots + +Per-player detail (inventory, medical, stamina, radio) is kept in `snapshots/.pb` instead +of the manifest, and fetched only when that player's profile is opened. + +JSON recordings are unaffected. Chunked recordings converted before this keep their snapshots in +the manifest and still play; re-run `convert --input` to move them out. diff --git a/internal/storage/player_snapshots.go b/internal/storage/player_snapshots.go new file mode 100644 index 000000000..a97b7d459 --- /dev/null +++ b/internal/storage/player_snapshots.go @@ -0,0 +1,126 @@ +package storage + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + + "google.golang.org/protobuf/proto" + + pbv1 "github.com/OCAP2/web/pkg/schemas/protobuf/v1" +) + +// SnapshotsDirName holds the per-player snapshot sidecars written next to the +// manifest and the chunks directory. +const SnapshotsDirName = "snapshots" + +// playerSnapshotEventTypes are the additive event types recorded per player. +// They are the bulk of a recording's event stream but the profile card reads +// one unit at a time, so the protobuf writer moves them out of the manifest +// into snapshots/.pb and the UI fetches the one series it needs. +// +// serverFps, tfarSettings and acreSettings are deliberately absent: they are +// global, tiny, and read by the Stats panel and the range circles without a +// unit being selected, so they stay in the manifest. +var playerSnapshotEventTypes = map[string]struct{}{ + "inventorySnapshot": {}, + "medicalSnapshot": {}, + "staminaSnapshot": {}, + "radioSnapshot": {}, +} + +// IsPlayerSnapshotEvent reports whether an event type is a per-player snapshot. +func IsPlayerSnapshotEvent(eventType string) bool { + _, ok := playerSnapshotEventTypes[eventType] + return ok +} + +// snapshotUnitID reads the unitId the server stamped onto a snapshot payload. +// Both full snapshots and diffs carry it at the top level. A payload without a +// usable unitId cannot be filed under a player, so it is left in the manifest +// where the existing decoders still see it. +func snapshotUnitID(message string) (uint32, bool) { + if message == "" { + return 0, false + } + var payload struct { + UnitID *float64 `json:"unitId"` + } + if err := json.Unmarshal([]byte(message), &payload); err != nil { + return 0, false + } + if payload.UnitID == nil || *payload.UnitID < 0 { + return 0, false + } + return uint32(*payload.UnitID), true +} + +// SplitPlayerSnapshots partitions events into those that stay in the manifest +// and those that move to a per-unit sidecar. Recorded order is preserved within +// each series, which is what the diff chain rebuild depends on. +func SplitPlayerSnapshots(events []*pbv1.Event) (manifest []*pbv1.Event, byUnit map[uint32][]*pbv1.Event) { + byUnit = make(map[uint32][]*pbv1.Event) + for _, e := range events { + if e == nil { + continue + } + if !IsPlayerSnapshotEvent(e.Type) { + manifest = append(manifest, e) + continue + } + unitID, ok := snapshotUnitID(e.Message) + if !ok { + manifest = append(manifest, e) + continue + } + byUnit[unitID] = append(byUnit[unitID], e) + } + return manifest, byUnit +} + +// SortedUnitIDs returns the unit ids of a snapshot map in ascending order, for +// the manifest's snapshot_unit_ids index. +func SortedUnitIDs(byUnit map[uint32][]*pbv1.Event) []uint32 { + if len(byUnit) == 0 { + return nil + } + ids := make([]uint32, 0, len(byUnit)) + for id := range byUnit { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + return ids +} + +// WritePlayerSnapshots writes one PlayerSnapshotSeries file per unit under +// outputPath/snapshots. Nothing is written when there are no snapshots, so a +// recording without the feature gains no empty directory. +func WritePlayerSnapshots(outputPath string, byUnit map[uint32][]*pbv1.Event) error { + if len(byUnit) == 0 { + return nil + } + + dir := filepath.Join(outputPath, SnapshotsDirName) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("create snapshots directory: %w", err) + } + + for _, unitID := range SortedUnitIDs(byUnit) { + series := &pbv1.PlayerSnapshotSeries{ + UnitId: unitID, + Events: byUnit[unitID], + } + data, err := proto.Marshal(series) + if err != nil { + return fmt.Errorf("marshal snapshots for unit %d: %w", unitID, err) + } + path := filepath.Join(dir, fmt.Sprintf("%d.pb", unitID)) + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("write snapshots for unit %d: %w", unitID, err) + } + } + + return nil +} diff --git a/internal/storage/player_snapshots_test.go b/internal/storage/player_snapshots_test.go new file mode 100644 index 000000000..16abdfaf1 --- /dev/null +++ b/internal/storage/player_snapshots_test.go @@ -0,0 +1,149 @@ +package storage + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + pbv1 "github.com/OCAP2/web/pkg/schemas/protobuf/v1" +) + +func snapshotEvent(frame uint32, eventType, message string) *pbv1.Event { + return &pbv1.Event{FrameNum: frame, Type: eventType, Message: message} +} + +func TestSplitPlayerSnapshots(t *testing.T) { + events := []*pbv1.Event{ + snapshotEvent(1, "serverFps", `{"fps":48}`), + snapshotEvent(5, "inventorySnapshot", `{"unitId":7,"massUnits":695}`), + snapshotEvent(6, "killed", ""), + snapshotEvent(9, "medicalSnapshot", `{"unitId":12,"ace":{"heartRate":80}}`), + snapshotEvent(20, "inventorySnapshot", `{"unitId":7,"diffOf":5,"set":{"massUnits":700}}`), + snapshotEvent(30, "tfarSettings", `{"terrainInterceptionCoefficient":7}`), + } + + manifest, byUnit := SplitPlayerSnapshots(events) + + // Global events stay where every reader already looks for them. + require.Len(t, manifest, 3) + require.Equal(t, "serverFps", manifest[0].Type) + require.Equal(t, "killed", manifest[1].Type) + require.Equal(t, "tfarSettings", manifest[2].Type) + + require.Equal(t, []uint32{7, 12}, SortedUnitIDs(byUnit)) + require.Len(t, byUnit[7], 2) + require.Len(t, byUnit[12], 1) + + // Recorded order is preserved so the diff chain still rebuilds. + require.Equal(t, uint32(5), byUnit[7][0].FrameNum) + require.Equal(t, uint32(20), byUnit[7][1].FrameNum) +} + +// A snapshot the server never stamped with a unitId cannot be filed under a +// player, so it stays in the manifest where the decoders still reach it. +func TestSplitPlayerSnapshotsKeepsUnattributedInManifest(t *testing.T) { + events := []*pbv1.Event{ + snapshotEvent(5, "inventorySnapshot", `{"massUnits":695}`), + snapshotEvent(6, "staminaSnapshot", `not json`), + snapshotEvent(7, "radioSnapshot", ""), + snapshotEvent(8, "medicalSnapshot", `{"unitId":3}`), + } + + manifest, byUnit := SplitPlayerSnapshots(events) + + require.Len(t, manifest, 3) + require.Equal(t, []uint32{3}, SortedUnitIDs(byUnit)) +} + +func TestWritePlayerSnapshotsRoundTrip(t *testing.T) { + dir := t.TempDir() + byUnit := map[uint32][]*pbv1.Event{ + 7: { + snapshotEvent(5, "inventorySnapshot", `{"unitId":7,"massUnits":695}`), + snapshotEvent(20, "inventorySnapshot", `{"unitId":7,"diffOf":5,"set":{"massUnits":700}}`), + }, + 12: {snapshotEvent(9, "medicalSnapshot", `{"unitId":12}`)}, + } + + require.NoError(t, WritePlayerSnapshots(dir, byUnit)) + + data, err := os.ReadFile(filepath.Join(dir, SnapshotsDirName, "7.pb")) + require.NoError(t, err) + + var series pbv1.PlayerSnapshotSeries + require.NoError(t, proto.Unmarshal(data, &series)) + require.Equal(t, uint32(7), series.UnitId) + require.Len(t, series.Events, 2) + require.Equal(t, `{"unitId":7,"massUnits":695}`, series.Events[0].Message) + require.Equal(t, uint32(20), series.Events[1].FrameNum) + + require.FileExists(t, filepath.Join(dir, SnapshotsDirName, "12.pb")) +} + +// A recording without the feature must not grow an empty snapshots directory. +func TestWritePlayerSnapshotsNoSnapshots(t *testing.T) { + dir := t.TempDir() + require.NoError(t, WritePlayerSnapshots(dir, nil)) + require.NoDirExists(t, filepath.Join(dir, SnapshotsDirName)) +} + +// End-to-end through the streaming converter: snapshots leave the manifest, +// land in per-unit sidecars, and the manifest indexes exactly those units. +func TestConverterWritesPlayerSnapshotSidecars(t *testing.T) { + dir := t.TempDir() + jsonPath := filepath.Join(dir, "mission.json") + outputPath := filepath.Join(dir, "mission") + + recording := `{ + "worldName": "altis", + "missionName": "snapshot test", + "endFrame": 20, + "captureDelay": 1, + "entities": [ + {"id": 7, "type": "unit", "name": "Alpha", "side": "WEST", "group": "A", + "isPlayer": 1, "startFrameNum": 0, "positions": [[[100,100,0],0,1,[],"Alpha",1]]} + ], + "events": [ + [1, "serverFps", {"fps": 48}], + [5, "inventorySnapshot", {"unitId": 7, "massUnits": 695}], + [6, "killed", [7, 7, "rifle", 10]], + [9, "medicalSnapshot", {"unitId": 12, "ace": {"heartRate": 80}}], + [20, "inventorySnapshot", {"unitId": 7, "diffOf": 5, "set": {"massUnits": 700}}] + ], + "Markers": [], + "times": [] + }` + require.NoError(t, os.WriteFile(jsonPath, []byte(recording), 0644)) + + require.NoError(t, NewConverter(10).Convert(context.Background(), jsonPath, outputPath)) + + data, err := os.ReadFile(filepath.Join(outputPath, "manifest.pb")) + require.NoError(t, err) + var manifest pbv1.Manifest + require.NoError(t, proto.Unmarshal(data, &manifest)) + + require.Equal(t, []uint32{7, 12}, manifest.SnapshotUnitIds) + for _, e := range manifest.Events { + require.False(t, IsPlayerSnapshotEvent(e.Type), "player snapshot %s left in manifest", e.Type) + } + // The global samples are untouched by the split. + var sawFps bool + for _, e := range manifest.Events { + if e.Type == "serverFps" { + sawFps = true + } + } + require.True(t, sawFps, "serverFps must stay in the manifest") + + data, err = os.ReadFile(filepath.Join(outputPath, SnapshotsDirName, "7.pb")) + require.NoError(t, err) + var series pbv1.PlayerSnapshotSeries + require.NoError(t, proto.Unmarshal(data, &series)) + require.Equal(t, uint32(7), series.UnitId) + require.Len(t, series.Events, 2) + require.Contains(t, series.Events[1].Message, `"diffOf":5`) +} diff --git a/internal/storage/streaming_converter.go b/internal/storage/streaming_converter.go index bd1728c0e..d0a815c78 100644 --- a/internal/storage/streaming_converter.go +++ b/internal/storage/streaming_converter.go @@ -248,7 +248,15 @@ func (sc *Converter) Convert(ctx context.Context, jsonPath, outputPath string) e } } - // Phase 3: Write manifest + // Phase 3: Write the per-player snapshot sidecars, then the manifest. + // The snapshots are the bulk of the event stream and are read one unit at a + // time, so keeping them in the manifest would mean downloading all of them + // before the first frame renders. + manifestEvents, snapshotsByUnit := SplitPlayerSnapshots(events) + if err := WritePlayerSnapshots(outputPath, snapshotsByUnit); err != nil { + return err + } + manifest := &pbv1.Manifest{ Version: uint32(SchemaVersionV1), WorldName: meta.WorldName, @@ -260,9 +268,10 @@ func (sc *Converter) Convert(ctx context.Context, jsonPath, outputPath string) e ExtensionVersion: meta.ExtensionVersion, AddonVersion: meta.AddonVersion, Entities: entities, - Events: events, + Events: manifestEvents, Markers: markers, Times: times, + SnapshotUnitIds: SortedUnitIDs(snapshotsByUnit), } data, err := proto.Marshal(manifest) diff --git a/internal/storage/streaming_converter_integration_test.go b/internal/storage/streaming_converter_integration_test.go index ce23afa05..724d53ab8 100644 --- a/internal/storage/streaming_converter_integration_test.go +++ b/internal/storage/streaming_converter_integration_test.go @@ -64,4 +64,31 @@ func TestStreamingConverter_RealFile(t *testing.T) { len(chunk.Frames), len(chunk.Frames[0].Entities)) } } + + // Player snapshots must have left the manifest for their own sidecars — + // that is what keeps the up-front download small on a real recording. + for _, e := range manifest.Events { + require.False(t, IsPlayerSnapshotEvent(e.Type), + "player snapshot %s left in manifest", e.Type) + } + + var sidecarBytes int64 + for _, unitID := range manifest.SnapshotUnitIds { + path := filepath.Join(outputPath, SnapshotsDirName, fmt.Sprintf("%d.pb", unitID)) + info, err := os.Stat(path) + require.NoError(t, err) + sidecarBytes += info.Size() + + data, err := os.ReadFile(path) + require.NoError(t, err) + var series pbv1.PlayerSnapshotSeries + require.NoError(t, proto.Unmarshal(data, &series)) + require.Equal(t, unitID, series.UnitId) + require.NotEmpty(t, series.Events) + } + + if len(manifest.SnapshotUnitIds) > 0 { + t.Logf("Manifest: %d KB up front; %d snapshot sidecars totalling %d KB, largest fetched on demand", + len(manifestData)/1024, len(manifest.SnapshotUnitIds), sidecarBytes/1024) + } } diff --git a/internal/storage/writer_protobuf_v1.go b/internal/storage/writer_protobuf_v1.go index ef241ca0b..e102e2fb1 100644 --- a/internal/storage/writer_protobuf_v1.go +++ b/internal/storage/writer_protobuf_v1.go @@ -24,11 +24,21 @@ func (w *ProtobufWriterV1) Version() SchemaVersion { return SchemaVersionV1 } // Format returns the format name func (w *ProtobufWriterV1) Format() string { return "protobuf" } -// WriteManifest writes the manifest to a protobuf file +// WriteManifest writes the manifest to a protobuf file, and the per-player +// snapshot sidecars alongside it. The two are written together because the +// manifest's snapshot_unit_ids index has to name exactly the files on disk. func (w *ProtobufWriterV1) WriteManifest(ctx context.Context, outputPath string, result *ParseResult) error { // Convert ParseResult to pbv1.Manifest manifest := w.toProtoManifest(result) + // Move per-player snapshots out of the manifest into snapshots/.pb + manifestEvents, byUnit := SplitPlayerSnapshots(manifest.Events) + manifest.Events = manifestEvents + manifest.SnapshotUnitIds = SortedUnitIDs(byUnit) + if err := WritePlayerSnapshots(outputPath, byUnit); err != nil { + return err + } + // Marshal protobuf data data, err := proto.Marshal(manifest) if err != nil { diff --git a/pkg/schemas/protobuf/v1/ocap.pb.go b/pkg/schemas/protobuf/v1/ocap.pb.go index bf36f2277..a061d37ba 100644 --- a/pkg/schemas/protobuf/v1/ocap.pb.go +++ b/pkg/schemas/protobuf/v1/ocap.pb.go @@ -146,8 +146,12 @@ type Manifest struct { Markers []*MarkerDef `protobuf:"bytes,11,rep,name=markers,proto3" json:"markers,omitempty"` ExtensionVersion string `protobuf:"bytes,12,opt,name=extension_version,json=extensionVersion,proto3" json:"extension_version,omitempty"` AddonVersion string `protobuf:"bytes,13,opt,name=addon_version,json=addonVersion,proto3" json:"addon_version,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Units that have per-player detail snapshots in snapshots/.pb. + // Empty for recordings converted before those sidecars existed — their + // snapshots are still in `events` and are read from there. + SnapshotUnitIds []uint32 `protobuf:"varint,14,rep,packed,name=snapshot_unit_ids,json=snapshotUnitIds,proto3" json:"snapshot_unit_ids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Manifest) Reset() { @@ -271,6 +275,71 @@ func (x *Manifest) GetAddonVersion() string { return "" } +func (x *Manifest) GetSnapshotUnitIds() []uint32 { + if x != nil { + return x.SnapshotUnitIds + } + return nil +} + +// PlayerSnapshotSeries - every inventory/medical/stamina/radio snapshot for one +// player, held in snapshots/.pb and fetched only when the profile card +// for that player is opened. Keeping them out of the manifest is what makes +// them affordable: they are the bulk of the event stream but are needed for one +// unit at a time. The events are the same Event message the manifest carries, +// in recorded frame order, so a diff chain rebuilds exactly as it always did. +type PlayerSnapshotSeries struct { + state protoimpl.MessageState `protogen:"open.v1"` + UnitId uint32 `protobuf:"varint,1,opt,name=unit_id,json=unitId,proto3" json:"unit_id,omitempty"` + Events []*Event `protobuf:"bytes,2,rep,name=events,proto3" json:"events,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PlayerSnapshotSeries) Reset() { + *x = PlayerSnapshotSeries{} + mi := &file_ocap_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PlayerSnapshotSeries) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PlayerSnapshotSeries) ProtoMessage() {} + +func (x *PlayerSnapshotSeries) ProtoReflect() protoreflect.Message { + mi := &file_ocap_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PlayerSnapshotSeries.ProtoReflect.Descriptor instead. +func (*PlayerSnapshotSeries) Descriptor() ([]byte, []int) { + return file_ocap_proto_rawDescGZIP(), []int{1} +} + +func (x *PlayerSnapshotSeries) GetUnitId() uint32 { + if x != nil { + return x.UnitId + } + return 0 +} + +func (x *PlayerSnapshotSeries) GetEvents() []*Event { + if x != nil { + return x.Events + } + return nil +} + type EntityDef struct { state protoimpl.MessageState `protogen:"open.v1"` Id uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` @@ -290,7 +359,7 @@ type EntityDef struct { func (x *EntityDef) Reset() { *x = EntityDef{} - mi := &file_ocap_proto_msgTypes[1] + mi := &file_ocap_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -302,7 +371,7 @@ func (x *EntityDef) String() string { func (*EntityDef) ProtoMessage() {} func (x *EntityDef) ProtoReflect() protoreflect.Message { - mi := &file_ocap_proto_msgTypes[1] + mi := &file_ocap_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -315,7 +384,7 @@ func (x *EntityDef) ProtoReflect() protoreflect.Message { // Deprecated: Use EntityDef.ProtoReflect.Descriptor instead. func (*EntityDef) Descriptor() ([]byte, []int) { - return file_ocap_proto_rawDescGZIP(), []int{1} + return file_ocap_proto_rawDescGZIP(), []int{2} } func (x *EntityDef) GetId() uint32 { @@ -408,7 +477,7 @@ type FiredFrame struct { func (x *FiredFrame) Reset() { *x = FiredFrame{} - mi := &file_ocap_proto_msgTypes[2] + mi := &file_ocap_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -420,7 +489,7 @@ func (x *FiredFrame) String() string { func (*FiredFrame) ProtoMessage() {} func (x *FiredFrame) ProtoReflect() protoreflect.Message { - mi := &file_ocap_proto_msgTypes[2] + mi := &file_ocap_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -433,7 +502,7 @@ func (x *FiredFrame) ProtoReflect() protoreflect.Message { // Deprecated: Use FiredFrame.ProtoReflect.Descriptor instead. func (*FiredFrame) Descriptor() ([]byte, []int) { - return file_ocap_proto_rawDescGZIP(), []int{2} + return file_ocap_proto_rawDescGZIP(), []int{3} } func (x *FiredFrame) GetFrameNum() uint32 { @@ -477,7 +546,7 @@ type TimeSample struct { func (x *TimeSample) Reset() { *x = TimeSample{} - mi := &file_ocap_proto_msgTypes[3] + mi := &file_ocap_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -489,7 +558,7 @@ func (x *TimeSample) String() string { func (*TimeSample) ProtoMessage() {} func (x *TimeSample) ProtoReflect() protoreflect.Message { - mi := &file_ocap_proto_msgTypes[3] + mi := &file_ocap_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -502,7 +571,7 @@ func (x *TimeSample) ProtoReflect() protoreflect.Message { // Deprecated: Use TimeSample.ProtoReflect.Descriptor instead. func (*TimeSample) Descriptor() ([]byte, []int) { - return file_ocap_proto_rawDescGZIP(), []int{3} + return file_ocap_proto_rawDescGZIP(), []int{4} } func (x *TimeSample) GetFrameNum() uint32 { @@ -553,7 +622,7 @@ type Chunk struct { func (x *Chunk) Reset() { *x = Chunk{} - mi := &file_ocap_proto_msgTypes[4] + mi := &file_ocap_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -565,7 +634,7 @@ func (x *Chunk) String() string { func (*Chunk) ProtoMessage() {} func (x *Chunk) ProtoReflect() protoreflect.Message { - mi := &file_ocap_proto_msgTypes[4] + mi := &file_ocap_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -578,7 +647,7 @@ func (x *Chunk) ProtoReflect() protoreflect.Message { // Deprecated: Use Chunk.ProtoReflect.Descriptor instead. func (*Chunk) Descriptor() ([]byte, []int) { - return file_ocap_proto_rawDescGZIP(), []int{4} + return file_ocap_proto_rawDescGZIP(), []int{5} } func (x *Chunk) GetIndex() uint32 { @@ -619,7 +688,7 @@ type Frame struct { func (x *Frame) Reset() { *x = Frame{} - mi := &file_ocap_proto_msgTypes[5] + mi := &file_ocap_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -631,7 +700,7 @@ func (x *Frame) String() string { func (*Frame) ProtoMessage() {} func (x *Frame) ProtoReflect() protoreflect.Message { - mi := &file_ocap_proto_msgTypes[5] + mi := &file_ocap_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -644,7 +713,7 @@ func (x *Frame) ProtoReflect() protoreflect.Message { // Deprecated: Use Frame.ProtoReflect.Descriptor instead. func (*Frame) Descriptor() ([]byte, []int) { - return file_ocap_proto_rawDescGZIP(), []int{5} + return file_ocap_proto_rawDescGZIP(), []int{6} } func (x *Frame) GetFrameNum() uint32 { @@ -683,7 +752,7 @@ type EntityState struct { func (x *EntityState) Reset() { *x = EntityState{} - mi := &file_ocap_proto_msgTypes[6] + mi := &file_ocap_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -695,7 +764,7 @@ func (x *EntityState) String() string { func (*EntityState) ProtoMessage() {} func (x *EntityState) ProtoReflect() protoreflect.Message { - mi := &file_ocap_proto_msgTypes[6] + mi := &file_ocap_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -708,7 +777,7 @@ func (x *EntityState) ProtoReflect() protoreflect.Message { // Deprecated: Use EntityState.ProtoReflect.Descriptor instead. func (*EntityState) Descriptor() ([]byte, []int) { - return file_ocap_proto_rawDescGZIP(), []int{6} + return file_ocap_proto_rawDescGZIP(), []int{7} } func (x *EntityState) GetEntityId() uint32 { @@ -829,7 +898,7 @@ type Event struct { func (x *Event) Reset() { *x = Event{} - mi := &file_ocap_proto_msgTypes[7] + mi := &file_ocap_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -841,7 +910,7 @@ func (x *Event) String() string { func (*Event) ProtoMessage() {} func (x *Event) ProtoReflect() protoreflect.Message { - mi := &file_ocap_proto_msgTypes[7] + mi := &file_ocap_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -854,7 +923,7 @@ func (x *Event) ProtoReflect() protoreflect.Message { // Deprecated: Use Event.ProtoReflect.Descriptor instead. func (*Event) Descriptor() ([]byte, []int) { - return file_ocap_proto_rawDescGZIP(), []int{7} + return file_ocap_proto_rawDescGZIP(), []int{8} } func (x *Event) GetFrameNum() uint32 { @@ -960,7 +1029,7 @@ type MarkerDef struct { func (x *MarkerDef) Reset() { *x = MarkerDef{} - mi := &file_ocap_proto_msgTypes[8] + mi := &file_ocap_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -972,7 +1041,7 @@ func (x *MarkerDef) String() string { func (*MarkerDef) ProtoMessage() {} func (x *MarkerDef) ProtoReflect() protoreflect.Message { - mi := &file_ocap_proto_msgTypes[8] + mi := &file_ocap_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -985,7 +1054,7 @@ func (x *MarkerDef) ProtoReflect() protoreflect.Message { // Deprecated: Use MarkerDef.ProtoReflect.Descriptor instead. func (*MarkerDef) Descriptor() ([]byte, []int) { - return file_ocap_proto_rawDescGZIP(), []int{8} + return file_ocap_proto_rawDescGZIP(), []int{9} } func (x *MarkerDef) GetType() string { @@ -1086,7 +1155,7 @@ type MarkerPosition struct { func (x *MarkerPosition) Reset() { *x = MarkerPosition{} - mi := &file_ocap_proto_msgTypes[9] + mi := &file_ocap_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1098,7 +1167,7 @@ func (x *MarkerPosition) String() string { func (*MarkerPosition) ProtoMessage() {} func (x *MarkerPosition) ProtoReflect() protoreflect.Message { - mi := &file_ocap_proto_msgTypes[9] + mi := &file_ocap_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1111,7 +1180,7 @@ func (x *MarkerPosition) ProtoReflect() protoreflect.Message { // Deprecated: Use MarkerPosition.ProtoReflect.Descriptor instead. func (*MarkerPosition) Descriptor() ([]byte, []int) { - return file_ocap_proto_rawDescGZIP(), []int{9} + return file_ocap_proto_rawDescGZIP(), []int{10} } func (x *MarkerPosition) GetFrameNum() uint32 { @@ -1203,7 +1272,7 @@ var File_ocap_proto protoreflect.FileDescriptor const file_ocap_proto_rawDesc = "" + "\n" + "\n" + - "ocap.proto\x12\aocap.v1\"\xf0\x03\n" + + "ocap.proto\x12\aocap.v1\"\x9c\x04\n" + "\bManifest\x12\x18\n" + "\aversion\x18\x01 \x01(\rR\aversion\x12\x1d\n" + "\n" + @@ -1221,7 +1290,11 @@ const file_ocap_proto_rawDesc = "" + " \x03(\v2\x0e.ocap.v1.EventR\x06events\x12,\n" + "\amarkers\x18\v \x03(\v2\x12.ocap.v1.MarkerDefR\amarkers\x12+\n" + "\x11extension_version\x18\f \x01(\tR\x10extensionVersion\x12#\n" + - "\raddon_version\x18\r \x01(\tR\faddonVersion\"\xe6\x02\n" + + "\raddon_version\x18\r \x01(\tR\faddonVersion\x12*\n" + + "\x11snapshot_unit_ids\x18\x0e \x03(\rR\x0fsnapshotUnitIds\"W\n" + + "\x14PlayerSnapshotSeries\x12\x17\n" + + "\aunit_id\x18\x01 \x01(\rR\x06unitId\x12&\n" + + "\x06events\x18\x02 \x03(\v2\x0e.ocap.v1.EventR\x06events\"\xe6\x02\n" + "\tEntityDef\x12\x0e\n" + "\x02id\x18\x01 \x01(\rR\x02id\x12'\n" + "\x04type\x18\x02 \x01(\x0e2\x13.ocap.v1.EntityTypeR\x04type\x12\x12\n" + @@ -1348,38 +1421,40 @@ func file_ocap_proto_rawDescGZIP() []byte { } var file_ocap_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_ocap_proto_msgTypes = make([]protoimpl.MessageInfo, 10) +var file_ocap_proto_msgTypes = make([]protoimpl.MessageInfo, 11) var file_ocap_proto_goTypes = []any{ - (EntityType)(0), // 0: ocap.v1.EntityType - (Side)(0), // 1: ocap.v1.Side - (*Manifest)(nil), // 2: ocap.v1.Manifest - (*EntityDef)(nil), // 3: ocap.v1.EntityDef - (*FiredFrame)(nil), // 4: ocap.v1.FiredFrame - (*TimeSample)(nil), // 5: ocap.v1.TimeSample - (*Chunk)(nil), // 6: ocap.v1.Chunk - (*Frame)(nil), // 7: ocap.v1.Frame - (*EntityState)(nil), // 8: ocap.v1.EntityState - (*Event)(nil), // 9: ocap.v1.Event - (*MarkerDef)(nil), // 10: ocap.v1.MarkerDef - (*MarkerPosition)(nil), // 11: ocap.v1.MarkerPosition + (EntityType)(0), // 0: ocap.v1.EntityType + (Side)(0), // 1: ocap.v1.Side + (*Manifest)(nil), // 2: ocap.v1.Manifest + (*PlayerSnapshotSeries)(nil), // 3: ocap.v1.PlayerSnapshotSeries + (*EntityDef)(nil), // 4: ocap.v1.EntityDef + (*FiredFrame)(nil), // 5: ocap.v1.FiredFrame + (*TimeSample)(nil), // 6: ocap.v1.TimeSample + (*Chunk)(nil), // 7: ocap.v1.Chunk + (*Frame)(nil), // 8: ocap.v1.Frame + (*EntityState)(nil), // 9: ocap.v1.EntityState + (*Event)(nil), // 10: ocap.v1.Event + (*MarkerDef)(nil), // 11: ocap.v1.MarkerDef + (*MarkerPosition)(nil), // 12: ocap.v1.MarkerPosition } var file_ocap_proto_depIdxs = []int32{ - 3, // 0: ocap.v1.Manifest.entities:type_name -> ocap.v1.EntityDef - 5, // 1: ocap.v1.Manifest.times:type_name -> ocap.v1.TimeSample - 9, // 2: ocap.v1.Manifest.events:type_name -> ocap.v1.Event - 10, // 3: ocap.v1.Manifest.markers:type_name -> ocap.v1.MarkerDef - 0, // 4: ocap.v1.EntityDef.type:type_name -> ocap.v1.EntityType - 1, // 5: ocap.v1.EntityDef.side:type_name -> ocap.v1.Side - 4, // 6: ocap.v1.EntityDef.frames_fired:type_name -> ocap.v1.FiredFrame - 7, // 7: ocap.v1.Chunk.frames:type_name -> ocap.v1.Frame - 8, // 8: ocap.v1.Frame.entities:type_name -> ocap.v1.EntityState - 1, // 9: ocap.v1.MarkerDef.side:type_name -> ocap.v1.Side - 11, // 10: ocap.v1.MarkerDef.positions:type_name -> ocap.v1.MarkerPosition - 11, // [11:11] is the sub-list for method output_type - 11, // [11:11] is the sub-list for method input_type - 11, // [11:11] is the sub-list for extension type_name - 11, // [11:11] is the sub-list for extension extendee - 0, // [0:11] is the sub-list for field type_name + 4, // 0: ocap.v1.Manifest.entities:type_name -> ocap.v1.EntityDef + 6, // 1: ocap.v1.Manifest.times:type_name -> ocap.v1.TimeSample + 10, // 2: ocap.v1.Manifest.events:type_name -> ocap.v1.Event + 11, // 3: ocap.v1.Manifest.markers:type_name -> ocap.v1.MarkerDef + 10, // 4: ocap.v1.PlayerSnapshotSeries.events:type_name -> ocap.v1.Event + 0, // 5: ocap.v1.EntityDef.type:type_name -> ocap.v1.EntityType + 1, // 6: ocap.v1.EntityDef.side:type_name -> ocap.v1.Side + 5, // 7: ocap.v1.EntityDef.frames_fired:type_name -> ocap.v1.FiredFrame + 8, // 8: ocap.v1.Chunk.frames:type_name -> ocap.v1.Frame + 9, // 9: ocap.v1.Frame.entities:type_name -> ocap.v1.EntityState + 1, // 10: ocap.v1.MarkerDef.side:type_name -> ocap.v1.Side + 12, // 11: ocap.v1.MarkerDef.positions:type_name -> ocap.v1.MarkerPosition + 12, // [12:12] is the sub-list for method output_type + 12, // [12:12] is the sub-list for method input_type + 12, // [12:12] is the sub-list for extension type_name + 12, // [12:12] is the sub-list for extension extendee + 0, // [0:12] is the sub-list for field type_name } func init() { file_ocap_proto_init() } @@ -1393,7 +1468,7 @@ func file_ocap_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_ocap_proto_rawDesc), len(file_ocap_proto_rawDesc)), NumEnums: 2, - NumMessages: 10, + NumMessages: 11, NumExtensions: 0, NumServices: 0, }, diff --git a/pkg/schemas/protobuf/v1/ocap.proto b/pkg/schemas/protobuf/v1/ocap.proto index f24fb70e3..0ed8acb97 100644 --- a/pkg/schemas/protobuf/v1/ocap.proto +++ b/pkg/schemas/protobuf/v1/ocap.proto @@ -18,6 +18,21 @@ message Manifest { repeated MarkerDef markers = 11; string extension_version = 12; string addon_version = 13; + // Units that have per-player detail snapshots in snapshots/.pb. + // Empty for recordings converted before those sidecars existed — their + // snapshots are still in `events` and are read from there. + repeated uint32 snapshot_unit_ids = 14; +} + +// PlayerSnapshotSeries - every inventory/medical/stamina/radio snapshot for one +// player, held in snapshots/.pb and fetched only when the profile card +// for that player is opened. Keeping them out of the manifest is what makes +// them affordable: they are the bulk of the event stream but are needed for one +// unit at a time. The events are the same Event message the manifest carries, +// in recorded frame order, so a diff chain rebuilds exactly as it always did. +message PlayerSnapshotSeries { + uint32 unit_id = 1; + repeated Event events = 2; } message EntityDef { diff --git a/ui/src/data/__tests__/chunkManager.test.ts b/ui/src/data/__tests__/chunkManager.test.ts index 3fc87077c..db148c54d 100644 --- a/ui/src/data/__tests__/chunkManager.test.ts +++ b/ui/src/data/__tests__/chunkManager.test.ts @@ -34,12 +34,14 @@ function makeDecoder(manifest?: Manifest): DecoderStrategy { return { decodeManifest: vi.fn().mockReturnValue(manifest ?? makeManifest()), decodeChunk: vi.fn().mockImplementation(() => makeChunkData()), + decodePlayerSnapshots: vi.fn().mockReturnValue([]), }; } function makeApi(): ApiClient & { getManifest: ReturnType; getChunk: ReturnType; + getPlayerSnapshots: ReturnType; } { return { getManifest: vi @@ -48,6 +50,9 @@ function makeApi(): ApiClient & { getChunk: vi.fn().mockImplementation( async (_id: string, _idx: number) => new Uint8Array([10, 20]).buffer, ), + getPlayerSnapshots: vi.fn().mockImplementation( + async (_id: string, _unitId: number) => new Uint8Array([30, 40]).buffer, + ), // Other methods not used by ChunkManager getRecordings: vi.fn(), getRecordingData: vi.fn(), @@ -292,6 +297,56 @@ describe("ChunkManager", () => { }); }); + // ─── Player snapshots ─── + + describe("loadPlayerSnapshots", () => { + it("fetches and decodes the sidecar for an indexed unit", async () => { + decoder = makeDecoder(makeManifest({ snapshotUnitIds: [7] })); + (decoder.decodePlayerSnapshots as ReturnType).mockReturnValue([ + { frameNum: 10, type: "inventorySnapshot", payload: { unitId: 7 } }, + ]); + cm = new ChunkManager(decoder, api); + await cm.loadManifest("op_mission"); + + const events = await cm.loadPlayerSnapshots(7); + + expect(api.getPlayerSnapshots).toHaveBeenCalledWith("op_mission", 7); + expect(events).toHaveLength(1); + }); + + it("fetches each unit at most once", async () => { + decoder = makeDecoder(makeManifest({ snapshotUnitIds: [7] })); + cm = new ChunkManager(decoder, api); + await cm.loadManifest("op_mission"); + + await Promise.all([cm.loadPlayerSnapshots(7), cm.loadPlayerSnapshots(7)]); + await cm.loadPlayerSnapshots(7); + + expect(api.getPlayerSnapshots).toHaveBeenCalledTimes(1); + }); + + // A recording whose snapshots are still in the manifest indexes no units, + // so the card must not go looking for a file that does not exist. + it("does not fetch for a unit the manifest does not index", async () => { + await cm.loadManifest("op_mission"); + + expect(await cm.loadPlayerSnapshots(7)).toEqual([]); + expect(api.getPlayerSnapshots).not.toHaveBeenCalled(); + }); + + it("forgets a failed fetch so reopening the card retries", async () => { + decoder = makeDecoder(makeManifest({ snapshotUnitIds: [7] })); + cm = new ChunkManager(decoder, api); + await cm.loadManifest("op_mission"); + api.getPlayerSnapshots.mockRejectedValueOnce(new Error("network")); + + await expect(cm.loadPlayerSnapshots(7)).rejects.toThrow("network"); + await cm.loadPlayerSnapshots(7); + + expect(api.getPlayerSnapshots).toHaveBeenCalledTimes(2); + }); + }); + // ─── clear ─── describe("clear", () => { diff --git a/ui/src/data/apiClient.ts b/ui/src/data/apiClient.ts index fb626566d..85eadd7f4 100644 --- a/ui/src/data/apiClient.ts +++ b/ui/src/data/apiClient.ts @@ -362,6 +362,18 @@ export class ApiClient { return this.fetchBuffer(url); } + /** + * Fetch one player's snapshot sidecar as raw bytes (static file). + * GET {baseUrl}/data/{filename}/snapshots/{unitId}.pb + */ + async getPlayerSnapshots( + filename: string, + unitId: number, + ): Promise { + const url = `${this.baseUrl}/data/${encodeURIComponent(filename)}/snapshots/${unitId}.pb`; + return this.fetchBuffer(url); + } + // ─── Auth methods ─── getSteamLoginUrl(returnTo?: string): string { diff --git a/ui/src/data/chunkManager.ts b/ui/src/data/chunkManager.ts index 65ba518df..3b65ecf9f 100644 --- a/ui/src/data/chunkManager.ts +++ b/ui/src/data/chunkManager.ts @@ -1,4 +1,4 @@ -import type { ChunkData, Manifest } from "./types"; +import type { ChunkData, EventDef, Manifest } from "./types"; import type { DecoderStrategy } from "./decoders/decoder.interface"; import type { ApiClient } from "./apiClient"; @@ -44,6 +44,9 @@ export class ChunkManager { /** Optional callbacks. */ private callbacks: ChunkManagerCallbacks = {}; + /** unitId -> in-flight or settled player-snapshot fetch (loaded at most once). */ + private readonly playerSnapshots = new Map>(); + constructor(decoder: DecoderStrategy, api: ApiClient) { this.decoder = decoder; this.api = api; @@ -119,6 +122,34 @@ export class ChunkManager { return this.loadedChunks.get(chunkIndex) ?? null; } + /** + * Load one player's snapshot sidecar. Snapshots are the bulk of a recording's + * event stream but are read one unit at a time, so they are fetched only when + * that player's profile is opened, and each unit is fetched at most once. + * + * Returns an empty list for a unit the manifest does not index — either the + * player has no snapshots, or this is a recording whose snapshots are still + * in the manifest and therefore already loaded. + */ + async loadPlayerSnapshots(unitId: number): Promise { + this.requireManifest(); + if (!this.manifest!.snapshotUnitIds?.includes(unitId)) return []; + + const inFlight = this.playerSnapshots.get(unitId); + if (inFlight) return inFlight; + + const filename = this.filename!; + const loadPromise = this.api + .getPlayerSnapshots(filename, unitId) + .then((buffer) => this.decoder.decodePlayerSnapshots(buffer)); + + // Cache the promise, not the result, so concurrent opens share one request. + // A failed fetch is forgotten so reopening the card retries it. + this.playerSnapshots.set(unitId, loadPromise); + loadPromise.catch(() => this.playerSnapshots.delete(unitId)); + return loadPromise; + } + /** * Clear all loaded chunks and reset state. */ @@ -126,6 +157,7 @@ export class ChunkManager { this.loadedChunks.clear(); this.chunkAccessOrder = []; this.loadingChunks.clear(); + this.playerSnapshots.clear(); this.prefetchingChunk = null; this.manifest = null; this.filename = null; diff --git a/ui/src/data/decoders/__tests__/protobufDecoder.test.ts b/ui/src/data/decoders/__tests__/protobufDecoder.test.ts index 68d20d445..42d01bd69 100644 --- a/ui/src/data/decoders/__tests__/protobufDecoder.test.ts +++ b/ui/src/data/decoders/__tests__/protobufDecoder.test.ts @@ -5,6 +5,7 @@ import { Manifest as PbManifest, Chunk as PbChunk, EntityType as PbEntityType, + PlayerSnapshotSeries as PbPlayerSnapshotSeries, Side as PbSide, } from "../generated/ocap.pb"; @@ -15,6 +16,85 @@ function encodePb(msg: { encode: (m: T) => { finish: () => Uint8Array }; from return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; } +// ─── Player snapshot sidecar tests ─── + +describe("ProtobufDecoder.decodeManifest snapshot index", () => { + const decoder = new ProtobufDecoder(); + + const base = { + version: 1, + worldName: "Altis", + missionName: "Snapshots", + endFrame: 100, + chunkSize: 300, + captureDelayMs: 1000, + chunkCount: 1, + }; + + it("exposes the units whose snapshots live in a sidecar", () => { + const manifest = decoder.decodeManifest( + encodePb(PbManifest, { ...base, snapshotUnitIds: [7, 12] }), + ); + expect(manifest.snapshotUnitIds).toEqual([7, 12]); + }); + + it("reports no sidecars for a recording that predates them", () => { + const manifest = decoder.decodeManifest(encodePb(PbManifest, base)); + expect(manifest.snapshotUnitIds).toEqual([]); + }); +}); + + +describe("ProtobufDecoder.decodePlayerSnapshots", () => { + const decoder = new ProtobufDecoder(); + + it("decodes a unit's snapshot series, keeping recorded order", () => { + const buffer = encodePb(PbPlayerSnapshotSeries, { + unitId: 7, + events: [ + { + frameNum: 10, + type: "inventorySnapshot", + message: JSON.stringify({ unitId: 7, massUnits: 695 }), + }, + { + frameNum: 40, + type: "inventorySnapshot", + message: JSON.stringify({ unitId: 7, diffOf: 10, set: { massUnits: 700 } }), + }, + ], + }); + + expect(decoder.decodePlayerSnapshots(buffer)).toEqual([ + { frameNum: 10, type: "inventorySnapshot", payload: { unitId: 7, massUnits: 695 } }, + { + frameNum: 40, + type: "inventorySnapshot", + payload: { unitId: 7, diffOf: 10, set: { massUnits: 700 } }, + }, + ]); + }); + + it("drops entries whose payload cannot be read", () => { + const buffer = encodePb(PbPlayerSnapshotSeries, { + unitId: 7, + events: [ + { frameNum: 10, type: "medicalSnapshot", message: "not json" }, + { frameNum: 20, type: "medicalSnapshot", message: JSON.stringify({ unitId: 7 }) }, + ], + }); + + const decoded = decoder.decodePlayerSnapshots(buffer); + expect(decoded).toHaveLength(1); + expect(decoded[0].frameNum).toBe(20); + }); + + it("returns an empty list for an empty series", () => { + expect(decoder.decodePlayerSnapshots(encodePb(PbPlayerSnapshotSeries, { unitId: 7 }))) + .toEqual([]); + }); +}); + // ─── Manifest decoding tests ─── describe("ProtobufDecoder.decodeManifest", () => { diff --git a/ui/src/data/decoders/decoder.interface.ts b/ui/src/data/decoders/decoder.interface.ts index de6c01bc3..e56e85cf1 100644 --- a/ui/src/data/decoders/decoder.interface.ts +++ b/ui/src/data/decoders/decoder.interface.ts @@ -1,6 +1,11 @@ -import type { ChunkData, Manifest } from "../types"; +import type { ChunkData, EventDef, Manifest } from "../types"; export interface DecoderStrategy { decodeManifest(buffer: ArrayBuffer): Manifest; decodeChunk(buffer: ArrayBuffer): ChunkData; + /** + * Decode one player's snapshot sidecar. Formats that keep snapshots in the + * manifest return an empty list — the events are already loaded. + */ + decodePlayerSnapshots(buffer: ArrayBuffer): EventDef[]; } diff --git a/ui/src/data/decoders/generated/ocap.pb.ts b/ui/src/data/decoders/generated/ocap.pb.ts index a575de9ba..0558fa793 100644 --- a/ui/src/data/decoders/generated/ocap.pb.ts +++ b/ui/src/data/decoders/generated/ocap.pb.ts @@ -1,6 +1,6 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: -// protoc-gen-ts_proto v2.11.4 +// protoc-gen-ts_proto v2.12.0 // protoc v4.25.1 // source: ocap.proto @@ -122,6 +122,25 @@ export interface Manifest { markers: MarkerDef[]; extensionVersion: string; addonVersion: string; + /** + * Units that have per-player detail snapshots in snapshots/.pb. + * Empty for recordings converted before those sidecars existed — their + * snapshots are still in `events` and are read from there. + */ + snapshotUnitIds: number[]; +} + +/** + * PlayerSnapshotSeries - every inventory/medical/stamina/radio snapshot for one + * player, held in snapshots/.pb and fetched only when the profile card + * for that player is opened. Keeping them out of the manifest is what makes + * them affordable: they are the bulk of the event stream but are needed for one + * unit at a time. The events are the same Event message the manifest carries, + * in recorded frame order, so a diff chain rebuilds exactly as it always did. + */ +export interface PlayerSnapshotSeries { + unitId: number; + events: Event[]; } export interface EntityDef { @@ -246,6 +265,7 @@ function createBaseManifest(): Manifest { markers: [], extensionVersion: "", addonVersion: "", + snapshotUnitIds: [], }; } @@ -290,6 +310,11 @@ export const Manifest: MessageFns = { if (message.addonVersion !== "") { writer.uint32(106).string(message.addonVersion); } + writer.uint32(114).fork(); + for (const v of message.snapshotUnitIds) { + writer.uint32(v); + } + writer.join(); return writer; }, @@ -404,6 +429,24 @@ export const Manifest: MessageFns = { message.addonVersion = reader.string(); continue; } + case 14: { + if (tag === 112) { + message.snapshotUnitIds.push(reader.uint32()); + + continue; + } + + if (tag === 114) { + const end2 = reader.uint32() + reader.pos; + while (reader.pos < end2) { + message.snapshotUnitIds.push(reader.uint32()); + } + + continue; + } + + break; + } } if ((tag & 7) === 4 || tag === 0) { break; @@ -468,6 +511,11 @@ export const Manifest: MessageFns = { : isSet(object.addon_version) ? globalThis.String(object.addon_version) : "", + snapshotUnitIds: globalThis.Array.isArray(object?.snapshotUnitIds) + ? object.snapshotUnitIds.map((e: any) => globalThis.Number(e)) + : globalThis.Array.isArray(object?.snapshot_unit_ids) + ? object.snapshot_unit_ids.map((e: any) => globalThis.Number(e)) + : [], }; }, @@ -512,6 +560,9 @@ export const Manifest: MessageFns = { if (message.addonVersion !== "") { obj.addonVersion = message.addonVersion; } + if (message.snapshotUnitIds?.length) { + obj.snapshotUnitIds = message.snapshotUnitIds.map((e) => Math.round(e)); + } return obj; }, @@ -533,6 +584,87 @@ export const Manifest: MessageFns = { message.markers = object.markers?.map((e) => MarkerDef.fromPartial(e)) || []; message.extensionVersion = object.extensionVersion ?? ""; message.addonVersion = object.addonVersion ?? ""; + message.snapshotUnitIds = object.snapshotUnitIds?.map((e) => e) || []; + return message; + }, +}; + +function createBasePlayerSnapshotSeries(): PlayerSnapshotSeries { + return { unitId: 0, events: [] }; +} + +export const PlayerSnapshotSeries: MessageFns = { + encode(message: PlayerSnapshotSeries, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.unitId !== 0) { + writer.uint32(8).uint32(message.unitId); + } + for (const v of message.events) { + Event.encode(v!, writer.uint32(18).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): PlayerSnapshotSeries { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBasePlayerSnapshotSeries(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.unitId = reader.uint32(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.events.push(Event.decode(reader, reader.uint32())); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): PlayerSnapshotSeries { + return { + unitId: isSet(object.unitId) + ? globalThis.Number(object.unitId) + : isSet(object.unit_id) + ? globalThis.Number(object.unit_id) + : 0, + events: globalThis.Array.isArray(object?.events) ? object.events.map((e: any) => Event.fromJSON(e)) : [], + }; + }, + + toJSON(message: PlayerSnapshotSeries): unknown { + const obj: any = {}; + if (message.unitId !== 0) { + obj.unitId = Math.round(message.unitId); + } + if (message.events?.length) { + obj.events = message.events.map((e) => Event.toJSON(e)); + } + return obj; + }, + + create, I>>(base?: I): PlayerSnapshotSeries { + return PlayerSnapshotSeries.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): PlayerSnapshotSeries { + const message = createBasePlayerSnapshotSeries(); + message.unitId = object.unitId ?? 0; + message.events = object.events?.map((e) => Event.fromPartial(e)) || []; return message; }, }; diff --git a/ui/src/data/decoders/jsonDecoder.ts b/ui/src/data/decoders/jsonDecoder.ts index 1837c08da..98592c028 100644 --- a/ui/src/data/decoders/jsonDecoder.ts +++ b/ui/src/data/decoders/jsonDecoder.ts @@ -440,4 +440,12 @@ export class JsonDecoder implements DecoderStrategy { "The entire operation is contained in the manifest.", ); } + + /** + * A JSON recording carries its player snapshots inline with every other + * event, so there is no sidecar to read and nothing to add. + */ + decodePlayerSnapshots(_buffer: ArrayBuffer): EventDef[] { + return []; + } } diff --git a/ui/src/data/decoders/protobufDecoder.ts b/ui/src/data/decoders/protobufDecoder.ts index 2750d71da..e0d5676e9 100644 --- a/ui/src/data/decoders/protobufDecoder.ts +++ b/ui/src/data/decoders/protobufDecoder.ts @@ -20,6 +20,7 @@ import { import { Manifest as PbManifest, Chunk as PbChunk, + PlayerSnapshotSeries as PbPlayerSnapshotSeries, EntityType as PbEntityType, Side as PbSide, type EntityDef as PbEntityDef, @@ -303,9 +304,21 @@ export class ProtobufDecoder implements DecoderStrategy { addonVersion: pb.addonVersion || undefined, radioPropagation: extracted.radioPropagation, acrePropagation: extracted.acrePropagation, + snapshotUnitIds: pb.snapshotUnitIds, }; } + /** + * Decode one player's snapshot sidecar (snapshots/.pb). The events are + * the same shape the manifest carries, so they go through convertEvent + * unchanged and the diff chain rebuilds exactly as it does for a recording + * that still keeps its snapshots in the manifest. + */ + decodePlayerSnapshots(buffer: ArrayBuffer): EventDef[] { + const pb = PbPlayerSnapshotSeries.decode(new Uint8Array(buffer)); + return pb.events.map(convertEvent).filter((e): e is EventDef => e !== null); + } + decodeChunk(buffer: ArrayBuffer): ChunkData { const pb = PbChunk.decode(new Uint8Array(buffer)); diff --git a/ui/src/data/loaders/__tests__/loader.test.ts b/ui/src/data/loaders/__tests__/loader.test.ts index 60fca6ee5..6be5726bb 100644 --- a/ui/src/data/loaders/__tests__/loader.test.ts +++ b/ui/src/data/loaders/__tests__/loader.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { DecoderStrategy } from "../../decoders/decoder.interface"; -import type { ChunkData, Manifest } from "../../types"; +import type { ChunkData, EventDef, Manifest } from "../../types"; import { Loader } from "../loader"; // ─── Helper ─── @@ -37,6 +37,9 @@ function makeCapturingDecoder(): DecoderStrategy & { stub.lastChunkBuffer = buffer; return { entities: new Map() }; }, + decodePlayerSnapshots(): EventDef[] { + return []; + }, }; return stub; } diff --git a/ui/src/data/loaders/__tests__/loaderRegistry.test.ts b/ui/src/data/loaders/__tests__/loaderRegistry.test.ts index 31fe56b18..dc6b1b40f 100644 --- a/ui/src/data/loaders/__tests__/loaderRegistry.test.ts +++ b/ui/src/data/loaders/__tests__/loaderRegistry.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { DecoderStrategy } from "../../decoders/decoder.interface"; -import type { ChunkData, Manifest } from "../../types"; +import type { ChunkData, EventDef, Manifest } from "../../types"; import { LoaderRegistry } from "../loaderRegistry"; /** Minimal stub decoder for testing the registry. */ @@ -24,6 +24,9 @@ function makeStubDecoder(label: string): DecoderStrategy { decodeChunk(_buffer: ArrayBuffer): ChunkData { return { entities: new Map() }; }, + decodePlayerSnapshots(_buffer: ArrayBuffer): EventDef[] { + return []; + }, }; } diff --git a/ui/src/data/types.ts b/ui/src/data/types.ts index f11648cdd..9b82a8cd1 100644 --- a/ui/src/data/types.ts +++ b/ui/src/data/types.ts @@ -327,6 +327,12 @@ export interface Manifest { radioPropagation?: TfarRadioPropagation; /** ACRE coverage settings stamped by the recorder, or omitted on older files. */ acrePropagation?: AcreRadioPropagation; + /** + * Units whose player snapshots live in their own file rather than in `events`. + * Only chunked protobuf recordings converted since the sidecars existed set + * this; everywhere else the snapshots are in `events` and this stays empty. + */ + snapshotUnitIds?: number[]; } /** A decoded chunk: entity ID -> array of states for this chunk's frames. */ diff --git a/ui/src/i18n/locales.ts b/ui/src/i18n/locales.ts index 876153f64..639393703 100644 --- a/ui/src/i18n/locales.ts +++ b/ui/src/i18n/locales.ts @@ -2635,6 +2635,7 @@ export const translations: Record> = { profile_reason_change: { en: "change", ru: "изменение", de: "Änderung", cs: "změna", it: "cambio", fr: "changement", fi: "muutos", uk: "зміна" }, profile_reason_death: { en: "death", ru: "смерть", de: "Tod", cs: "smrt", it: "morte", fr: "mort", fi: "kuolema", uk: "смерть" }, profile_empty: { en: "Empty", ru: "Пусто", de: "Leer", cs: "Prázdné", it: "Vuoto", fr: "Vide", fi: "Tyhjä", uk: "Порожньо" }, + profile_loading: { en: "Loading player data…", ru: "Загрузка данных игрока…", de: "Spielerdaten werden geladen…", cs: "Načítání dat hráče…", it: "Caricamento dati giocatore…", fr: "Chargement des données du joueur…", fi: "Ladataan pelaajan tietoja…", uk: "Завантаження даних гравця…" }, profile_none: { en: "None", ru: "Нет", de: "Keine", cs: "Žádné", it: "Nessuno", fr: "Aucun", fi: "Ei ole", uk: "Немає" }, profile_yes: { en: "Yes", ru: "Да", de: "Ja", cs: "Ano", it: "Sì", fr: "Oui", fi: "Kyllä", uk: "Так" }, profile_no: { en: "No", ru: "Нет", de: "Nein", cs: "Ne", it: "No", fr: "Non", fi: "Ei", uk: "Ні" }, diff --git a/ui/src/pages/recording-playback/components/PlayerProfileCard.module.css b/ui/src/pages/recording-playback/components/PlayerProfileCard.module.css index eb81c3a20..a80c9dbef 100644 --- a/ui/src/pages/recording-playback/components/PlayerProfileCard.module.css +++ b/ui/src/pages/recording-playback/components/PlayerProfileCard.module.css @@ -121,6 +121,15 @@ text-transform: uppercase; } +/* Shown while a chunked recording fetches this player's snapshot sidecar. */ +.loading { + padding: 12px; + color: var(--text-dimmest); + font-size: 11px; + font-style: italic; + text-align: center; +} + .tabs { display: flex; gap: 4px; diff --git a/ui/src/pages/recording-playback/components/PlayerProfileCard.tsx b/ui/src/pages/recording-playback/components/PlayerProfileCard.tsx index 6622a4329..3ad9ab635 100644 --- a/ui/src/pages/recording-playback/components/PlayerProfileCard.tsx +++ b/ui/src/pages/recording-playback/components/PlayerProfileCard.tsx @@ -335,16 +335,28 @@ export function PlayerProfileCard(props: Props): JSX.Element { setRangeMode(mode); }; + // A chunked recording keeps player snapshots in a per-unit sidecar rather than + // the manifest, so opening the card is what fetches them. Everywhere else this + // resolves immediately against data that is already loaded. + const [snapshotsLoading, setSnapshotsLoading] = createSignal(false); + createEffect(() => { - props.unit.id; + const unitId = props.unit.id; setSelectedPart(null); setRangeRadio(null); setDiffing(false); setToPinned(null); + + setSnapshotsLoading(true); + void engine.ensurePlayerSnapshots(unitId).finally(() => { + // Ignore a load that finished after the user moved to another player. + if (props.unit.id === unitId) setSnapshotsLoading(false); + }); }); const trackedKinds = createMemo(() => { engine.endFrame(); + engine.playerSnapshotsVersion(); const kinds = new Set(); for (const entry of TAB_DEFS) { if (engine.eventManager.getFirstPlayerSnapshotFrame(props.unit.id, entry.type) !== undefined) { @@ -366,6 +378,7 @@ export function PlayerProfileCard(props: Props): JSX.Element { const snapshots = createMemo(() => { engine.currentFrame(); + engine.playerSnapshotsVersion(); return engine.eventManager.getPlayerSnapshots(props.unit.id, engine.currentFrame()); }); const toFrame = () => toPinned() ?? engine.currentFrame(); @@ -813,6 +826,10 @@ export function PlayerProfileCard(props: Props): JSX.Element {
+ +
{t("profile_loading")}
+
+ 0}>
+ + ); +} diff --git a/ui/src/pages/recording-playback/components/EventFilters.tsx b/ui/src/pages/recording-playback/components/EventFilters.tsx index 930c33532..dbb4dbbd1 100644 --- a/ui/src/pages/recording-playback/components/EventFilters.tsx +++ b/ui/src/pages/recording-playback/components/EventFilters.tsx @@ -15,6 +15,10 @@ export interface EventFilterState { showTerminalHacks: boolean; showMissionEvents: boolean; showZeusPings: boolean; + showExplosions: boolean; + showServicing: boolean; + showConstructions: boolean; + showTransmissions: boolean; sideFilter: SideFilter; } @@ -26,6 +30,14 @@ export const DEFAULT_EVENT_FILTERS: EventFilterState = { showTerminalHacks: true, showMissionEvents: true, showZeusPings: true, + // Off by default like hits: a mortar mission writes one per round, and the + // blast circles are on the map either way. + showExplosions: false, + showServicing: true, + showConstructions: true, + // Off by default like hits and explosions: a talkative op keys up hundreds of + // times, and the Comms tab is where transmissions are actually readable. + showTransmissions: false, sideFilter: "all", }; @@ -44,6 +56,10 @@ const EVENT_TYPE_ITEMS: EventTypeItem[] = [ { key: "showTerminalHacks", labelKey: "terminal_hacks" }, { key: "showMissionEvents", labelKey: "mission_events" }, { key: "showZeusPings", labelKey: "zeus_pings" }, + { key: "showExplosions", labelKey: "explosions" }, + { key: "showServicing", labelKey: "servicing" }, + { key: "showConstructions", labelKey: "constructions" }, + { key: "showTransmissions", labelKey: "transmissions" }, ]; const SIDE_FILTER_OPTIONS: { key: SideFilter; labelKey: string }[] = [ @@ -61,6 +77,10 @@ function isNonDefault(state: EventFilterState): boolean { state.showTerminalHacks !== DEFAULT_EVENT_FILTERS.showTerminalHacks || state.showMissionEvents !== DEFAULT_EVENT_FILTERS.showMissionEvents || state.showZeusPings !== DEFAULT_EVENT_FILTERS.showZeusPings || + state.showExplosions !== DEFAULT_EVENT_FILTERS.showExplosions || + state.showServicing !== DEFAULT_EVENT_FILTERS.showServicing || + state.showConstructions !== DEFAULT_EVENT_FILTERS.showConstructions || + state.showTransmissions !== DEFAULT_EVENT_FILTERS.showTransmissions || state.sideFilter !== DEFAULT_EVENT_FILTERS.sideFilter ); } diff --git a/ui/src/pages/recording-playback/components/EventsTab.tsx b/ui/src/pages/recording-playback/components/EventsTab.tsx index a0a132b6c..411da628f 100644 --- a/ui/src/pages/recording-playback/components/EventsTab.tsx +++ b/ui/src/pages/recording-playback/components/EventsTab.tsx @@ -10,10 +10,17 @@ import { CapturedEvent } from "../../../playback/events/capturedEvent"; import { TerminalHackEvent } from "../../../playback/events/terminalHackEvent"; import { PlayerSnapshotEvent } from "../../../playback/events/playerSnapshotEvent"; import { ZeusPingEvent } from "../../../playback/events/zeusEvents"; +import { + ExplosionEvent, + RadioTransmissionEvent, + ServiceEvent, + StaticWeaponEvent, +} from "../../../playback/events/supportEvents"; +import { formatNet } from "../../../playback/comms"; import type { GameEvent } from "../../../playback/events/gameEvent"; import { SIDE_COLORS_UI } from "../../../config/sideColors"; import { formatElapsedTime } from "../../../playback/time"; -import { SkullIcon, BulletIcon, LinkIcon, ClockIcon, DoorExitIcon, ActivityIcon, FlagIcon, AlertTriangleIcon, TerminalIcon, TargetIcon } from "../../../components/Icons"; +import { SkullIcon, BulletIcon, LinkIcon, ClockIcon, DoorExitIcon, ActivityIcon, FlagIcon, AlertTriangleIcon, TerminalIcon, TargetIcon, ExplosionIcon, WrenchIcon, StaticWeaponIcon, RadioIcon } from "../../../components/Icons"; import { EventFilters, DEFAULT_EVENT_FILTERS } from "./EventFilters"; import type { EventFilterState } from "./EventFilters"; import styles from "./SidePanel.module.css"; @@ -52,6 +59,18 @@ function eventStyle(event: GameEvent): { icon: JSX.Element; color: string } { if (event instanceof ZeusPingEvent) { return { icon: , color: SIDE_COLORS_UI.VIRTUAL }; } + if (event instanceof ExplosionEvent) { + return { icon: , color: "var(--accent-warning)" }; + } + if (event instanceof ServiceEvent) { + return { icon: , color: "var(--accent-success)" }; + } + if (event instanceof StaticWeaponEvent) { + return { icon: , color: "var(--accent-primary)" }; + } + if (event instanceof RadioTransmissionEvent) { + return { icon: , color: "var(--accent-purple)" }; + } return { icon: , color: "#888" }; } @@ -72,6 +91,12 @@ export function EventsTab(): JSX.Element { if (event instanceof CapturedEvent) return f.showCaptures; if (event instanceof TerminalHackEvent) return f.showTerminalHacks; if (event instanceof ZeusPingEvent) return f.showZeusPings; + if (event instanceof ExplosionEvent) return f.showExplosions; + if (event instanceof ServiceEvent) return f.showServicing; + if (event instanceof StaticWeaponEvent) return f.showConstructions; + // Only the key-down is listed: a Stop says nothing on its own, and pairing + // the two into a duration is the comms tab's job. + if (event instanceof RadioTransmissionEvent) return f.showTransmissions && event.isStart; if (event instanceof EndMissionEvent || event instanceof GeneralMissionEvent) { return f.showMissionEvents; } @@ -118,6 +143,23 @@ export function EventsTab(): JSX.Element { if (!event.unitName.toLowerCase().includes(text)) return false; } else if (event instanceof ZeusPingEvent) { if (!event.payload.name.toLowerCase().includes(text)) return false; + } else if (event instanceof ExplosionEvent) { + if (!event.payload.name.toLowerCase().includes(text)) return false; + } else if (event instanceof ServiceEvent) { + const haystack = [event.payload.vehicleName, event.payload.unitName] + .join(" ").toLowerCase(); + if (!haystack.includes(text)) return false; + } else if (event instanceof StaticWeaponEvent) { + const haystack = [event.payload.name, event.payload.unitName] + .join(" ").toLowerCase(); + if (!haystack.includes(text)) return false; + } else if (event instanceof RadioTransmissionEvent) { + const haystack = [ + event.payload.radio, + formatNet(event.payload.frequency, event.payload.code), + engine.entityManager.getEntity(event.unitId)?.name ?? "", + ].join(" ").toLowerCase(); + if (!haystack.includes(text)) return false; } } @@ -136,6 +178,16 @@ export function EventsTab(): JSX.Element { engine.panToPosition(event.position); } else if (event instanceof ZeusPingEvent) { engine.panToPosition(event.position); + } else if (event instanceof ExplosionEvent) { + engine.panToPosition(event.position); + } else if (event instanceof ServiceEvent) { + // The vehicle is the subject and it may well have driven off since. + if (event.payload.vehicleId >= 0) engine.panToEntity(event.payload.vehicleId); + else engine.panToPosition(event.position); + } else if (event instanceof StaticWeaponEvent) { + engine.panToPosition(event.position); + } else if (event instanceof RadioTransmissionEvent) { + engine.panToEntity(event.unitId); } }; @@ -288,6 +340,82 @@ export function EventsTab(): JSX.Element { + ) : event instanceof ExplosionEvent ? ( + <> + + {event.payload.name} {t("detonated")} + + + + + {timeStr(event.frameNum)} + + + {Math.round(event.radius)}m + + + + ) : event instanceof ServiceEvent ? ( + <> + + {t(event.payload.kind)}}> + + {event.payload.unitName} + + {" "}{t(event.payload.kind)} + + {" "}{event.payload.vehicleName} + + + + + {timeStr(event.frameNum)} + + + {Math.round((event.progress ?? 0) * 100)}% + + }> + + {event.payload.count}x {event.payload.magazineName ?? event.payload.magazine} + + + + + ) : event instanceof StaticWeaponEvent ? ( + <> + + {t(event.payload.action)}}> + + {event.payload.unitName} + + {" "}{t(event.payload.action)} + + {" "}{event.payload.name} + + + + + {timeStr(event.frameNum)} + + + + ) : event instanceof RadioTransmissionEvent ? ( + <> + + + {engine.entityManager.getEntity(event.unitId)?.name ?? `Unit ${event.unitId}`} + + {" "}{t("transmitted_on")} {formatNet(event.payload.frequency, event.payload.code)} + + + + + {timeStr(event.frameNum)} + + {event.payload.radio} + + ) : event instanceof TerminalHackEvent ? ( <> diff --git a/ui/src/pages/recording-playback/components/MovementTable.module.css b/ui/src/pages/recording-playback/components/MovementTable.module.css new file mode 100644 index 000000000..dd09e03ad --- /dev/null +++ b/ui/src/pages/recording-playback/components/MovementTable.module.css @@ -0,0 +1,19 @@ +/* Distance columns for the movement leaderboard. Narrower than the kill columns + because the values are formatted ("1.2 km"), not raw counts. */ +.col { + width: 58px; + text-align: right; + font-family: var(--font-mono); + font-size: var(--font-size-xs); + color: var(--text-dimmer); + font-variant-numeric: tabular-nums; +} + +.colTotal { + width: 62px; + text-align: right; + font-family: var(--font-mono); + font-size: var(--font-size-xs); + color: var(--text-primary); + font-variant-numeric: tabular-nums; +} diff --git a/ui/src/pages/recording-playback/components/OrbatTab.module.css b/ui/src/pages/recording-playback/components/OrbatTab.module.css new file mode 100644 index 000000000..9e68c5246 --- /dev/null +++ b/ui/src/pages/recording-playback/components/OrbatTab.module.css @@ -0,0 +1,187 @@ +.section { + padding: 10px 12px 6px; + font-size: var(--font-size-xs); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-dimmer); + font-family: var(--font-mono); +} + +.note { + padding: 0 12px 10px; + font-size: var(--font-size-xs); + color: var(--text-dimmer); +} + +.group { + margin: 0 8px 8px; + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 6px; + overflow: hidden; +} + +.groupHeader { + display: flex; + align-items: center; + padding: 6px 10px; + background: rgba(255, 255, 255, 0.03); + font-family: var(--font-mono); + font-size: var(--font-size-sm); +} + +.groupName { + color: var(--text-primary); +} + +.groupStrength { + margin-left: auto; + font-variant-numeric: tabular-nums; + color: var(--text-dimmer); +} + +.strengthLow { + color: var(--accent-danger); +} + +.strengthSlash { + opacity: 0.4; + margin: 0 2px; +} + +.member { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 4px 10px 4px 14px; + border: none; + background: none; + color: inherit; + font-family: inherit; + font-size: var(--font-size-sm); + text-align: left; + cursor: pointer; +} + +.member:hover { + background: rgba(255, 255, 255, 0.05); +} + +.memberDead { + opacity: 0.4; + text-decoration: line-through; +} + +.memberName { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.leaderBadge { + padding: 0 4px; + border-radius: 3px; + background: rgba(255, 255, 255, 0.12); + font-size: var(--font-size-xs); + font-family: var(--font-mono); + color: var(--text-secondary); +} + +.regroupedBadge { + color: var(--accent-warning); + font-size: var(--font-size-xs); +} + +.memberMeta { + margin-left: auto; + display: flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono); + font-size: var(--font-size-xs); + color: var(--text-dimmer); + white-space: nowrap; +} + +.mounted { + color: var(--accent-primary); +} + +.memberRole { + overflow: hidden; + text-overflow: ellipsis; + max-width: 90px; +} + +.vehicle { + margin: 0 8px 10px; +} + +.vehicleHeader { + display: flex; + align-items: center; + width: 100%; + padding: 3px 2px; + border: none; + background: none; + color: inherit; + font-family: inherit; + font-size: var(--font-size-sm); + text-align: left; + cursor: pointer; +} + +.vehicleHeader:hover .vehicleName { + color: var(--accent-primary); +} + +.vehicleName { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-primary); +} + +.vehicleMeta { + margin-left: auto; + font-family: var(--font-mono); + font-size: var(--font-size-xs); + color: var(--text-dimmer); +} + +.crewCount { + color: var(--accent-success); +} + +.strip { + display: flex; + gap: 1px; + height: 14px; + border-radius: 3px; + overflow: hidden; + background: rgba(255, 255, 255, 0.03); +} + +.stripCell { + flex: 1; + min-width: 0; + background: var(--accent-primary); + cursor: pointer; +} + +.stripCellEmpty { + background: rgba(255, 255, 255, 0.05); +} + +.stripCell:hover { + background: var(--text-primary); +} + +.crewList { + padding: 3px 2px 0; + font-size: var(--font-size-xs); + color: var(--text-dimmer); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/ui/src/pages/recording-playback/components/OrbatTab.tsx b/ui/src/pages/recording-playback/components/OrbatTab.tsx new file mode 100644 index 000000000..8123110fd --- /dev/null +++ b/ui/src/pages/recording-playback/components/OrbatTab.tsx @@ -0,0 +1,228 @@ +import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; +import type { JSX } from "solid-js"; + +import { useEngine } from "../../../hooks/useEngine"; +import { useI18n } from "../../../hooks/useLocale"; +import { SIDE_COLORS_UI } from "../../../config/sideColors"; +import { formatElapsedTime } from "../../../playback/time"; +import { buildOrbat, buildVehicleOccupancy, isLeader } from "../orbat"; +import { activeSide } from "../shortcuts"; +import styles from "./OrbatTab.module.css"; +import panel from "./SidePanel.module.css"; + +export function OrbatTab(): JSX.Element { + const engine = useEngine(); + const { t } = useI18n(); + + const [chunksReady, setChunksReady] = createSignal(false); + const [search, setSearch] = createSignal(""); + const query = createMemo(() => search().trim().toLowerCase()); + + // The vehicle strips span the whole mission, so a chunked recording has to + // have every chunk in hand. Playback itself only ever loads the chunk under + // the playhead, which is why this is asked for explicitly rather than assumed. + createEffect(() => { + void engine.ensureAllChunks().then(() => setChunksReady(true)); + }); + + const allGroups = createMemo(() => buildOrbat(engine, activeSide(), engine.currentFrame())); + + /** + * Groups narrowed by the search box. A hit on the group name keeps the whole + * group — searching a callsign is how you ask who is in it — while a hit on a + * member keeps that member alone. `alive` is recounted over what survives so + * the strength readout describes the rows actually shown. + */ + const groups = createMemo(() => { + const q = query(); + if (!q) return allGroups(); + return allGroups().flatMap((group) => { + if (group.name.toLowerCase().includes(q)) return [group]; + const members = group.members.filter( + (m) => m.name.toLowerCase().includes(q) || m.role.toLowerCase().includes(q), + ); + if (members.length === 0) return []; + return [{ ...group, members, alive: members.filter((m) => m.alive).length }]; + }); + }); + + const allVehicles = createMemo(() => { + chunksReady(); + engine.endFrame(); + return buildVehicleOccupancy(engine, engine.currentFrame()); + }); + + /** Vehicles match on their own name or on anyone who ever crewed them. */ + const vehicles = createMemo(() => { + const q = query(); + if (!q) return allVehicles(); + return allVehicles().filter((vehicle) => { + if (vehicle.name.toLowerCase().includes(q)) return true; + const crew = new Set(); + for (const sample of vehicle.samples) { + for (const id of sample.crewIds) crew.add(id); + } + for (const id of crew) { + const name = engine.entityManager.getEntity(id)?.name ?? ""; + if (name.toLowerCase().includes(q)) return true; + } + return false; + }); + }); + + const timeStr = (frame: number): string => formatElapsedTime(frame, engine.captureDelayMs()); + + const sideColor = () => SIDE_COLORS_UI[activeSide()] ?? "#888"; + + const crewNames = (crewIds: number[]): string => + crewIds + .map((id) => engine.entityManager.getEntity(id)?.name ?? `#${id}`) + .join(", "); + + /** Crew aboard at the sample nearest the playhead. */ + const currentCrew = (vehicleId: number): number[] => { + const state = engine.getStateAt(vehicleId, engine.currentFrame()); + return state?.crewIds ?? []; + }; + + return ( + <> + {/* Search: group, player or vehicle name */} +
+ setSearch(e.currentTarget.value)} + /> +
+ +
+
+ {t("orbat_groups")} · {activeSide()} +
+ + 0} + fallback={ +
+ {query() ? t("orbat_no_matches") : t("orbat_no_groups")} +
+ } + > + + {(group) => ( +
+
+ {group.name || t("ungrouped")} + + + {group.alive} + + / + {group.members.length} + +
+ + + {(member) => ( + + )} + +
+ )} +
+
+ +
{t("orbat_vehicles")}
+ + {t("orbat_loading_vehicles")}
} + > + 0} + fallback={ +
+ {query() ? t("orbat_no_vehicle_matches") : t("orbat_no_vehicles")} +
+ } + > + + {(vehicle) => { + const crew = () => currentCrew(vehicle.vehicleId); + return ( +
+ + + {/* Occupancy strip: one cell per sample, brighter with more + aboard, so boarding and dismount read as edges. */} +
+ + {(sample) => ( +
engine.seekTo(sample.frame)} + /> + )} + +
+ + 0}> +
{crewNames(crew())}
+
+
+ ); + }} + + + +
+ + ); +} diff --git a/ui/src/pages/recording-playback/components/SidePanel.module.css b/ui/src/pages/recording-playback/components/SidePanel.module.css index 3a96fa916..3a84ed0ed 100644 --- a/ui/src/pages/recording-playback/components/SidePanel.module.css +++ b/ui/src/pages/recording-playback/components/SidePanel.module.css @@ -38,13 +38,23 @@ border-bottom: var(--border-subtle); padding: 0 4px; flex-shrink: 0; + /* The panel is a fixed 370px and the row has to hold every tab inside it. + Without this the row simply overflows and the last tab is clipped at the + panel's rounded edge rather than the row adapting to fit. */ + overflow: hidden; } .tab { display: flex; align-items: center; - gap: 6px; - padding: 8px 14px; + justify-content: center; + gap: 5px; + /* Equal shares of the panel, and allowed to shrink below their content width. + `min-width: 0` is the part that actually lets the label ellipsis instead of + forcing the row wider than its container. */ + flex: 1 1 0; + min-width: 0; + padding: 8px 6px; border: none; border-bottom: 2px solid transparent; background: transparent; @@ -57,6 +67,12 @@ position: relative; } +/* The icon is the tab's only always-legible identifier, so it never gets + squeezed -- the label gives up room first. */ +.tab > svg { + flex-shrink: 0; +} + .tab:hover { color: var(--text-muted); } @@ -64,10 +80,19 @@ .tabActive { border-bottom-color: var(--accent-primary); color: var(--text-primary); + /* The tab you are on always reads in full; the others give up their share + first. This is what keeps a long label in German or Russian legible where + equal shares alone would truncate every tab including the current one. */ + flex-shrink: 0; } .tabLabel { font-size: var(--font-size-md); + /* Truncate rather than push the row wider. The full text stays reachable + through the button's title tooltip. */ + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .tabBadge { diff --git a/ui/src/pages/recording-playback/components/SidePanel.tsx b/ui/src/pages/recording-playback/components/SidePanel.tsx index ed806ff2c..79ad33651 100644 --- a/ui/src/pages/recording-playback/components/SidePanel.tsx +++ b/ui/src/pages/recording-playback/components/SidePanel.tsx @@ -1,10 +1,12 @@ import { Switch, Match, For } from "solid-js"; import type { Accessor, JSX } from "solid-js"; -import { UsersIcon, ActivityIcon, BarChartIcon } from "../../../components/Icons"; +import { UsersIcon, ActivityIcon, BarChartIcon, RadioIcon, SitemapIcon } from "../../../components/Icons"; import { useI18n } from "../../../hooks/useLocale"; import { UnitsTab } from "./UnitsTab"; import { EventsTab } from "./EventsTab"; import { StatsTab } from "./StatsTab"; +import { CommsTab } from "./CommsTab"; +import { OrbatTab } from "./OrbatTab"; import styles from "./SidePanel.module.css"; export interface SidePanelProps { @@ -22,6 +24,8 @@ export function SidePanel(props: SidePanelProps): JSX.Element { const tabs = [ { id: "units" as const, labelKey: "units", Icon: UsersIcon }, { id: "events" as const, labelKey: "events", Icon: ActivityIcon }, + { id: "orbat" as const, labelKey: "orbat", Icon: SitemapIcon }, + { id: "comms" as const, labelKey: "comms", Icon: RadioIcon }, { id: "stats" as const, labelKey: "stats", Icon: BarChartIcon }, ]; @@ -34,6 +38,9 @@ export function SidePanel(props: SidePanelProps): JSX.Element { class={styles.tab} classList={{ [styles.tabActive]: props.activeTab() === tab.id }} onClick={() => props.onTabChange(tab.id)} + // Five tabs share a fixed-width panel, so an inactive label can be + // truncated. The tooltip is where the full name stays reachable. + title={t(tab.labelKey)} > {t(tab.labelKey)} @@ -54,6 +61,12 @@ export function SidePanel(props: SidePanelProps): JSX.Element { + + + + + + diff --git a/ui/src/pages/recording-playback/components/StatsTab.tsx b/ui/src/pages/recording-playback/components/StatsTab.tsx index 892427575..c0f375038 100644 --- a/ui/src/pages/recording-playback/components/StatsTab.tsx +++ b/ui/src/pages/recording-playback/components/StatsTab.tsx @@ -1,11 +1,14 @@ -import { createMemo, For, Show } from "solid-js"; +import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; import type { JSX } from "solid-js"; import type { Side } from "../../../data/types"; import { SIDE_COLORS_UI, SIDE_BG_COLORS } from "../../../config/sideColors"; import { useEngine } from "../../../hooks/useEngine"; import { useCustomize } from "../../../hooks/useCustomize"; import { useI18n } from "../../../hooks/useLocale"; +import { buildMovementStats, formatDistance } from "../movement"; import styles from "./SidePanel.module.css"; +import commsStyles from "./CommsTab.module.css"; +import movementStyles from "./MovementTable.module.css"; const SIDES: Side[] = ["WEST", "EAST", "GUER", "CIV", "VIRTUAL"]; @@ -45,6 +48,19 @@ export function StatsTab(): JSX.Element { engine.eventManager.getServerFpsStats(engine.currentFrame()), ); + // Distance covers the whole mission, so a chunked recording needs every chunk; + // a JSON one resolves immediately and this never blocks. + const [movementReady, setMovementReady] = createSignal(false); + createEffect(() => { + void engine.ensureAllChunks().then(() => setMovementReady(true)); + }); + + const movement = createMemo(() => { + movementReady(); + engine.endFrame(); + return buildMovementStats(engine); + }); + const formatFps = (value: number): string => value.toFixed(1); const fpsColor = (value: number): string => { if (value >= 60) return "var(--accent-success)"; @@ -255,6 +271,58 @@ export function StatsTab(): JSX.Element {
+ + {/* ── Distance and pace ── */} +
{t("movement")}
+ {t("movement_loading")}
} + > + 0} + fallback={
{t("movement_none")}
} + > +
+
+ # + + {t("name")} + + + {t("movement_foot_short")} + + + {t("movement_mounted_short")} + + + {t("movement_total_short")} + +
+ + {(entry, i) => ( +
+ {i() + 1} + + {entry.name} + + {formatDistance(entry.onFoot)} + {formatDistance(entry.mounted)} + {formatDistance(entry.total)} +
+ )} +
+
+
+
); diff --git a/ui/src/pages/recording-playback/components/TimelineScrubber.tsx b/ui/src/pages/recording-playback/components/TimelineScrubber.tsx index aaa7b0da4..e394d4775 100644 --- a/ui/src/pages/recording-playback/components/TimelineScrubber.tsx +++ b/ui/src/pages/recording-playback/components/TimelineScrubber.tsx @@ -3,6 +3,8 @@ import type { JSX, Accessor } from "solid-js"; import { useEngine } from "../../../hooks/useEngine"; import type { GameEvent } from "../../../playback/events/gameEvent"; import { HitKilledEvent } from "../../../playback/events/hitKilledEvent"; +import { ExplosionEvent, RadioTransmissionEvent } from "../../../playback/events/supportEvents"; +import { bookmarks } from "../bookmarks"; import { ConnectEvent } from "../../../playback/events/connectEvent"; import { EndMissionEvent } from "../../../playback/events/endMissionEvent"; import { GeneralMissionEvent } from "../../../playback/events/generalEvent"; @@ -20,6 +22,10 @@ interface HeatmapBucket { frameEnd: number; kills: number; hits: number; + /** Detonations: the loudest thing on the map that is not a hit. */ + explosions: number; + /** Radio key-downs, so a busy net reads as activity in its own right. */ + comms: number; other: number; } @@ -109,6 +115,8 @@ export function TimelineScrubber(props: TimelineScrubberProps): JSX.Element { frameEnd: start + ((i + 1) / BUCKET_COUNT) * span, kills: 0, hits: 0, + explosions: 0, + comms: 0, other: 0, })); @@ -118,12 +126,21 @@ export function TimelineScrubber(props: TimelineScrubberProps): JSX.Element { if (ev instanceof HitKilledEvent) { if (ev.type === "killed") buckets[idx].kills++; else buckets[idx].hits++; + } else if (ev instanceof ExplosionEvent) { + buckets[idx].explosions++; + } else if (ev instanceof RadioTransmissionEvent) { + // Only the key-down: counting the release too would double every + // transmission and make a chatty net look twice as busy as it was. + if (ev.isStart) buckets[idx].comms++; } else { buckets[idx].other++; } } - const maxVal = Math.max(1, ...buckets.map(b => b.kills + b.hits + b.other)); + const maxVal = Math.max( + 1, + ...buckets.map((b) => b.kills + b.hits + b.explosions + b.comms + b.other), + ); return { buckets, maxVal }; }); @@ -254,12 +271,15 @@ export function TimelineScrubber(props: TimelineScrubberProps): JSX.Element {
{(bucket) => { - const total = bucket.kills + bucket.hits + bucket.other; + const total = + bucket.kills + bucket.hits + bucket.explosions + bucket.comms + bucket.other; if (total === 0) return
; const h = Math.max(2, (total / heatmapData().maxVal) * HEATMAP_HEIGHT); const killH = (bucket.kills / total) * h; const hitH = (bucket.hits / total) * h; - const otherH = h - killH - hitH; + const explosionH = (bucket.explosions / total) * h; + const commsH = (bucket.comms / total) * h; + const otherH = h - killH - hitH - explosionH - commsH; const isPast = () => bucket.frameEnd <= engine.currentFrame(); const isOutsideFocus = () => { if (constrained()) return false; // everything visible IS the focus @@ -278,6 +298,12 @@ export function TimelineScrubber(props: TimelineScrubberProps): JSX.Element { 0}>
+ 0}> +
+ + 0}> +
+ 0}>
@@ -319,6 +345,30 @@ export function TimelineScrubber(props: TimelineScrubberProps): JSX.Element { }} + {/* Bookmark ticks — the viewer's own marks, above the kill ticks */} + + {(bookmark) => { + if (constrained()) { + const f = props.focusRange(); + if (f && (bookmark.frame < f.inFrame || bookmark.frame > f.outFrame)) return null; + } + return ( +
{ + // The track behind this seeks on click; without stopping here + // a bookmark click would both jump and re-scrub to the cursor. + e.stopPropagation(); + engine.seekTo(bookmark.frame); + }} + /> + ); + }} + + {/* Playhead: full-height vertical line + bottom knob */}
!customize().disableKillCount; const [expandedGroups, setExpandedGroups] = createSignal>(new Set()); + const [search, setSearch] = createSignal(""); + const query = createMemo(() => search().trim().toLowerCase()); const [selectedUnit, setSelectedUnit] = createSignal(null); const selectedProfileUnit = createMemo(() => { const id = selectedUnit(); @@ -90,11 +92,30 @@ export function UnitsTab(props: UnitsTabProps): JSX.Element { return "inactive"; }; + /** + * Name shown in the list. Zeus curators are renamed as they take over units, + * so their label only exists in the frame snapshot. + */ + const unitLabel = (unit: Unit): string => { + if (unit.type === "zeus") { + return engine.entitySnapshots().get(unit.id)?.name || unit.name || `Unit ${unit.id}`; + } + return unit.name || `Unit ${unit.id}`; + }; + + /** A unit matches on its own name or role; the group name is tested separately. */ + const unitMatches = (unit: Unit, q: string): boolean => + unitLabel(unit).toLowerCase().includes(q) || (unit.role ?? "").toLowerCase().includes(q); + const groups = createMemo((): GroupData[] => { const units = unitsForSide(activeSide()); + const q = query(); const groupMap = new Map(); for (const u of units) { const gn = u.groupName || t("ungrouped"); + // A hit on the group name keeps the whole group, so searching a callsign + // still shows who is in it; otherwise only the matching members remain. + if (q && !gn.toLowerCase().includes(q) && !unitMatches(u, q)) continue; const arr = groupMap.get(gn); if (arr) { arr.push(u); @@ -107,6 +128,10 @@ export function UnitsTab(props: UnitsTabProps): JSX.Element { .map(([name, units]) => ({ name, units })); }); + /** While searching, every surviving group is open — hits must be visible. */ + const isExpanded = (name: string): boolean => + query().length > 0 || expandedGroups().has(name); + const toggleGroup = (name: string) => { const current = expandedGroups(); const next = new Set(current); @@ -167,11 +192,25 @@ export function UnitsTab(props: UnitsTabProps): JSX.Element {
+ {/* Search: player, unit or group name */} +
+ setSearch(e.currentTarget.value)} + /> +
+ {/* Scrollable unit list */}
+ 0 || !query()} + fallback={
{t("no_units_match")}
} + > {(group) => { - const expanded = () => expandedGroups().has(group.name); + const expanded = () => isExpanded(group.name); const alive = () => aliveCount(group.units); return ( <> @@ -200,12 +239,7 @@ export function UnitsTab(props: UnitsTabProps): JSX.Element { const status = () => getUnitStatus(unit.id); const selected = () => selectedUnit() === unit.id; const snap = () => engine.entitySnapshots().get(unit.id); - const displayName = () => { - if (unit.type === "zeus") { - return snap()?.name || unit.name || `Unit ${unit.id}`; - } - return unit.name || `Unit ${unit.id}`; - }; + const displayName = () => unitLabel(unit); const displayRole = () => { if (unit.type === "zeus" && snap()?.controllingUnitId != null) { return ""; @@ -271,6 +305,7 @@ export function UnitsTab(props: UnitsTabProps): JSX.Element { ); }} +
{(unit) => ( diff --git a/ui/src/pages/recording-playback/components/ViewSettings.tsx b/ui/src/pages/recording-playback/components/ViewSettings.tsx index ec6dfd943..e3a0245a4 100644 --- a/ui/src/pages/recording-playback/components/ViewSettings.tsx +++ b/ui/src/pages/recording-playback/components/ViewSettings.tsx @@ -8,6 +8,13 @@ import { useClickOutside } from "../../../hooks/useClickOutside"; import type { TimeMode } from "../../../playback/time"; import type { RenderLayer } from "../../../renderers/renderer.types"; import type { WorldConfig } from "../../../data/types"; +import { + deathHeatmapVisible, + setDeathHeatmapVisible, + setTrailMode, + trailMode, + type TrailMode, +} from "../viewOptions"; import styles from "./ViewSettings.module.css"; type NameMode = "all" | "players" | "none"; @@ -21,6 +28,14 @@ const TIME_MODE_KEYS: Record = { }; const NAME_MODES: NameMode[] = ["all", "players", "none"]; + +const TRAIL_MODES: TrailMode[] = ["off", "followed", "players"]; + +const TRAIL_MODE_KEYS: Record = { + off: "trails_off", + followed: "trails_followed", + players: "trails_players", +}; const NAME_MODE_KEYS: Record = { all: "names_all", players: "names_players", @@ -155,6 +170,71 @@ export function ViewSettings(props: ViewSettingsProps): JSX.Element { + {/* ── Analysis overlays ── */} +
+ {t("section_overlays")} +
+ + + + + {(mode) => { + const active = () => trailMode() === mode; + return ( + + ); + }} + + {/* ── Time Format ── */}
{t("section_time_format")} diff --git a/ui/src/pages/recording-playback/deepLink.ts b/ui/src/pages/recording-playback/deepLink.ts new file mode 100644 index 000000000..a3c6f91d1 --- /dev/null +++ b/ui/src/pages/recording-playback/deepLink.ts @@ -0,0 +1,192 @@ +/** + * Playback state carried in the URL. + * + * The point is that "watch from 14:32, following Alpha-2" is a link you can + * paste to someone. Only state that survives a reload is encoded: the frame, the + * followed unit, the open panel tab and the side filter. Camera position is + * deliberately left out — it is derived from the followed unit, and encoding a + * pan the viewer immediately overrides is noise. + * + * Written with replaceState, not pushState: scrubbing must not fill the back + * button with every frame you passed through. + */ + +import type { Side } from "../../data/types"; + +/** Panel tabs a link may select. Mirrors the tab ids in . */ +export const DEEP_LINK_TABS = ["units", "events", "orbat", "comms", "stats"] as const; + +export type DeepLinkTab = (typeof DEEP_LINK_TABS)[number]; + +const DEEP_LINK_SIDES: readonly Side[] = ["WEST", "EAST", "GUER", "CIV", "VIRTUAL"]; + +export interface DeepLinkState { + frame?: number; + unit?: number; + tab?: DeepLinkTab; + side?: Side; +} + +/** + * Narrow a panel tab id to one a link can carry, or undefined. + * + * The panel tab signal is a plain string shared with code that has nothing to do + * with links, so the narrowing happens here rather than by tightening that + * signal's type and rippling through every caller. + */ +export function asDeepLinkTab(value: string | null | undefined): DeepLinkTab | undefined { + if (value == null) return undefined; + return (DEEP_LINK_TABS as readonly string[]).includes(value) + ? (value as DeepLinkTab) + : undefined; +} + +const PARAM_FRAME = "t"; +const PARAM_UNIT = "u"; +const PARAM_TAB = "tab"; +const PARAM_SIDE = "side"; + +/** + * A whole, non-negative number, or undefined. + * + * `Number(null)` and `Number("")` are both 0, so an absent or blank parameter + * would otherwise read as frame zero and yank a viewer back to the start. + */ +function readIndex(raw: string | null): number | undefined { + if (raw === null || raw.trim() === "") return undefined; + const value = Number(raw); + if (!Number.isFinite(value) || value < 0) return undefined; + return Math.floor(value); +} + +/** + * Read playback state out of the current URL. + * + * Everything here is untrusted: a link is typed, truncated and edited by hand. + * A parameter that is not one of the values the app actually has is dropped + * rather than passed through, because the alternative is a `Side` that is not a + * side reaching a colour lookup, or a tab id that matches no panel and renders + * an empty sidebar. An unreadable parameter leaves that piece of state at its + * default; it never rejects the rest of the link. + */ +export function readDeepLink(search: string = window.location.search): DeepLinkState { + const params = new URLSearchParams(search); + const state: DeepLinkState = {}; + + const frame = readIndex(params.get(PARAM_FRAME)); + if (frame !== undefined) state.frame = frame; + + const unit = readIndex(params.get(PARAM_UNIT)); + if (unit !== undefined) state.unit = unit; + + const tab = asDeepLinkTab(params.get(PARAM_TAB)); + if (tab !== undefined) state.tab = tab; + + const side = params.get(PARAM_SIDE); + if (side !== null && (DEEP_LINK_SIDES as readonly string[]).includes(side)) { + state.side = side as Side; + } + + return state; +} + +/** + * Build the query string for a state, preserving any parameter the app does not + * own — `renderer`, for one, which selects the map renderer and must survive + * being handed a shared link. + */ +export function buildDeepLinkSearch( + state: DeepLinkState, + search: string = window.location.search, +): string { + const params = new URLSearchParams(search); + + const set = (key: string, value: string | number | undefined) => { + if (value === undefined || value === "") params.delete(key); + else params.set(key, String(value)); + }; + + set(PARAM_FRAME, state.frame); + set(PARAM_UNIT, state.unit); + set(PARAM_TAB, state.tab); + set(PARAM_SIDE, state.side); + + const query = params.toString(); + return query ? `?${query}` : ""; +} + +/** Replace the URL's query string in place, without touching history. */ +export function writeDeepLink(state: DeepLinkState): void { + const search = buildDeepLinkSearch(state); + const url = `${window.location.pathname}${search}${window.location.hash}`; + window.history.replaceState(window.history.state, "", url); +} + +/** + * Smallest gap between two URL writes, in milliseconds. + * + * `replaceState` is rate limited by the browser, not merely slow: Safari throws + * a SecurityError after roughly 100 calls in 30 seconds. The playhead is a + * signal driven by requestAnimationFrame, and a scrub drag moves it once per + * pointer event, so an unthrottled write per frame breaks the page rather than + * just costing time. One write every 500 ms stays an order of magnitude clear of + * that ceiling while keeping a copied address current enough to share. + */ +export const DEEP_LINK_WRITE_INTERVAL_MS = 500; + +let pendingState: DeepLinkState | null = null; +let pendingTimer: ReturnType | null = null; +let lastWriteMs = Number.NEGATIVE_INFINITY; + +function flushPending(): void { + pendingTimer = null; + if (pendingState === null) return; + const state = pendingState; + pendingState = null; + lastWriteMs = Date.now(); + writeDeepLink(state); +} + +/** + * Write the URL at most once per , keeping the + * newest state. + * + * Trailing rather than leading-only: the last position of a scrub is the one + * worth sharing, so the final call always lands even if it arrived inside the + * quiet window. + */ +export function writeDeepLinkThrottled(state: DeepLinkState): void { + pendingState = state; + + if (pendingTimer !== null) return; + + const elapsed = Date.now() - lastWriteMs; + if (elapsed >= DEEP_LINK_WRITE_INTERVAL_MS) { + flushPending(); + return; + } + + pendingTimer = setTimeout(flushPending, DEEP_LINK_WRITE_INTERVAL_MS - elapsed); +} + +/** + * Write any state still waiting and stop the throttle. + * + * Called when playback is torn down, so the pending timer cannot fire against a + * page that has already navigated away and rewrite the URL of wherever the + * viewer went next. + */ +export function cancelThrottledDeepLink(): void { + if (pendingTimer !== null) { + clearTimeout(pendingTimer); + pendingTimer = null; + } + pendingState = null; + lastWriteMs = Number.NEGATIVE_INFINITY; +} + +/** The absolute link to share for a state. */ +export function deepLinkUrl(state: DeepLinkState): string { + const search = buildDeepLinkSearch(state); + return `${window.location.origin}${window.location.pathname}${search}`; +} diff --git a/ui/src/pages/recording-playback/movement.ts b/ui/src/pages/recording-playback/movement.ts new file mode 100644 index 000000000..9f7a1bc3b --- /dev/null +++ b/ui/src/pages/recording-playback/movement.ts @@ -0,0 +1,112 @@ +import type { PlaybackEngine } from "../../playback/engine"; +import { Unit } from "../../playback/entities/unit"; +import type { Side } from "../../data/types"; + +/** + * Distance and pace, summed from the recorded positions. + * + * Sampled every `step` frames rather than every frame, for two reasons: the cost + * is linear in samples and a mission is thousands of frames, and summing every + * frame accumulates the position jitter of a standing man into hundreds of + * phantom metres. A few seconds between samples measures travel, not noise. + */ + +export interface MovementStats { + unitId: number; + name: string; + side: Side | null; + /** Metres covered on foot. */ + onFoot: number; + /** Metres covered as a vehicle occupant. */ + mounted: number; + total: number; + /** Frames spent riding, for the mounted share of their time. */ + mountedFrames: number; + trackedFrames: number; + /** Fastest sustained speed between two samples, m/s. */ + topSpeed: number; +} + +/** + * A jump further than this between samples is a teleport, not movement: Zeus + * placement, a respawn, or a unit re-entering after being out of the loaded + * range. Counting it would put a rifleman ahead of a helicopter. + */ +const MAX_STEP_METRES = 400; + +export function buildMovementStats( + engine: PlaybackEngine, + options: { step?: number; playersOnly?: boolean } = {}, +): MovementStats[] { + const endFrame = engine.endFrame(); + if (endFrame <= 0) return []; + + const captureDelayMs = engine.captureDelayMs() || 1000; + // Roughly five seconds between samples, at any capture delay. + const step = options.step ?? Math.max(1, Math.round(5000 / captureDelayMs)); + const secondsPerStep = (step * captureDelayMs) / 1000; + const playersOnly = options.playersOnly ?? true; + + const result: MovementStats[] = []; + + for (const entity of engine.entityManager.getAll()) { + if (!(entity instanceof Unit)) continue; + if (playersOnly && !entity.isPlayer) continue; + + let onFoot = 0; + let mounted = 0; + let mountedFrames = 0; + let trackedFrames = 0; + let topSpeed = 0; + + let previous: { x: number; y: number } | null = null; + const last = Math.min(entity.endFrame, endFrame); + + for (let frame = entity.startFrame; frame <= last; frame += step) { + const state = engine.getStateAt(entity.id, frame); + if (!state) { + // A gap in loaded data breaks the chain rather than bridging it, so an + // unloaded stretch cannot masquerade as a long march. + previous = null; + continue; + } + + trackedFrames += 1; + if (state.isInVehicle) mountedFrames += 1; + + const position = { x: state.position[0], y: state.position[1] }; + if (previous) { + const distance = Math.hypot(position.x - previous.x, position.y - previous.y); + if (distance <= MAX_STEP_METRES) { + if (state.isInVehicle) mounted += distance; + else onFoot += distance; + topSpeed = Math.max(topSpeed, distance / secondsPerStep); + } + } + previous = position; + } + + if (onFoot + mounted < 1) continue; + + result.push({ + unitId: entity.id, + name: entity.name, + side: entity.side, + onFoot, + mounted, + total: onFoot + mounted, + mountedFrames, + trackedFrames, + topSpeed, + }); + } + + return result.sort((a, b) => b.total - a.total); +} + +/** Metres as a compact "1.2 km" / "840 m". */ +export function formatDistance(metres: number): string { + if (!Number.isFinite(metres)) return "-"; + if (metres >= 1000) return `${(metres / 1000).toFixed(1)} km`; + return `${Math.round(metres)} m`; +} diff --git a/ui/src/pages/recording-playback/orbat.ts b/ui/src/pages/recording-playback/orbat.ts new file mode 100644 index 000000000..5bed6836a --- /dev/null +++ b/ui/src/pages/recording-playback/orbat.ts @@ -0,0 +1,169 @@ +import type { PlaybackEngine } from "../../playback/engine"; +import { Unit } from "../../playback/entities/unit"; +import { Vehicle } from "../../playback/entities/vehicle"; +import type { Side } from "../../data/types"; + +/** + * Force structure read at a frame. + * + * Groups come from the *per-frame* group name, not the entity's starting group, + * because units are reassigned mid-mission — a survivor folded into another + * squad shows up where they actually ended up. `EntitySnapshot` drops that + * field, which is why everything here goes through `engine.getStateAt`. + */ + +export interface OrbatMember { + unitId: number; + name: string; + role: string; + isPlayer: boolean; + alive: boolean; + /** In a vehicle this frame, so the map icon is the vehicle, not them. */ + mounted: boolean; + /** Current group differs from the one they started the mission in. */ + regrouped: boolean; +} + +export interface OrbatGroup { + name: string; + members: OrbatMember[]; + alive: number; +} + +/** Roles the recorder writes for a group leader, lowercased. */ +const LEADER_ROLES = ["leader", "squadleader", "teamleader", "officer"]; + +function isLeader(role: string): boolean { + const normalised = role.toLowerCase().replace(/[\s_-]/g, ""); + return LEADER_ROLES.some((candidate) => normalised.includes(candidate)); +} + +/** + * Build the order of battle for one side at a frame. + * + * Leaders sort to the top of their group, then players, then the rest by name, + * which is how a briefing would list them. + * + * Players only by default. An ORBAT is read to find out who was where and under + * whom, and on a populated mission the AI outnumbers the players heavily enough + * to bury exactly that: filler squads the players never interacted with become + * most of the list. A group with no players in it drops out entirely rather than + * appearing empty. Pass `playersOnly: false` for the full roster. + */ +export function buildOrbat( + engine: PlaybackEngine, + side: Side, + frame: number, + options: { playersOnly?: boolean } = {}, +): OrbatGroup[] { + const playersOnly = options.playersOnly ?? true; + const groups = new Map(); + + for (const entity of engine.entityManager.getAll()) { + if (!(entity instanceof Unit)) continue; + if (frame < entity.startFrame || frame > entity.endFrame) continue; + + const state = engine.getStateAt(entity.id, frame); + if (!state) continue; + if ((state.side ?? entity.side) !== side) continue; + + // Read from the frame rather than the entity: someone who took over an AI + // slot mid-mission is a player from that point on. + const isPlayer = state.isPlayer ?? entity.isPlayer; + if (playersOnly && !isPlayer) continue; + + const groupName = state.groupName || entity.groupName || ""; + const member: OrbatMember = { + unitId: entity.id, + name: state.name || entity.name, + role: state.role ?? "", + isPlayer, + alive: !!state.alive, + mounted: state.isInVehicle === true, + regrouped: !!entity.groupName && !!groupName && groupName !== entity.groupName, + }; + + const list = groups.get(groupName); + if (list) list.push(member); + else groups.set(groupName, [member]); + } + + return [...groups.entries()] + .map(([name, members]) => { + members.sort((a, b) => { + const leadDiff = Number(isLeader(b.role)) - Number(isLeader(a.role)); + if (leadDiff !== 0) return leadDiff; + const playerDiff = Number(b.isPlayer) - Number(a.isPlayer); + if (playerDiff !== 0) return playerDiff; + return a.name.localeCompare(b.name); + }); + return { name, members, alive: members.filter((m) => m.alive).length }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +export { isLeader }; + +// ─── Vehicle occupancy ─── + +export interface CrewSample { + frame: number; + crewIds: number[]; +} + +export interface VehicleOccupancy { + vehicleId: number; + name: string; + samples: CrewSample[]; + /** Highest crew count seen, which sizes the strip. */ + peak: number; + /** True while anyone is aboard at the sampled frame nearest the playhead. */ + occupiedNow: boolean; +} + +/** + * Sample who was aboard each vehicle across the whole recording. + * + * Sampled rather than per-frame on purpose: a two-hour mission is thousands of + * frames and the strip is a couple of hundred pixels wide, so reading every + * frame would cost far more than it could ever show. A vehicle nobody ever + * boarded is dropped — an empty strip says nothing. + */ +export function buildVehicleOccupancy( + engine: PlaybackEngine, + frame: number, + sampleCount = 160, +): VehicleOccupancy[] { + const endFrame = engine.endFrame(); + if (endFrame <= 0) return []; + + const step = Math.max(1, Math.floor(endFrame / sampleCount)); + const result: VehicleOccupancy[] = []; + + for (const entity of engine.entityManager.getAll()) { + if (!(entity instanceof Vehicle)) continue; + + const samples: CrewSample[] = []; + let peak = 0; + let occupiedNow = false; + let nearestDistance = Number.POSITIVE_INFINITY; + + for (let f = entity.startFrame; f <= Math.min(entity.endFrame, endFrame); f += step) { + const state = engine.getStateAt(entity.id, f); + const crewIds = state?.crewIds ?? []; + samples.push({ frame: f, crewIds }); + if (crewIds.length > peak) peak = crewIds.length; + + const distance = Math.abs(f - frame); + if (distance < nearestDistance) { + nearestDistance = distance; + occupiedNow = crewIds.length > 0; + } + } + + if (peak === 0) continue; + result.push({ vehicleId: entity.id, name: entity.name, samples, peak, occupiedNow }); + } + + return result.sort((a, b) => b.peak - a.peak || a.name.localeCompare(b.name)); +} diff --git a/ui/src/pages/recording-playback/useRenderBridge.ts b/ui/src/pages/recording-playback/useRenderBridge.ts index 90a586a89..487c23a22 100644 --- a/ui/src/pages/recording-playback/useRenderBridge.ts +++ b/ui/src/pages/recording-playback/useRenderBridge.ts @@ -1,4 +1,4 @@ -import { createEffect } from "solid-js"; +import { createEffect, createMemo, createSignal } from "solid-js"; import type { Accessor } from "solid-js"; import type { MarkerHandle, @@ -18,10 +18,38 @@ import { zeusPingLifetimeFrames, type ZeusPingGroup, } from "../../playback/zeus"; +import { + explosionAlpha, + explosionColor, + explosionLifetimeFrames, +} from "../../playback/explosions"; +import { + binDeathSites, + busiestCell, + deathBlobAlpha, + deathBlobRadius, + type DeathSite, +} from "../../playback/deathHeatmap"; +import { + fallbackRange, + matchTransmissionRadio, + pairTransmissions, + reachability, + transmissionsAtFrame, +} from "../../playback/comms"; +import { loadWorldDem, type DemGrid } from "../../playback/radioRange/demGrid"; +import type { RadioSnapshot } from "../../data/types"; import { formatTime, type TimeMode } from "../../playback/time"; import type { Side } from "../../data/types"; import type { MapRenderer } from "../../renderers/renderer.interface"; import { leftPanelVisible, activeSide } from "./shortcuts"; +import { + DEATH_HEATMAP_CELL_M, + TRAIL_SAMPLES, + TRAIL_SECONDS, + deathHeatmapVisible, + trailMode, +} from "./viewOptions"; /** * Build structured crew info for a vehicle. @@ -77,6 +105,12 @@ interface PingRender { labelText: string; } +interface BlastRender { + circle: BriefingMarkerHandle; + position: [number, number]; + frameNum: number; +} + /** * Syncs engine snapshots to renderer markers, updates briefing markers * per frame, and keeps the CSS left-offset in sync with panel visibility. @@ -249,6 +283,376 @@ export function useRenderBridge( } }); + // Explosions → transient blast circles. + // The circle is the ammo's own indirectHitRange, so it shows the area the + // blast could actually damage. Recomputing the active set from the frame keeps + // scrubbing backwards working, exactly as it does for pings. + const blastRenders = new Map(); + + createEffect(() => { + const frame = engine.currentFrame(); + const lifetime = explosionLifetimeFrames(engine.captureDelayMs()); + const blasts = engine.eventManager.getActiveExplosions(frame, lifetime); + + const live = new Set(); + + for (const blast of blasts) { + live.add(blast.id); + + let render = blastRenders.get(blast.id); + if (!render) { + render = { + circle: renderer.createBriefingMarker({ + shape: "ELLIPSE", + type: "Empty", + color: explosionColor(blast.radius), + side: "GLOBAL", + size: [blast.radius, blast.radius], + brush: "Solid", + layer: "systemMarkers", + }), + position: blast.position, + frameNum: blast.frameNum, + }; + blastRenders.set(blast.id, render); + } + + renderer.updateBriefingMarker(render.circle, { + position: render.position, + direction: 0, + alpha: explosionAlpha(frame, render.frameNum, lifetime), + }); + } + + for (const [id, render] of blastRenders) { + if (live.has(id)) continue; + renderer.removeBriefingMarker(render.circle); + blastRenders.delete(id); + } + }); + + // Live transmissions → who could hear them. + // + // A line is drawn from the speaker to every unit tuned to that net: solid + // where the signal reaches, dashed and red where terrain or distance breaks + // it. That turns "was the net split" into something visible at the frame the + // contact report went out, rather than a claim in a table. + // + // Reachability is evaluated at the playhead against the terrain model the + // coverage overlay already uses, so the two always agree. + let commsLines: LineHandle[] = []; + + // loadWorldDem is cached per world, so asking for it here costs nothing when + // the comms tab or a profile card has already pulled it. + const [commsDem, setCommsDem] = createSignal(null); + createEffect(() => { + if (commsDem()) return; + const world = engine.worldConfig; + if (!world) return; + void loadWorldDem({ + tileBaseUrl: world.tileBaseUrl, + worldName: world.worldName, + worldSize: world.worldSize, + hasDem: world.hasDem, + hasHeightmap: world.hasHeightmap, + }).then((grid) => { + if (grid) setCommsDem(grid); + }); + }); + + // Pairing folds every Start/Stop in the recording into spans. That depends on + // the recording, not on the playhead, so it is computed once per recording + // rather than rebuilt on each of the sixty frames a second the effect below + // runs at. `endFrame` is the signal that settles when a recording finishes + // loading, which is what makes this recompute for the next one. + const pairedTransmissions = createMemo(() => { + engine.endFrame(); + return pairTransmissions( + engine.eventManager.getRadioTransmissions().map((event) => ({ + frameNum: event.frameNum, + payload: event.payload, + })), + ); + }); + + createEffect(() => { + const frame = engine.currentFrame(); + const snapshots = engine.entitySnapshots(); + + for (const handle of commsLines) renderer.removeLine(handle); + commsLines = []; + + const live = transmissionsAtFrame(pairedTransmissions(), frame); + if (live.length === 0) return; + + // Radios for everyone with a snapshot at this frame, so net membership can + // be resolved without reaching for the sidecars the comms tab loads. + const radiosByUnit = new Map(); + for (const unitId of engine.eventManager.getPlayerSnapshotUnitIds()) { + const snapshot = engine.eventManager.getPlayerSnapshots(unitId, frame).get("radioSnapshot"); + const payload = snapshot?.payload as RadioSnapshot | undefined; + if (payload?.radios?.length) radiosByUnit.set(unitId, payload); + } + + for (const tx of live) { + const origin = snapshots.get(tx.unitId)?.position; + if (!origin) continue; + + const speakerRadios = radiosByUnit.get(tx.unitId)?.radios ?? []; + const radio = matchTransmissionRadio(speakerRadios, tx); + + const peers: Parameters[0]["peers"] = []; + for (const [unitId, snapshot] of radiosByUnit) { + if (unitId === tx.unitId) continue; + if (!snapshot.radios.some((entry) => matchTransmissionRadio([entry], tx))) continue; + const position = snapshots.get(unitId)?.position; + if (!position) continue; + peers.push({ + unitId, + name: "", + side: "", + position: [position[0], position[1], position[2] ?? 0], + monitoring: false, + }); + } + if (peers.length === 0) continue; + + const results = reachability({ + origin: [origin[0], origin[1], origin[2] ?? 0], + peers, + rangeMeters: radio?.rangeMeters ?? fallbackRange(tx.type), + mod: radio?.mod ?? "TFAR", + grid: commsDem(), + tfar: engine.radioPropagation, + acre: engine.acrePropagation, + frequencyMHz: tx.frequency, + }); + + for (const result of results) { + const target = snapshots.get(result.unitId)?.position; + if (!target) continue; + commsLines.push( + renderer.addLine(origin, target, { + color: result.inRange ? "#4ade80" : "#f87171", + weight: result.inRange ? 2 : 1, + opacity: result.inRange ? 0.7 : 0.35, + }), + ); + } + } + }); + + // Movement trails. + // + // Drawn from the recording rather than accumulated during playback, so + // scrubbing backwards shows the trail as it was rather than an empty line. + // Positions are sampled at a fixed count regardless of the window length, + // which keeps a two-minute trail the same cost at any capture delay. + const trailHandles = new Map(); + + // Sampled positions per unit, keyed by the frame they were read at. + // + // The window slides by one frame at a time while the samples inside it are + // snapped to absolute multiples of `step` below, so consecutive playhead + // frames ask for almost entirely the same frames. Without this every frame + // re-read all forty samples for every unit with a trail, which in "players" + // mode is a few thousand chunk lookups a frame. + // + // Only resolved positions are cached. A miss means the covering chunk is not + // loaded yet, and caching that would freeze the gap into the trail even after + // the chunk arrives. + const trailSamples = new Map>(); + + createEffect(() => { + const frame = engine.currentFrame(); + const mode = trailMode(); + const snapshots = engine.entitySnapshots(); + + const wanted = new Set(); + if (mode === "followed") { + const target = engine.followTarget(); + if (target !== null) wanted.add(target); + } else if (mode === "players") { + for (const [id, snap] of snapshots) { + if (snap.isPlayer && !snap.isInVehicle) wanted.add(id); + } + } + + for (const [id, handle] of trailHandles) { + if (wanted.has(id)) continue; + renderer.removeBriefingMarker(handle); + trailHandles.delete(id); + } + for (const id of trailSamples.keys()) { + if (!wanted.has(id)) trailSamples.delete(id); + } + if (wanted.size === 0) return; + + const captureDelayMs = engine.captureDelayMs() || 1000; + const windowFrames = Math.max(1, Math.round((TRAIL_SECONDS * 1000) / captureDelayMs)); + const step = Math.max(1, Math.round(windowFrames / TRAIL_SAMPLES)); + const windowStart = Math.max(0, frame - windowFrames); + + for (const id of wanted) { + let cache = trailSamples.get(id); + if (!cache) { + cache = new Map(); + trailSamples.set(id, cache); + } + // Samples that have fallen out of the back of the window, and anything + // ahead of the playhead, which is what a backwards scrub leaves behind. + for (const sampled of cache.keys()) { + if (sampled < windowStart || sampled > frame) cache.delete(sampled); + } + + const points: [number, number][] = []; + // Snapped to absolute multiples of `step`, so the sample frames are stable + // as the playhead advances and the cache above actually hits. + const first = Math.ceil(windowStart / step) * step; + for (let f = first; f <= frame; f += step) { + let position = cache.get(f); + if (!position) { + const state = engine.getPositionAt(id, f); + if (!state) continue; + position = [state[0], state[1]]; + cache.set(f, position); + } + points.push(position); + } + const head = snapshots.get(id)?.position; + if (head) points.push([head[0], head[1]]); + + // One point is not a line, and Leaflet renders a degenerate polyline as a + // dot that reads as a stray marker. + if (points.length < 2) { + const stale = trailHandles.get(id); + if (stale) { + renderer.removeBriefingMarker(stale); + trailHandles.delete(id); + } + continue; + } + + let handle = trailHandles.get(id); + if (!handle) { + const side = snapshots.get(id)?.side; + handle = renderer.createBriefingMarker({ + shape: "POLYLINE", + type: "Empty", + color: (side ? SIDE_COLORS_BRIGHT[side] : "#ffffff").replace("#", ""), + side: "GLOBAL", + layer: "systemMarkers", + }); + trailHandles.set(id, handle); + } + + renderer.updateBriefingMarker(handle, { + position: points[points.length - 1], + direction: 0, + alpha: 0.55, + points, + }); + } + }); + + // Death heatmap. + // + // Every death up to the playhead, binned into a grid so overlapping kills in + // one building become one blob whose size and opacity carry the count. Bound + // to the playhead rather than the whole mission so it grows as you watch. + // The radius is baked into an ELLIPSE handle at creation, so a blob that grows + // as more deaths land in its cell has to be recreated rather than updated. + const deathBlobs = new Map(); + + // A death's position is read from the chunk covering the frame it happened on, + // and playback keeps only the chunk under the playhead and its neighbour + // resident. Reading them straight therefore silently dropped every death + // outside that window, and blobs blinked in and out as the viewer scrubbed. + // The whole recording is pulled in once, the first time the overlay is turned + // on; a JSON recording already holds everything and resolves immediately, and + // anyone who leaves the overlay off never pays for it. + const [deathChunksReady, setDeathChunksReady] = createSignal(false); + + createEffect(() => { + if (!deathHeatmapVisible()) return; + if (deathChunksReady()) return; + void engine.ensureAllChunks().then(() => setDeathChunksReady(true)); + }); + + /** + * Every death in the recording with a resolved position, oldest first. + * + * Depends on the recording rather than the playhead, so it is resolved once + * instead of rescanning every event and re-reading every victim's position on + * each frame. Sorted so the binning below can stop at the playhead. + */ + const deathSites = createMemo(() => { + if (!deathHeatmapVisible() || !deathChunksReady()) return []; + engine.endFrame(); + + const sites: DeathSite[] = []; + for (const event of engine.eventManager.getAll()) { + if (!(event instanceof HitKilledEvent) || event.type !== "killed") continue; + const position = engine.getPositionAt(event.victimId, event.frameNum); + if (!position) continue; + sites.push({ frameNum: event.frameNum, x: position[0], y: position[1] }); + } + sites.sort((a, b) => a.frameNum - b.frameNum); + return sites; + }); + + createEffect(() => { + const frame = engine.currentFrame(); + const visible = deathHeatmapVisible(); + + if (!visible) { + for (const blob of deathBlobs.values()) renderer.removeBriefingMarker(blob.handle); + deathBlobs.clear(); + return; + } + + const cells = binDeathSites(deathSites(), frame, DEATH_HEATMAP_CELL_M); + + for (const [key, blob] of deathBlobs) { + if (cells.has(key)) continue; + renderer.removeBriefingMarker(blob.handle); + deathBlobs.delete(key); + } + + const busiest = busiestCell(cells.values()); + + for (const [key, cell] of cells) { + const radius = deathBlobRadius(cell.count, busiest, DEATH_HEATMAP_CELL_M); + + let blob = deathBlobs.get(key); + if (blob && Math.abs(blob.radius - radius) > 1) { + renderer.removeBriefingMarker(blob.handle); + blob = undefined; + } + if (!blob) { + blob = { + handle: renderer.createBriefingMarker({ + shape: "ELLIPSE", + type: "Empty", + color: "ff3b3b", + side: "GLOBAL", + size: [radius, radius], + brush: "Solid", + layer: "systemMarkers", + }), + radius, + }; + deathBlobs.set(key, blob); + } + + renderer.updateBriefingMarker(blob.handle, { + position: [cell.x, cell.y], + direction: 0, + alpha: deathBlobAlpha(cell.count, busiest), + }); + } + }); + // Side filter → briefing markers createEffect(() => { markerManager.setSideFilter(activeSide()); diff --git a/ui/src/pages/recording-playback/viewOptions.ts b/ui/src/pages/recording-playback/viewOptions.ts new file mode 100644 index 000000000..a8679cad7 --- /dev/null +++ b/ui/src/pages/recording-playback/viewOptions.ts @@ -0,0 +1,36 @@ +import { createSignal } from "solid-js"; + +/** + * Map overlays that are computed in the UI rather than drawn from the + * recording's own markers. They live here, next to the other panel signals, + * because both the settings menu that toggles them and the render bridge that + * draws them need to read the same value. + */ + +/** + * Whose movement trail to draw. + * + * "off" is the default, so nobody pays for trails they did not ask for. + * "followed" is the cheap one: a single trail for the unit the camera is + * already following. "players" draws every player on foot and is meant for + * looking at a manoeuvre after the fact. + */ +export type TrailMode = "off" | "followed" | "players"; + +export const [trailMode, setTrailMode] = createSignal("off"); + +/** Seconds of movement history a trail covers. */ +export const TRAIL_SECONDS = 120; + +/** Positions sampled per trail; more is smoother and costs more to draw. */ +export const TRAIL_SAMPLES = 40; + +/** Death markers aggregated into a density overlay. */ +export const [deathHeatmapVisible, setDeathHeatmapVisible] = createSignal(false); + +/** + * Grid cell for the death heatmap, in metres. Deaths inside one cell collapse + * into a single blob whose size and opacity carry the count, which is what keeps + * a 500-death mission from drawing 500 overlapping circles. + */ +export const DEATH_HEATMAP_CELL_M = 60; diff --git a/ui/src/playback/__tests__/comms.test.ts b/ui/src/playback/__tests__/comms.test.ts new file mode 100644 index 000000000..c939f2553 --- /dev/null +++ b/ui/src/playback/__tests__/comms.test.ts @@ -0,0 +1,347 @@ +import { describe, it, expect } from "vitest"; + +import { + FALLBACK_RANGE_LR, + FALLBACK_RANGE_SW, + TRANSMISSION_MAX_FRAMES, + buildNets, + fallbackRange, + formatFrequency, + formatNet, + matchTransmissionRadio, + netKey, + pairTransmissions, + reachability, + transmissionsAtFrame, + type CommsUnitRadios, +} from "../comms"; +import { makeFlatDem } from "../radioRange/demGrid"; +import type { RadioSnapshotEntry, RadioTransmissionPayload } from "../../data/types"; + +const TFAR = { terrainInterceptionCoefficient: 7, globalRadioRangeCoef: 1 }; +const ACRE = { terrainLoss: 1, signalModel: 2, ignoreAntennaDirection: false }; + +function radio(over: Partial = {}): RadioSnapshotEntry { + return { + class: "TFAR_anprc152", + name: "AN/PRC-152", + count: 1, + mod: "TFAR", + type: "SW", + channel: 1, + frequency: 69.9, + code: "", + rangeMeters: 5000, + additional: false, + active: true, + ...over, + } as RadioSnapshotEntry; +} + +function unit(unitId: number, radios: RadioSnapshotEntry[]): CommsUnitRadios { + return { unitId, name: `Unit ${unitId}`, side: "WEST", radios }; +} + +describe("netKey", () => { + it("normalises the float32 a transmission arrives as against the snapshot float", () => { + // A transmission's frequency is rounded to 3 decimals in SQF, sent as a + // string and stored as float32; the snapshot keeps the raw value. + const fromTransmission = Math.fround(69.9); + expect(fromTransmission).not.toBe(69.9); + expect(netKey(fromTransmission, "")).toBe(netKey(69.9, "")); + }); + + it("separates nets by encryption code on the same frequency", () => { + expect(netKey(69.9, "0451")).not.toBe(netKey(69.9, "1234")); + }); + + it("does not split a net by SW versus LR, which interoperate in TFAR", () => { + expect(netKey(69.9, "")).toBe(netKey(69.9, "")); + }); + + it("survives a missing frequency without collapsing every net into one", () => { + expect(netKey(Number.NaN, "a")).not.toBe(netKey(Number.NaN, "b")); + }); +}); + +describe("formatFrequency", () => { + it("writes a TFAR frequency the way TFAR does", () => { + // TFAR_FREQ_ROUND_POWER is 10, and its string form drops the decimal when + // the value is whole: "100", not "100.0" and certainly not "100.000". + expect(formatFrequency(100)).toBe("100"); + expect(formatFrequency(69.9)).toBe("69.9"); + expect(formatFrequency(295.8)).toBe("295.8"); + }); + + it("keeps the decimals ACRE actually uses", () => { + // PRC-152 presets walk (950 + 2i) * 0.0625, so 59.375 is a real channel. + expect(formatFrequency(59.375)).toBe("59.375"); + expect(formatFrequency(2400.01)).toBe("2400.01"); + }); + + it("survives a missing frequency", () => { + expect(formatFrequency(Number.NaN)).toBe("unknown"); + }); +}); + +describe("formatNet", () => { + it("shows the frequency, and the code only when one is set", () => { + expect(formatNet(69.9, "")).toBe("69.9 MHz"); + expect(formatNet(69.9, "0451")).toBe("69.9 MHz · 0451"); + }); +}); + +describe("frequency precision against the real mods", () => { + // Every net frequency from the No Wonder Convoy recording, which is what the + // recorder actually writes: TFAR rounds to one decimal and whole numbers keep + // none. Each has to survive the float32 a transmission arrives as. + const RECORDED = [100, 84, 200, 295.8, 201, 47.3, 202]; + + it("joins a transmission to a snapshot for every recorded TFAR frequency", () => { + for (const frequency of RECORDED) { + expect(netKey(Math.fround(frequency), "")).toBe(netKey(frequency, "")); + } + }); + + it("joins the finest ACRE steps too", () => { + for (const frequency of [59.375, 59.5, 400.63, 2400.01, 2402.55]) { + expect(netKey(Math.fround(frequency), "")).toBe(netKey(frequency, "")); + } + }); + + it("still separates adjacent TFAR channels, which are 0.1 apart", () => { + expect(netKey(69.9, "")).not.toBe(netKey(70.0, "")); + expect(netKey(Math.fround(69.9), "")).not.toBe(netKey(Math.fround(70.0), "")); + }); + + it("separates adjacent ACRE channels, which are 0.01 apart", () => { + expect(netKey(Math.fround(2400.01), "")).not.toBe(netKey(Math.fround(2400.02), "")); + }); +}); + +describe("buildNets", () => { + it("puts units sharing a frequency and code on one net", () => { + const nets = buildNets([ + unit(1, [radio()]), + unit(2, [radio()]), + unit(3, [radio({ frequency: 40.5 })]), + ]); + + expect(nets.map((n) => n.frequency)).toEqual([40.5, 69.9]); + expect(nets[1].members.map((m) => m.unitId)).toEqual([1, 2]); + expect(nets[0].members.map((m) => m.unitId)).toEqual([3]); + }); + + it("counts a monitored additional channel as membership", () => { + const nets = buildNets([ + unit(1, [radio()]), + unit(2, [radio({ frequency: 40.5 }), radio({ frequency: 69.9, additional: true, active: false })]), + ]); + + const company = nets.find((n) => n.frequency === 69.9)!; + expect(company.members.map((m) => m.unitId)).toEqual([1, 2]); + expect(company.members.find((m) => m.unitId === 2)!.monitoring).toBe(true); + }); + + it("keeps one entry per unit per net, preferring the longer-ranged radio", () => { + const nets = buildNets([ + unit(1, [radio({ rangeMeters: 5000 }), radio({ type: "LR", rangeMeters: 20000 })]), + ]); + + expect(nets).toHaveLength(1); + expect(nets[0].members).toHaveLength(1); + expect(nets[0].members[0].entry.rangeMeters).toBe(20000); + }); + + it("ignores radios with no frequency", () => { + expect(buildNets([unit(1, [radio({ frequency: Number.NaN })])])).toEqual([]); + }); +}); + +describe("matchTransmissionRadio", () => { + const tx = { frequency: Math.fround(69.9), code: "", type: "SW" }; + + it("finds the radio the transmission was made on", () => { + const sw = radio({ rangeMeters: 5000 }); + const lr = radio({ type: "LR", frequency: 40.5, rangeMeters: 20000 }); + expect(matchTransmissionRadio([lr, sw], tx)).toBe(sw); + }); + + it("prefers the active radio when two of the same type sit on the net", () => { + const idle = radio({ active: false, rangeMeters: 3000 }); + const active = radio({ active: true, rangeMeters: 5000 }); + expect(matchTransmissionRadio([idle, active], tx)).toBe(active); + }); + + it("falls back to any radio on the net when the type does not match", () => { + const lr = radio({ type: "LR", active: false }); + expect(matchTransmissionRadio([lr], tx)).toBe(lr); + }); + + it("returns null when the player carries nothing on that net", () => { + expect(matchTransmissionRadio([radio({ frequency: 40.5 })], tx)).toBeNull(); + }); +}); + +describe("fallbackRange", () => { + it("uses the addon's own defaults when there is no snapshot to join", () => { + expect(fallbackRange("SW")).toBe(FALLBACK_RANGE_SW); + expect(fallbackRange("LR")).toBe(FALLBACK_RANGE_LR); + }); +}); + +describe("reachability", () => { + const peers = [ + { unitId: 2, name: "Near", side: "WEST", position: [1100, 1000, 0] as [number, number, number], monitoring: false }, + { unitId: 3, name: "Far", side: "WEST", position: [9000, 1000, 0] as [number, number, number], monitoring: false }, + ]; + + it("splits the net by range and reports distance nearest first", () => { + const result = reachability({ + origin: [1000, 1000, 0], + peers, + rangeMeters: 5000, + mod: "TFAR", + grid: null, + tfar: TFAR, + acre: ACRE, + }); + + expect(result.map((r) => r.unitId)).toEqual([2, 3]); + expect(result[0].inRange).toBe(true); + expect(Math.round(result[0].distance)).toBe(100); + expect(result[1].inRange).toBe(false); + }); + + it("uses straight-line distance when no terrain grid is loaded", () => { + const result = reachability({ + origin: [1000, 1000, 0], + peers: [peers[0]], + rangeMeters: 50, + mod: "TFAR", + grid: null, + tfar: TFAR, + acre: ACRE, + }); + expect(result[0].inRange).toBe(false); + }); + + it("agrees with the terrain model over flat ground", () => { + const grid = makeFlatDem(10000, 0, 100); + const result = reachability({ + origin: [1000, 1000, 0], + peers, + rangeMeters: 5000, + mod: "TFAR", + grid, + tfar: TFAR, + acre: ACRE, + }); + expect(result[0].inRange).toBe(true); + expect(result[1].inRange).toBe(false); + }); + + it("carries the monitoring flag through, so a listener is distinguishable", () => { + const result = reachability({ + origin: [1000, 1000, 0], + peers: [{ ...peers[0], monitoring: true }], + rangeMeters: 5000, + mod: "TFAR", + grid: null, + tfar: TFAR, + acre: ACRE, + }); + expect(result[0].monitoring).toBe(true); + }); +}); + +describe("pairTransmissions", () => { + function tx(frameNum: number, over: Partial): { frameNum: number; payload: RadioTransmissionPayload } { + return { + frameNum, + payload: { + unitId: 1, + radio: "AN/PRC-152", + type: "SW", + action: "Start", + channel: 1, + additional: false, + frequency: 69.9, + code: "", + ...over, + }, + }; + } + + it("folds a Start and its Stop into one transmission", () => { + const result = pairTransmissions([tx(10, {}), tx(14, { action: "Stop" })]); + + expect(result).toHaveLength(1); + expect(result[0].startFrame).toBe(10); + expect(result[0].endFrame).toBe(14); + expect(result[0].openEnded).toBe(false); + }); + + it("caps a Start whose Stop never arrived rather than dropping it", () => { + const result = pairTransmissions([tx(10, {})]); + + expect(result).toHaveLength(1); + expect(result[0].endFrame).toBe(10 + TRANSMISSION_MAX_FRAMES); + expect(result[0].openEnded).toBe(true); + }); + + it("ignores a Stop for a transmission that began before the recording", () => { + expect(pairTransmissions([tx(10, { action: "Stop" })])).toEqual([]); + }); + + it("pairs per net, so keying a second radio does not close the first", () => { + const result = pairTransmissions([ + tx(10, { frequency: 69.9 }), + tx(12, { frequency: 40.5 }), + tx(14, { action: "Stop", frequency: 69.9 }), + tx(20, { action: "Stop", frequency: 40.5 }), + ]); + + expect(result).toHaveLength(2); + const company = result.find((r) => r.frequency === 69.9)!; + const squad = result.find((r) => r.frequency === 40.5)!; + expect(company.endFrame).toBe(14); + expect(squad.endFrame).toBe(20); + }); + + it("closes an unreleased transmission when the same net is keyed again", () => { + const result = pairTransmissions([tx(10, {}), tx(20, {})]); + + expect(result).toHaveLength(2); + expect(result[0].endFrame).toBe(20); + expect(result[1].startFrame).toBe(20); + }); + + it("keeps two speakers on one net apart", () => { + const result = pairTransmissions([ + tx(10, { unitId: 1 }), + tx(11, { unitId: 2 }), + tx(14, { unitId: 1, action: "Stop" }), + tx(15, { unitId: 2, action: "Stop" }), + ]); + + expect(result.map((r) => [r.unitId, r.startFrame, r.endFrame])).toEqual([ + [1, 10, 14], + [2, 11, 15], + ]); + }); +}); + +describe("transmissionsAtFrame", () => { + it("returns what is live at the playhead, inclusive of both ends", () => { + const all = pairTransmissions([ + { frameNum: 10, payload: { unitId: 1, radio: "r", type: "SW", action: "Start", channel: 1, additional: false, frequency: 69.9, code: "" } }, + { frameNum: 14, payload: { unitId: 1, radio: "r", type: "SW", action: "Stop", channel: 1, additional: false, frequency: 69.9, code: "" } }, + ]); + + expect(transmissionsAtFrame(all, 9)).toEqual([]); + expect(transmissionsAtFrame(all, 10)).toHaveLength(1); + expect(transmissionsAtFrame(all, 14)).toHaveLength(1); + expect(transmissionsAtFrame(all, 15)).toEqual([]); + }); +}); diff --git a/ui/src/playback/__tests__/deathHeatmap.test.ts b/ui/src/playback/__tests__/deathHeatmap.test.ts new file mode 100644 index 000000000..d7f2b780e --- /dev/null +++ b/ui/src/playback/__tests__/deathHeatmap.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from "vitest"; + +import { + binDeathSites, + busiestCell, + deathBlobAlpha, + deathBlobRadius, + type DeathSite, +} from "../deathHeatmap"; + +const CELL = 60; + +/** Sites arrive sorted by frame, which is what the scan relies on. */ +function sites(...entries: [number, number, number][]): DeathSite[] { + return entries + .map(([frameNum, x, y]) => ({ frameNum, x, y })) + .sort((a, b) => a.frameNum - b.frameNum); +} + +describe("binDeathSites", () => { + it("collapses deaths inside one cell into a single blob", () => { + const cells = binDeathSites(sites([1, 10, 10], [2, 20, 20], [3, 30, 30]), 100, CELL); + + expect(cells.size).toBe(1); + expect([...cells.values()][0].count).toBe(3); + }); + + it("keeps deaths in different cells apart", () => { + const cells = binDeathSites(sites([1, 10, 10], [2, 500, 500]), 100, CELL); + expect(cells.size).toBe(2); + }); + + it("puts the blob on the mean of its deaths, not the cell corner", () => { + // Both inside the 0..60 cell. The mean is (20, 20); the corner is (0, 0). + const cells = binDeathSites(sites([1, 10, 10], [2, 30, 30]), 100, CELL); + const cell = [...cells.values()][0]; + + expect(cell.x).toBeCloseTo(20, 6); + expect(cell.y).toBeCloseTo(20, 6); + }); + + it("keeps the running mean exact across many deaths", () => { + // The incremental mean must not drift from the plain average. + const entries: [number, number, number][] = []; + for (let i = 0; i < 20; i++) entries.push([i, i, i * 2]); + const cells = binDeathSites(sites(...entries), 100, 1000); + const cell = [...cells.values()][0]; + + expect(cell.x).toBeCloseTo(9.5, 6); + expect(cell.y).toBeCloseTo(19, 6); + }); + + // ── Bound to the playhead ── + + it("counts only deaths at or before the playhead", () => { + const all = sites([10, 5, 5], [20, 5, 5], [30, 5, 5]); + + expect([...binDeathSites(all, 9, CELL).values()]).toEqual([]); + expect([...binDeathSites(all, 10, CELL).values()][0].count).toBe(1); + expect([...binDeathSites(all, 25, CELL).values()][0].count).toBe(2); + expect([...binDeathSites(all, 999, CELL).values()][0].count).toBe(3); + }); + + it("stops at the first death past the playhead rather than scanning on", () => { + // The early break is what keeps this cheap; a site after the break must not + // be counted even though it would fall in the same cell. + const all = sites([1, 5, 5], [500, 5, 5]); + expect([...binDeathSites(all, 100, CELL).values()][0].count).toBe(1); + }); + + it("returns nothing for a recording with no deaths", () => { + expect(binDeathSites([], 100, CELL).size).toBe(0); + }); + + it("handles negative coordinates without merging opposite cells", () => { + // Math.floor, not truncation: -10 and 10 are different cells. + const cells = binDeathSites(sites([1, -10, -10], [2, 10, 10]), 100, CELL); + expect(cells.size).toBe(2); + }); + + it("refuses a nonsensical cell size rather than dividing by zero", () => { + expect(binDeathSites(sites([1, 10, 10]), 100, 0).size).toBe(0); + expect(binDeathSites(sites([1, 10, 10]), 100, Number.NaN).size).toBe(0); + }); +}); + +describe("busiestCell", () => { + it("returns the largest count", () => { + expect(busiestCell([{ x: 0, y: 0, count: 2 }, { x: 0, y: 0, count: 7 }])).toBe(7); + }); + + it("never returns zero, so it is safe to divide by", () => { + expect(busiestCell([])).toBe(1); + expect(busiestCell([{ x: 0, y: 0, count: 0 }])).toBe(1); + }); +}); + +describe("deathBlobRadius", () => { + it("scales area rather than radius with the count", () => { + // Four times the deaths should be twice as wide, once the floor is removed. + const one = deathBlobRadius(1, 4, CELL) - 0.45 * CELL; + const four = deathBlobRadius(4, 4, CELL) - 0.45 * CELL; + expect(four / one).toBeCloseTo(2, 6); + }); + + it("keeps a lone death visible", () => { + expect(deathBlobRadius(1, 100, CELL)).toBeGreaterThan(0.4 * CELL); + }); + + it("caps the busiest cell at the full cell size", () => { + expect(deathBlobRadius(9, 9, CELL)).toBeCloseTo(CELL, 6); + }); +}); + +describe("deathBlobAlpha", () => { + it("is faintest for an isolated death and strongest in the worst cell", () => { + expect(deathBlobAlpha(1, 100)).toBeLessThan(deathBlobAlpha(100, 100)); + }); + + it("stays within a sane opacity range", () => { + for (const [count, busiest] of [[0, 1], [1, 1], [1, 500], [500, 500]]) { + const alpha = deathBlobAlpha(count, busiest); + expect(alpha).toBeGreaterThanOrEqual(0.12); + expect(alpha).toBeLessThanOrEqual(0.4); + } + }); +}); diff --git a/ui/src/playback/__tests__/engine.test.ts b/ui/src/playback/__tests__/engine.test.ts index 893c2d1f0..6f7d4dc16 100644 --- a/ui/src/playback/__tests__/engine.test.ts +++ b/ui/src/playback/__tests__/engine.test.ts @@ -90,6 +90,132 @@ describe("PlaybackEngine", () => { vi.useRealTimers(); }); + // ─── Historical reads (trails, ORBAT, vehicle strips, distance) ─── + // + // These two are the seam every whole-mission feature sits on, and the failure + // they guard against is silent: code written against `entity.positions` works + // on a JSON recording and returns nothing on a chunked one, which reads as + // "this player never moved" rather than as an error. + + describe("getStateAt", () => { + it("reads per-frame state out of the chunk covering the frame", () => { + const cm = makeMockChunkManager( + makeChunkData( + new Map([ + [ + 1, + [ + { position: [10, 10], direction: 0, alive: 1, groupName: "Alpha", role: "Leader" }, + { position: [20, 20], direction: 90, alive: 1, groupName: "Bravo", role: "Leader" }, + ] as never, + ], + ]), + ), + ); + engine.loadRecording( + makeManifest({ entities: [makeEntityDef({ id: 1 })] }), + cm, + ); + + // Fields EntitySnapshot drops are exactly why this method exists. + expect(engine.getStateAt(1, 0)?.groupName).toBe("Alpha"); + expect(engine.getStateAt(1, 1)?.groupName).toBe("Bravo"); + expect(engine.getStateAt(1, 1)?.position).toEqual([20, 20]); + }); + + it("falls back to the entity's own positions for a JSON recording", () => { + // No chunk manager at all: everything came down in one file. + engine.loadRecording( + makeManifest({ + entities: [ + makeEntityDef({ + id: 1, + positions: [ + { position: [5, 5], direction: 0, alive: 1, groupName: "Alpha" }, + { position: [6, 6], direction: 0, alive: 1, groupName: "Alpha" }, + ], + } as Partial), + ], + }), + ); + + expect(engine.getStateAt(1, 1)?.position).toEqual([6, 6]); + expect(engine.getStateAt(1, 1)?.groupName).toBe("Alpha"); + }); + + it("returns null outside the entity's lifespan rather than clamping", () => { + engine.loadRecording( + makeManifest({ + entities: [ + makeEntityDef({ + id: 1, + startFrame: 10, + endFrame: 20, + positions: [{ position: [5, 5], direction: 0, alive: 1 }], + } as Partial), + ], + }), + ); + + expect(engine.getStateAt(1, 5)).toBeNull(); + expect(engine.getStateAt(1, 25)).toBeNull(); + }); + + it("returns null for an unknown entity", () => { + engine.loadRecording(makeManifest()); + expect(engine.getStateAt(999, 0)).toBeNull(); + }); + + it("returns null for a frame whose chunk is not loaded, so callers see a gap", () => { + // getChunkForFrame returning null is what an unloaded chunk looks like. + const cm = makeMockChunkManager(null); + engine.loadRecording(makeManifest({ entities: [makeEntityDef({ id: 1 })] }), cm); + + expect(engine.getStateAt(1, 50)).toBeNull(); + }); + + it("getPositionAt is the same lookup narrowed to the position", () => { + engine.loadRecording( + makeManifest({ + entities: [ + makeEntityDef({ + id: 1, + positions: [{ position: [7, 8], direction: 0, alive: 1 }], + } as Partial), + ], + }), + ); + + expect(engine.getPositionAt(1, 0)).toEqual([7, 8]); + expect(engine.getPositionAt(1, 999)).toBeNull(); + }); + }); + + describe("ensureAllChunks", () => { + it("loads every chunk of a chunked recording exactly once each", async () => { + const cm = makeMockChunkManager(); + (cm.loadChunk as ReturnType).mockResolvedValue(undefined); + engine.loadRecording(makeManifest({ chunkCount: 4 }), cm); + + await engine.ensureAllChunks(); + + expect(cm.loadChunk).toHaveBeenCalledTimes(4); + expect((cm.loadChunk as ReturnType).mock.calls.map((c) => c[0])).toEqual([ + 0, 1, 2, 3, + ]); + }); + + it("resolves immediately for a JSON recording, fetching nothing", async () => { + engine.loadRecording(makeManifest({ chunkCount: 0 })); + await expect(engine.ensureAllChunks()).resolves.toBeUndefined(); + }); + + it("resolves when a recording has no chunk manager even if the manifest claims chunks", async () => { + engine.loadRecording(makeManifest({ chunkCount: 5 })); + await expect(engine.ensureAllChunks()).resolves.toBeUndefined(); + }); + }); + // ─── Player snapshot sidecars ─── describe("ensurePlayerSnapshots", () => { diff --git a/ui/src/playback/__tests__/eventManager.test.ts b/ui/src/playback/__tests__/eventManager.test.ts index 912ad0509..18c22d6d6 100644 --- a/ui/src/playback/__tests__/eventManager.test.ts +++ b/ui/src/playback/__tests__/eventManager.test.ts @@ -7,6 +7,7 @@ import { getCounterStateAtFrame, type CounterState, } from "../events/counterEvent"; +import { ExplosionEvent, RadioTransmissionEvent } from "../events/supportEvents"; import { EventManager } from "../eventManager"; import { EntityManager } from "../entityManager"; import { Unit } from "../entities/unit"; @@ -876,4 +877,102 @@ describe("EventManager", () => { expect(mgr.getEventsAtFrame(10)).toEqual([]); }); }); + // ── Explosions and transmissions ── + // + // Both are indexed into their own array as well as the main event log, and + // both are ordered lazily on read rather than sorted on every insert: a mortar + // mission writes one explosion per round and a talkative operation keys up + // thousands of times, so a full sort per arrival is quadratic. That makes the + // dirty-flag behaviour worth pinning down -- a read that leaves the flag set, + // or an insert that fails to clear it, is a silently mis-ordered overlay. + + const blast = (frameNum: number): ExplosionEvent => + new ExplosionEvent(frameNum, frameNum, { + x: 0, y: 0, ammo: "Sh_155mm_AMOS", name: "155mm HE", + radius: 20, power: 100, firerId: -1, vehicleId: -1, + side: "WEST", source: "projectile", + }); + + const keyUp = (frameNum: number): RadioTransmissionEvent => + new RadioTransmissionEvent(frameNum, frameNum, { + unitId: 1, radio: "AN/PRC-152", type: "SW", action: "Start", + channel: 1, additional: false, frequency: 69.9, code: "", + }); + + describe("explosion and transmission ordering", () => { + it("returns explosions oldest first even when they arrive out of order", () => { + [blast(30), blast(10), blast(20)].forEach((e) => mgr.addEvent(e)); + expect(mgr.getExplosions().map((e) => e.frameNum)).toEqual([10, 20, 30]); + }); + + it("returns transmissions oldest first even when they arrive out of order", () => { + [keyUp(30), keyUp(10), keyUp(20)].forEach((e) => mgr.addEvent(e)); + expect(mgr.getRadioTransmissions().map((e) => e.frameNum)).toEqual([10, 20, 30]); + }); + + it("re-orders after an event arrives following an earlier read", () => { + // The regression a lazy sort invites: the first read marks the array + // clean, and a later out-of-order insert has to mark it dirty again. + mgr.addEvent(blast(20)); + expect(mgr.getExplosions().map((e) => e.frameNum)).toEqual([20]); + + mgr.addEvent(blast(5)); + expect(mgr.getExplosions().map((e) => e.frameNum)).toEqual([5, 20]); + + mgr.addEvent(keyUp(20)); + expect(mgr.getRadioTransmissions().map((e) => e.frameNum)).toEqual([20]); + mgr.addEvent(keyUp(5)); + expect(mgr.getRadioTransmissions().map((e) => e.frameNum)).toEqual([5, 20]); + }); + + it("stays ordered across repeated reads with no inserts between", () => { + [blast(30), blast(10)].forEach((e) => mgr.addEvent(e)); + expect(mgr.getExplosions().map((e) => e.frameNum)).toEqual([10, 30]); + expect(mgr.getExplosions().map((e) => e.frameNum)).toEqual([10, 30]); + }); + + it("keeps already-ordered arrivals in order", () => { + [blast(1), blast(2), blast(3)].forEach((e) => mgr.addEvent(e)); + expect(mgr.getExplosions().map((e) => e.frameNum)).toEqual([1, 2, 3]); + }); + + it("still lists both kinds in the main event log", () => { + // Neither indexer returns early, because both belong in the event feed as + // well as their own overlay. + const e = blast(10); + const t = keyUp(11); + mgr.addEvent(e); + mgr.addEvent(t); + expect(mgr.getAll()).toEqual([e, t]); + expect(mgr.getEventsAtFrame(10)).toEqual([e]); + }); + + it("returns only blasts whose lifetime covers the frame, oldest first", () => { + [blast(30), blast(10), blast(20)].forEach((e) => mgr.addEvent(e)); + + // Lifetime 5: a blast is live for frames [f, f+5). + expect(mgr.getActiveExplosions(22, 5).map((e) => e.frameNum)).toEqual([20]); + expect(mgr.getActiveExplosions(10, 5).map((e) => e.frameNum)).toEqual([10]); + expect(mgr.getActiveExplosions(15, 5)).toEqual([]); + // A wide lifetime catches every blast already past, still ordered. + expect(mgr.getActiveExplosions(30, 100).map((e) => e.frameNum)).toEqual([10, 20, 30]); + }); + + it("does not treat a blast as live before it happens", () => { + mgr.addEvent(blast(20)); + expect(mgr.getActiveExplosions(19, 10)).toEqual([]); + }); + + it("clear empties both indexes and leaves them ordered for the next recording", () => { + [blast(30), blast(10), keyUp(30), keyUp(10)].forEach((e) => mgr.addEvent(e)); + mgr.clear(); + + expect(mgr.getExplosions()).toEqual([]); + expect(mgr.getRadioTransmissions()).toEqual([]); + + mgr.addEvent(blast(7)); + mgr.addEvent(blast(3)); + expect(mgr.getExplosions().map((e) => e.frameNum)).toEqual([3, 7]); + }); + }); }); diff --git a/ui/src/playback/__tests__/explosions.test.ts b/ui/src/playback/__tests__/explosions.test.ts new file mode 100644 index 000000000..1027151d0 --- /dev/null +++ b/ui/src/playback/__tests__/explosions.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from "vitest"; + +import { + EXPLOSION_MAP_SECONDS, + EXPLOSION_PEAK_ALPHA, + explosionAlpha, + explosionColor, + explosionLifetimeFrames, +} from "../explosions"; +import { EventManager } from "../eventManager"; +import { ExplosionEvent } from "../events/supportEvents"; +import type { ExplosionPayload } from "../../data/types"; + +function payload(over: Partial = {}): ExplosionPayload { + return { + x: 100, + y: 200, + ammo: "Sh_155mm_AMOS", + name: "155mm HE", + radius: 28, + power: 180, + firerId: 7, + vehicleId: -1, + side: "WEST", + source: "projectile", + ...over, + }; +} + +describe("explosionLifetimeFrames", () => { + it("derives the lifetime from the recording's capture delay", () => { + expect(explosionLifetimeFrames(1000)).toBe(EXPLOSION_MAP_SECONDS); + // A 2 s capture delay halves the number of frames the same wall-clock + // lifetime spans. + expect(explosionLifetimeFrames(2000)).toBe(EXPLOSION_MAP_SECONDS / 2); + }); + + it("falls back to seconds when the capture delay is missing or nonsense", () => { + expect(explosionLifetimeFrames(0)).toBe(EXPLOSION_MAP_SECONDS); + expect(explosionLifetimeFrames(Number.NaN)).toBe(EXPLOSION_MAP_SECONDS); + }); + + it("never returns zero, which would make every blast invisible", () => { + expect(explosionLifetimeFrames(600000)).toBeGreaterThanOrEqual(1); + }); +}); + +describe("explosionAlpha", () => { + const lifetime = explosionLifetimeFrames(1000); + + it("is at full strength on the frame the blast happens", () => { + expect(explosionAlpha(100, 100, lifetime)).toBe(EXPLOSION_PEAK_ALPHA); + }); + + it("is nothing before the blast and after its lifetime", () => { + expect(explosionAlpha(99, 100, lifetime)).toBe(0); + expect(explosionAlpha(100 + lifetime, 100, lifetime)).toBe(0); + }); + + it("fades monotonically over the tail", () => { + let previous = Number.POSITIVE_INFINITY; + for (let age = 0; age < lifetime; age += 1) { + const alpha = explosionAlpha(100 + age, 100, lifetime); + expect(alpha).toBeLessThanOrEqual(previous); + previous = alpha; + } + expect(previous).toBeLessThan(EXPLOSION_PEAK_ALPHA); + }); +}); + +describe("explosionColor", () => { + it("grades by blast size so a satchel does not read like a grenade", () => { + const grenade = explosionColor(5); + const rocket = explosionColor(12); + const shell = explosionColor(28); + expect(new Set([grenade, rocket, shell]).size).toBe(3); + }); + + it("returns a bare hex without the leading hash, as the marker route expects", () => { + expect(explosionColor(28)).toMatch(/^[0-9a-f]{6}$/); + expect(explosionColor(Number.NaN)).toMatch(/^[0-9a-f]{6}$/); + }); +}); + +describe("EventManager explosion index", () => { + it("returns only the blasts whose lifetime covers the frame", () => { + const manager = new EventManager(); + manager.addEvent(new ExplosionEvent(10, 1, payload())); + manager.addEvent(new ExplosionEvent(20, 2, payload({ x: 500 }))); + + expect(manager.getActiveExplosions(10, 6).map((e) => e.id)).toEqual([1]); + // Still inside the first blast's six frames, and the second has not happened. + expect(manager.getActiveExplosions(15, 6).map((e) => e.id)).toEqual([1]); + // The first has expired by 16; the second is drawn from 20. + expect(manager.getActiveExplosions(16, 6)).toEqual([]); + expect(manager.getActiveExplosions(21, 6).map((e) => e.id)).toEqual([2]); + }); + + it("keeps blasts in the event log as well as the map index", () => { + const manager = new EventManager(); + const blast = new ExplosionEvent(10, 1, payload()); + manager.addEvent(blast); + + // Unlike the Zeus camera and entity events, an explosion is a readable + // mission event and must survive into the feed. + expect(manager.getAll()).toContain(blast); + expect(manager.getEventsAtFrame(10)).toContain(blast); + expect(manager.getExplosions()).toEqual([blast]); + }); + + it("clears the index with the rest of the events", () => { + const manager = new EventManager(); + manager.addEvent(new ExplosionEvent(10, 1, payload())); + manager.clear(); + + expect(manager.getExplosions()).toEqual([]); + expect(manager.getActiveExplosions(10, 6)).toEqual([]); + }); +}); diff --git a/ui/src/playback/comms.ts b/ui/src/playback/comms.ts new file mode 100644 index 000000000..c36165e5e --- /dev/null +++ b/ui/src/playback/comms.ts @@ -0,0 +1,341 @@ +import type { RadioSnapshotEntry, RadioTransmissionPayload } from "../data/types"; +import type { TfarRadioPropagation, AcreRadioPropagation } from "../data/radioPropagation"; +import type { DemGrid } from "./radioRange/demGrid"; +import { inTfarRange } from "./radioRange/tfarCoverage"; +import { inAcreRange } from "./radioRange/acreCoverage"; + +/** + * How long a transmission with no matching Stop stays open, in frames. A client + * that disconnects mid-word, or a Stop lost because the recording ended, would + * otherwise leave a transmission running to the end of the mission. + */ +export const TRANSMISSION_MAX_FRAMES = 60; + +/** + * Fallback ranges, in metres, for a transmitter whose radio never made it into + * a snapshot. These are the addon's own defaults + * (`OCAP_settings_radioShortRangeMeters` / `radioLongRangeMeters`), so a + * recording with radio tracking switched off still yields a usable estimate + * instead of nothing. + */ +export const FALLBACK_RANGE_SW = 5000; +export const FALLBACK_RANGE_LR = 20000; + +/** + * Decimals the net key rounds to. + * + * Three, set by the finer of the two mods rather than by taste: + * + * - TFAR quantises every frequency itself. `TFAR_FREQ_ROUND_POWER` is 10, so a + * frequency is `round(f * 10) / 10` — one decimal — and its string form drops + * the decimal entirely when the value is whole ("100", "69.9"). SW runs + * 30–512 MHz and LR 30–87 MHz. + * - ACRE keeps MHz with finer steps: the PRC-152 walks `(950 + 2i) * 0.0625` + * from 59.375, the PRC-343 and BF-888S step by 0.01. Three decimals covers + * the smallest of those exactly. + * + * Rounding also absorbs the float32 the join disagrees over. A transmission's + * frequency is stringified in SQF and stored as a float32, so 69.9 comes back as + * 69.90000152587891, while the snapshot carries the raw float 69.9. At these + * magnitudes float32 error is around 1e-5 — far too small to cross a 0.0005 + * boundary, so no TFAR or ACRE frequency can round to two different keys. + */ +const FREQ_DECIMALS = 3; + +/** + * Identity of a radio net: frequency plus encryption code. + * + * That pairing is not a guess — it is TFAR's own. `TFAR_fnc_sendSpeakerRadios` + * hands the TeamSpeak plugin `format ["%1%2", , ]` as the + * net a radio listens on, and builds it identically for SW and LR sets. So the + * key deliberately does not include SW/LR: a backpack LR on 69.9 MHz and a + * handheld SW on 69.9 MHz are one net in TFAR and are one net here. Their bands + * overlap in 30–87 MHz, so this is reachable in practice, not just in theory. + */ +export function netKey(frequency: number, code: string): string { + if (!Number.isFinite(frequency)) return `?|${code ?? ""}`; + return `${frequency.toFixed(FREQ_DECIMALS)}|${code ?? ""}`; +} + +/** + * A frequency written the way the mod writes it: trailing zeros trimmed, so a + * TFAR net reads "100" and "69.9" exactly as it does in game, and an ACRE one + * keeps the decimals it actually uses ("59.375"). + */ +export function formatFrequency(frequency: number): string { + if (!Number.isFinite(frequency)) return "unknown"; + const fixed = frequency.toFixed(FREQ_DECIMALS); + return fixed.includes(".") ? fixed.replace(/0+$/, "").replace(/\.$/, "") : fixed; +} + +/** Human-readable net name: "69.9 MHz", with the code when one is set. */ +export function formatNet(frequency: number, code: string): string { + const freq = Number.isFinite(frequency) ? `${formatFrequency(frequency)} MHz` : "unknown"; + return code ? `${freq} · ${code}` : freq; +} + +/** One player's radios at a frame, as the caller reads them off the snapshot. */ +export interface CommsUnitRadios { + unitId: number; + name: string; + side: string; + radios: RadioSnapshotEntry[]; +} + +export interface NetMember { + unitId: number; + name: string; + side: string; + /** The radio this member is on the net with. */ + entry: RadioSnapshotEntry; + /** True when this is a monitored additional channel rather than the selected one. */ + monitoring: boolean; +} + +export interface NetState { + key: string; + frequency: number; + code: string; + /** Mods seen on this net, normally one. */ + mods: string[]; + members: NetMember[]; +} + +/** + * Group every radio every unit can hear into nets. + * + * A unit appears once per net it is on, so a rifleman monitoring the company + * net on his additional channel while working the squad net is a member of + * both — which is exactly the question "who could have heard this" needs. + * Membership is by what the radio is *tuned to*, not by who spoke. + */ +export function buildNets(units: CommsUnitRadios[]): NetState[] { + const nets = new Map(); + + for (const unit of units) { + for (const entry of unit.radios) { + if (!Number.isFinite(entry.frequency)) continue; + const key = netKey(entry.frequency, entry.code); + + let net = nets.get(key); + if (!net) { + net = { key, frequency: entry.frequency, code: entry.code ?? "", mods: [], members: [] }; + nets.set(key, net); + } + if (entry.mod && !net.mods.includes(entry.mod)) net.mods.push(entry.mod); + + // One entry per unit per net: a unit carrying two radios tuned to the + // same net is still one listener, and the better radio is the one that + // decides whether it hears anything. + const existing = net.members.findIndex((m) => m.unitId === unit.unitId); + const member: NetMember = { + unitId: unit.unitId, + name: unit.name, + side: unit.side, + entry, + monitoring: entry.additional === true, + }; + if (existing === -1) { + net.members.push(member); + } else if ((net.members[existing].entry.rangeMeters ?? 0) < (entry.rangeMeters ?? 0)) { + net.members[existing] = member; + } + } + } + + return [...nets.values()].sort((a, b) => a.frequency - b.frequency || a.code.localeCompare(b.code)); +} + +/** + * The transmitter's own radio for this transmission, out of that player's + * snapshot. The transmission names a net but carries no range, and the range is + * what decides who heard it. + * + * Frequency is the discriminator; the SW/LR type only breaks ties, because a + * player with a handheld and a backpack on the same net has two entries and the + * one they keyed is the one whose type the transmission reports. + */ +export function matchTransmissionRadio( + entries: RadioSnapshotEntry[], + transmission: Pick, +): RadioSnapshotEntry | null { + const key = netKey(transmission.frequency, transmission.code); + const onNet = entries.filter((entry) => netKey(entry.frequency, entry.code) === key); + if (onNet.length === 0) return null; + + return ( + onNet.find((entry) => entry.type === transmission.type && entry.active) ?? + onNet.find((entry) => entry.type === transmission.type) ?? + onNet.find((entry) => entry.active) ?? + onNet[0] + ); +} + +/** Range to fall back on when the transmitter has no snapshot to join against. */ +export function fallbackRange(type: string): number { + return type === "LR" ? FALLBACK_RANGE_LR : FALLBACK_RANGE_SW; +} + +export interface ReachabilityPeer { + unitId: number; + name: string; + side: string; + position: [number, number, number]; + monitoring: boolean; +} + +export interface ReachabilityResult { + unitId: number; + name: string; + side: string; + /** Straight-line distance in metres. */ + distance: number; + inRange: boolean; + monitoring: boolean; +} + +export interface ReachabilityInput { + origin: [number, number, number]; + peers: ReachabilityPeer[]; + /** Range of the *transmitting* radio: the receiver's own set does not extend it. */ + rangeMeters: number; + /** "ACRE" picks the diffraction model, anything else picks TFAR's. */ + mod: string; + grid: DemGrid | null; + tfar: TfarRadioPropagation; + acre: AcreRadioPropagation; + frequencyMHz?: number; +} + +/** + * Who, on this net, was close enough to hear it. + * + * With a DEM loaded this is the same terrain-aware test the coverage overlay + * draws with, so a ridge between two units puts them out of contact exactly + * where the overlay says it does. Without one it degrades to straight-line + * distance, which is what the "simple range" mode already shows. + * + * What it cannot know: whether the receiver's radio was switched on, their + * volume, or whether they were listening. This is reachability, not attention. + */ +export function reachability(input: ReachabilityInput): ReachabilityResult[] { + const [ox, oy, oz] = input.origin; + const isAcre = input.mod === "ACRE"; + + return input.peers + .map((peer) => { + const [px, py, pz] = peer.position; + const distance = Math.hypot(px - ox, py - oy, pz - oz); + + let inRange: boolean; + if (!input.grid) { + inRange = distance <= input.rangeMeters; + } else if (isAcre) { + inRange = inAcreRange( + input.grid, ox, oy, oz, px, py, pz, + input.rangeMeters, input.acre, input.frequencyMHz, + ); + } else { + inRange = inTfarRange( + input.grid, ox, oy, oz, px, py, pz, + input.rangeMeters, input.tfar, + ); + } + + return { + unitId: peer.unitId, + name: peer.name, + side: peer.side, + distance, + inRange, + monitoring: peer.monitoring, + }; + }) + .sort((a, b) => a.distance - b.distance); +} + +/** A keyed-up period, folded from the Start and Stop pair. */ +export interface Transmission { + unitId: number; + radio: string; + type: string; + channel: number; + additional: boolean; + frequency: number; + code: string; + netKey: string; + startFrame: number; + /** Frame of the matching Stop, or the capped end when none arrived. */ + endFrame: number; + /** True when no Stop was recorded and the end is the cap, not a real release. */ + openEnded: boolean; +} + +interface RawTransmission { + frameNum: number; + payload: RadioTransmissionPayload; +} + +/** + * Fold Start/Stop pairs into transmissions. + * + * Pairing is per unit *and* per net, because a player can key a second radio + * before releasing the first, and a Stop must not close whichever transmission + * happens to be open. An unmatched Start is capped at TRANSMISSION_MAX_FRAMES + * rather than dropped — someone did speak — and an unmatched Stop is ignored, + * since it describes a transmission that began before the recording did. + */ +export function pairTransmissions(events: RawTransmission[]): Transmission[] { + const open = new Map(); + const done: Transmission[] = []; + + const ordered = [...events].sort((a, b) => a.frameNum - b.frameNum); + + for (const event of ordered) { + const p = event.payload; + const key = netKey(p.frequency, p.code); + const pairKey = `${p.unitId}|${key}`; + + if (p.action === "Stop") { + const started = open.get(pairKey); + if (!started) continue; + started.endFrame = event.frameNum; + started.openEnded = false; + open.delete(pairKey); + done.push(started); + continue; + } + + // A second Start without a Stop closes the first at this frame: the player + // is demonstrably still talking, and leaving both open would double-count. + const previous = open.get(pairKey); + if (previous) { + previous.endFrame = event.frameNum; + open.delete(pairKey); + done.push(previous); + } + + open.set(pairKey, { + unitId: p.unitId, + radio: p.radio, + type: p.type, + channel: p.channel, + additional: p.additional, + frequency: p.frequency, + code: p.code ?? "", + netKey: key, + startFrame: event.frameNum, + endFrame: event.frameNum + TRANSMISSION_MAX_FRAMES, + openEnded: true, + }); + } + + for (const started of open.values()) done.push(started); + + return done.sort((a, b) => a.startFrame - b.startFrame); +} + +/** Transmissions live at `frame`, oldest first. */ +export function transmissionsAtFrame(all: Transmission[], frame: number): Transmission[] { + return all.filter((t) => t.startFrame <= frame && frame <= t.endFrame); +} diff --git a/ui/src/playback/deathHeatmap.ts b/ui/src/playback/deathHeatmap.ts new file mode 100644 index 000000000..a94c240f1 --- /dev/null +++ b/ui/src/playback/deathHeatmap.ts @@ -0,0 +1,93 @@ +/** + * Death heatmap binning. + * + * Deaths are collapsed onto a coarse grid so that a dozen kills inside one + * building read as a single hot blob rather than a dozen overlapping circles. + * + * Kept pure and separate from the render bridge for two reasons: the grid maths + * is the part worth pinning down in tests, and the site list it works from has + * to be resolved once per recording rather than rebuilt on every frame. Reading + * a victim's position goes through the chunk covering the frame they died on, + * which is a lookup the playhead effect must not be doing hundreds of times a + * frame. + */ + +/** One death, already resolved to a map position. */ +export interface DeathSite { + frameNum: number; + x: number; + y: number; +} + +/** Deaths sharing one grid cell, collapsed to a single blob. */ +export interface DeathCell { + x: number; + y: number; + count: number; +} + +/** + * Bin every death up to `frame` onto a `cellMeters` grid. + * + * `sites` must be sorted by frame: the scan stops at the first death past the + * playhead rather than filtering the whole list, which is what keeps this cheap + * on a long recording where the viewer is still near the start. + * + * A cell's position is the running mean of the deaths in it, so the blob sits on + * the bodies rather than on the corner of an arbitrary grid square. + */ +export function binDeathSites( + sites: readonly DeathSite[], + frame: number, + cellMeters: number, +): Map { + const cells = new Map(); + if (!Number.isFinite(cellMeters) || cellMeters <= 0) return cells; + + for (const site of sites) { + if (site.frameNum > frame) break; + + const cx = Math.floor(site.x / cellMeters); + const cy = Math.floor(site.y / cellMeters); + const key = `${cx}|${cy}`; + + const cell = cells.get(key); + if (cell) { + cell.x += (site.x - cell.x) / (cell.count + 1); + cell.y += (site.y - cell.y) / (cell.count + 1); + cell.count += 1; + } else { + cells.set(key, { x: site.x, y: site.y, count: 1 }); + } + } + + return cells; +} + +/** The count in the busiest cell, and never below 1 so it is safe to divide by. */ +export function busiestCell(cells: Iterable): number { + let busiest = 1; + for (const cell of cells) { + if (cell.count > busiest) busiest = cell.count; + } + return busiest; +} + +/** + * Blob radius in metres. + * + * Grows with the square root of the count so that *area*, not radius, tracks how + * many died there -- a cell with four deaths looks twice as wide as one with a + * single death, not four times. Floored well above zero so that a lone kill is + * still visible. + */ +export function deathBlobRadius(count: number, busiest: number, cellMeters: number): number { + const share = busiest > 0 ? count / busiest : 0; + return cellMeters * (0.45 + 0.55 * Math.sqrt(Math.max(0, share))); +} + +/** Blob opacity: faint for an isolated death, strongest in the worst cell. */ +export function deathBlobAlpha(count: number, busiest: number): number { + const share = busiest > 0 ? count / busiest : 0; + return 0.12 + 0.28 * Math.max(0, Math.min(1, share)); +} diff --git a/ui/src/playback/engine.ts b/ui/src/playback/engine.ts index d8c25be91..871a55950 100644 --- a/ui/src/playback/engine.ts +++ b/ui/src/playback/engine.ts @@ -1,6 +1,7 @@ import { createSignal, type Accessor } from "solid-js"; -import type { Manifest, EventDef, WorldConfig } from "../data/types"; +import type { EntityState, Manifest, EventDef, WorldConfig } from "../data/types"; +import type { ArmaCoord } from "../utils/coordinates"; import { resolveAcrePropagation, resolveTfarPropagation, @@ -29,6 +30,12 @@ import { ZeusPingEvent, ZeusRemoteControlEvent, } from "./events/zeusEvents"; +import { + ExplosionEvent, + RadioTransmissionEvent, + ServiceEvent, + StaticWeaponEvent, +} from "./events/supportEvents"; import { Unit } from "./entities/unit"; import { Vehicle } from "./entities/vehicle"; import { formatZeusOccupancyName } from "./zeus"; @@ -88,6 +95,14 @@ function createGameEvent(def: EventDef): GameEvent | null { return new ZeusCameraEvent(def.frameNum, id, def.payload); case "zeusPing": return new ZeusPingEvent(def.frameNum, id, def.payload); + case "explosion": + return new ExplosionEvent(def.frameNum, id, def.payload); + case "serviceEvent": + return new ServiceEvent(def.frameNum, id, def.payload); + case "staticWeapon": + return new StaticWeaponEvent(def.frameNum, id, def.payload); + case "radioTransmission": + return new RadioTransmissionEvent(def.frameNum, id, def.payload); default: return null; } @@ -464,6 +479,70 @@ export class PlaybackEngine { return load; } + /** + * Load every player's snapshot sidecar. + * + * The profile card reads one player at a time, which is why sidecars are + * lazy. A comms rollup is the opposite shape: it needs every player's radios + * at once to know who shared a net, so it pays the whole fan-out up front, + * once. A recording that keeps its snapshots in the manifest resolves + * immediately without a single request. + */ + ensureAllPlayerSnapshots(): Promise { + const unitIds = this.manifest?.snapshotUnitIds ?? []; + if (unitIds.length === 0) return Promise.resolve(); + return Promise.all(unitIds.map((unitId) => this.ensurePlayerSnapshots(unitId))).then(() => {}); + } + + /** + * Raw per-frame state of one entity, or null when that frame is not loaded. + * + * Resolves from the same two sources as the playback snapshot: the chunk + * covering the frame when the recording is chunked, and the entity's own dense + * position array when it is a JSON recording. Anything reading history rather + * than the playhead -- trails, distance totals, group membership over time -- + * has to come through here, because `entity.positions` is null for a chunked + * recording, and because EntitySnapshot drops the per-frame group and role + * this returns. + */ + getStateAt(entityId: number, frame: number): EntityState | null { + const entity = this.entityManager.getEntity(entityId); + if (!entity) return null; + if (frame < entity.startFrame || frame > entity.endFrame) return null; + + const chunkSize = this.manifest?.chunkSize || 300; + const chunk = this.chunkManager?.getChunkForFrame(frame); + if (chunk) { + const states = chunk.entities.get(entityId); + const state = states?.[frame - Math.floor(frame / chunkSize) * chunkSize]; + if (state) return state; + } + + return entity.positions?.[entity.getRelativeFrameIndex(frame)] ?? null; + } + + /** Position of one entity at an arbitrary frame, or null when not loaded. */ + getPositionAt(entityId: number, frame: number): ArmaCoord | null { + return this.getStateAt(entityId, frame)?.position ?? null; + } + + /** + * Every chunk of a chunked recording, loaded. + * + * Playback only ever needs the chunk under the playhead and its neighbour, so + * chunks are lazy. Whole-mission analysis is the opposite shape and has to pay + * for the lot once; a JSON recording already holds everything and resolves + * immediately. + */ + ensureAllChunks(): Promise { + const chunkManager = this.chunkManager; + const count = this.manifest?.chunkCount ?? 0; + if (!chunkManager || count <= 0) return Promise.resolve(); + const loads: Promise[] = []; + for (let i = 0; i < count; i++) loads.push(chunkManager.loadChunk(i)); + return Promise.all(loads).then(() => {}); + } + /** Whether a player has snapshots at all, before any sidecar is fetched. */ hasPlayerSnapshots(unitId: number): boolean { return ( diff --git a/ui/src/playback/eventManager.ts b/ui/src/playback/eventManager.ts index 21c2a4d5f..0bd085b4f 100644 --- a/ui/src/playback/eventManager.ts +++ b/ui/src/playback/eventManager.ts @@ -11,6 +11,7 @@ import { ZeusPingEvent, ZeusRemoteControlEvent, } from "./events/zeusEvents"; +import { ExplosionEvent, RadioTransmissionEvent } from "./events/supportEvents"; import { applySnapshotDiff, isSnapshotDiff } from "../data/snapshotDiff"; import type { PlayerSnapshotPayload, PlayerSnapshotType } from "../data/types"; import { ZEUS_RC_KILL_GRACE_FRAMES, type ZeusCameraSample, type ZeusEntityInfo, type ZeusFrameState } from "./zeus"; @@ -29,6 +30,15 @@ export class EventManager { private zeusRemoteControl: ZeusRemoteControlEvent[] = []; private zeusCameras = new Map(); private zeusPings: ZeusPingEvent[] = []; + private explosions: ExplosionEvent[] = []; + private transmissions: RadioTransmissionEvent[] = []; + + // Both arrays are ordered lazily, on the first read after an insert. Events + // normally arrive in frame order and the sort is then a no-op scan, but + // nothing guarantees it -- a sidecar or a late chunk can interleave -- so + // order is established on read instead of assumed. + private explosionsSorted = true; + private transmissionsSorted = true; /** Add an event and index it by frame number. */ addEvent(event: GameEvent): void { @@ -71,6 +81,23 @@ export class EventManager { this.zeusCameras.set(event.payload.curatorId, series); return; } + if (event instanceof RadioTransmissionEvent) { + // Deliberately no early return: a transmission is indexed for the comms + // rollup and still belongs in this.events so it shows in the event log. + // + // Sorted on read rather than here. Zeus pings below sort per insert, which + // is fine at a few dozen; a talkative operation keys up thousands of times + // and sorting the whole array once per arrival is quadratic. + this.transmissions.push(event); + this.transmissionsSorted = false; + } + if (event instanceof ExplosionEvent) { + // Deliberately no early return: a blast is indexed for the map overlay and + // still belongs in this.events so it shows in the event log. + // Sorted on read, as above: a mortar mission writes one per round. + this.explosions.push(event); + this.explosionsSorted = false; + } if (event instanceof ZeusPingEvent) { // Deliberately no early return: a ping is indexed for the map overlay and // still belongs in this.events so it shows in the event log. @@ -139,6 +166,11 @@ export class EventManager { } } + /** Players who have any snapshot indexed, in id order. */ + getPlayerSnapshotUnitIds(): number[] { + return [...this.playerSnapshots.keys()].sort((a, b) => a - b); + } + /** Whether any snapshot has been indexed for a player. */ hasPlayerSnapshots(unitId: number): boolean { return (this.playerSnapshots.get(unitId)?.size ?? 0) > 0; @@ -191,6 +223,37 @@ export class EventManager { ); } + /** + * Blasts whose map lifetime covers `frame`, oldest first. A blast is drawn + * from its own frame until `frameNum + lifetimeFrames`. + */ + getActiveExplosions(frame: number, lifetimeFrames: number): ExplosionEvent[] { + return this.getExplosions().filter( + (blast) => blast.frameNum <= frame && frame < blast.frameNum + lifetimeFrames, + ); + } + + /** + * Every push-to-talk in the recording, oldest first. Start and Stop are + * separate entries here; `pairTransmissions` folds them. + */ + getRadioTransmissions(): RadioTransmissionEvent[] { + if (!this.transmissionsSorted) { + this.transmissions.sort((a, b) => a.frameNum - b.frameNum); + this.transmissionsSorted = true; + } + return this.transmissions; + } + + /** Every explosion in the recording, oldest first. */ + getExplosions(): ExplosionEvent[] { + if (!this.explosionsSorted) { + this.explosions.sort((a, b) => a.frameNum - b.frameNum); + this.explosionsSorted = true; + } + return this.explosions; + } + /** Every ping in the recording, oldest first. */ getZeusPings(): ZeusPingEvent[] { return this.zeusPings; @@ -438,5 +501,9 @@ export class EventManager { this.zeusRemoteControl = []; this.zeusCameras = new Map(); this.zeusPings = []; + this.explosions = []; + this.transmissions = []; + this.explosionsSorted = true; + this.transmissionsSorted = true; } } diff --git a/ui/src/playback/events/supportEvents.ts b/ui/src/playback/events/supportEvents.ts new file mode 100644 index 000000000..abc5fd969 --- /dev/null +++ b/ui/src/playback/events/supportEvents.ts @@ -0,0 +1,95 @@ +import { GameEvent } from "./gameEvent"; +import type { + ExplosionPayload, + RadioTransmissionPayload, + ServiceEventPayload, + StaticWeaponPayload, +} from "../../data/types"; + +/** + * A detonation, drawn on the map as the area its blast covered and listed in the + * event log like any other mission event. + */ +export class ExplosionEvent extends GameEvent { + constructor( + frameNum: number, + id: number, + public readonly payload: ExplosionPayload, + ) { + super(frameNum, "explosion", id); + } + + get position(): [number, number] { + return [this.payload.x, this.payload.y]; + } + + /** Blast radius in metres, straight from the ammo's indirectHitRange. */ + get radius(): number { + return this.payload.radius; + } +} + +/** A vehicle was repaired, refuelled or rearmed. */ +export class ServiceEvent extends GameEvent { + constructor( + frameNum: number, + id: number, + public readonly payload: ServiceEventPayload, + ) { + super(frameNum, "serviceEvent", id); + } + + get position(): [number, number] { + return [this.payload.x, this.payload.y]; + } + + /** + * How much of the job this event covers, 0-1, or null when the kind has no + * meaningful span. A repair counts damage removed, a refuel fuel added. + */ + get progress(): number | null { + if (this.payload.kind === "rearm") return null; + return Math.abs(this.payload.to - this.payload.from); + } +} + +/** + * One push-to-talk. Start and Stop arrive as separate events; `comms.ts` folds + * the pair into a transmission with a duration. + */ +export class RadioTransmissionEvent extends GameEvent { + constructor( + frameNum: number, + id: number, + public readonly payload: RadioTransmissionPayload, + ) { + super(frameNum, "radioTransmission", id); + } + + get unitId(): number { + return this.payload.unitId; + } + + get isStart(): boolean { + return this.payload.action === "Start"; + } +} + +/** A static weapon was assembled from bags, or taken apart again. */ +export class StaticWeaponEvent extends GameEvent { + constructor( + frameNum: number, + id: number, + public readonly payload: StaticWeaponPayload, + ) { + super(frameNum, "staticWeapon", id); + } + + get position(): [number, number] { + return [this.payload.x, this.payload.y]; + } + + get assembled(): boolean { + return this.payload.action === "assembled"; + } +} diff --git a/ui/src/playback/explosions.ts b/ui/src/playback/explosions.ts new file mode 100644 index 000000000..e4d61e536 --- /dev/null +++ b/ui/src/playback/explosions.ts @@ -0,0 +1,59 @@ +/** + * How long a blast circle stays on the map, in seconds of playback time. Short + * on purpose: an explosion is an instant, and the circle is there to show where + * and how big, not to accumulate into a heat map. The event itself stays in the + * event log forever. + */ +export const EXPLOSION_MAP_SECONDS = 6; + +/** Seconds of that lifetime spent fading out. */ +export const EXPLOSION_FADE_SECONDS = 4; + +/** + * Fill opacity of a fresh blast circle. Matched to the renderer ceiling for a + * "Solid" brush, which clamps fill to min(0.3, alpha): anything higher would sit + * flat at the cap and only start fading in the last moments. + */ +export const EXPLOSION_PEAK_ALPHA = 0.3; + +/** + * The blast lifetime in frames. Derived rather than hardcoded, because + * captureDelayMs comes from the manifest and is not always 1000. + */ +export function explosionLifetimeFrames(captureDelayMs: number): number { + if (!Number.isFinite(captureDelayMs) || captureDelayMs <= 0) { + return EXPLOSION_MAP_SECONDS; + } + return Math.max(1, Math.round((EXPLOSION_MAP_SECONDS * 1000) / captureDelayMs)); +} + +/** + * Opacity for a blast circle at `frame`. Full strength on the frame it happens, + * then a linear ramp to nothing, so a barrage reads as a sequence rather than as + * one solid mass. + */ +export function explosionAlpha( + frame: number, + eventFrame: number, + lifetimeFrames: number, +): number { + const age = frame - eventFrame; + if (age < 0 || age >= lifetimeFrames) return 0; + + const fade = Math.max(1, Math.min(lifetimeFrames, Math.round(lifetimeFrames * (EXPLOSION_FADE_SECONDS / EXPLOSION_MAP_SECONDS)))); + const remaining = lifetimeFrames - age; + if (remaining >= fade) return EXPLOSION_PEAK_ALPHA; + return EXPLOSION_PEAK_ALPHA * (remaining / fade); +} + +/** + * Colour for a blast, as the bare hex the marker route expects. Graded by size + * so a satchel charge is not mistaken for a grenade at a glance: the scale runs + * from a warning amber for small blasts to red for anything artillery-sized. + */ +export function explosionColor(radius: number): string { + if (!Number.isFinite(radius)) return "ff9d3d"; + if (radius >= 20) return "ff2d2d"; + if (radius >= 10) return "ff5c2d"; + return "ff9d3d"; +} From de77fd3b801e3b57eb3cf9d374bff0c7eac536cf Mon Sep 17 00:00:00 2001 From: Yethe Samartaka Date: Mon, 7 Sep 2026 23:58:54 +0200 Subject: [PATCH 20/39] Compression improvements --- README.md | 147 ++++++++++ cmd/ocap-webserver/cli.go | 4 + go.mod | 1 + go.sum | 2 + internal/precompresscli/precompress.go | 258 ++++++++++++++++++ internal/precompresscli/precompress_test.go | 220 +++++++++++++++ internal/server/encoding.go | 119 ++++++++ internal/server/encoding_test.go | 145 ++++++++++ internal/server/handler.go | 84 +++++- .../server/precompress_integration_test.go | 220 +++++++++++++++ internal/server/static_cache_test.go | 127 +++++++++ internal/storage/player_snapshots.go | 2 +- internal/storage/precompress.go | 192 +++++++++++++ internal/storage/precompress_test.go | 140 ++++++++++ internal/storage/streaming_converter.go | 4 +- internal/storage/writer_protobuf_v1.go | 4 +- 16 files changed, 1660 insertions(+), 9 deletions(-) create mode 100644 internal/precompresscli/precompress.go create mode 100644 internal/precompresscli/precompress_test.go create mode 100644 internal/server/encoding.go create mode 100644 internal/server/encoding_test.go create mode 100644 internal/server/precompress_integration_test.go create mode 100644 internal/server/static_cache_test.go create mode 100644 internal/storage/precompress.go create mode 100644 internal/storage/precompress_test.go diff --git a/README.md b/README.md index 460a0f56f..e5576ce93 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,16 @@ Convert recordings manually using the CLI: ./ocap-webserver convert --set-format protobuf --id 1 ``` +Add compressed sidecars to recordings converted by an older build (see [Compression](#compression)): + +```bash +# Show what is missing, without writing anything +./ocap-webserver precompress --dry-run + +# Write the missing sidecars +./ocap-webserver precompress +``` + ### File Structure After Conversion ``` @@ -316,15 +326,152 @@ data/ ├── mission_name.gz # Original JSON (preserved) └── mission_name/ # Chunked binary format ├── manifest.pb # Metadata, entities, events + ├── manifest.pb.zst # Precompressed copies, served when the + ├── manifest.pb.gz # client accepts the encoding ├── chunks/ │ ├── 0000.pb # Frames 0-299 + │ ├── 0000.pb.zst + │ ├── 0000.pb.gz │ ├── 0001.pb # Frames 300-599 │ └── ... └── snapshots/ # Per-player detail, fetched on demand ├── 7.pb # Every snapshot recorded for unit 7 + ├── 7.pb.zst └── ... ``` +The `.zst` and `.gz` files are optional: the server falls back to the raw `.pb` whenever one is +missing. See [Compression](#compression). + +### Compression + +A converted recording never changes, so its artifacts are compressed once during conversion rather +than on every request. Each `.pb` gets `.zst` and `.gz` copies written beside it: + +| File | Size (3h45m mission, ~950 entities) | +|------|------| +| `manifest.pb` | 2.5 MB | +| `manifest.pb.zst` | 378 KB | +| `chunks/0000.pb` | 2.4 MB | +| `chunks/0000.pb.zst` | 33 KB | +| `chunks/0000.pb.gz` | 55 KB | + +The server picks the best sidecar the client accepts — `zstd` first, then `gzip` — and serves it +with the matching `Content-Encoding`. A client that accepts neither, and any recording that has no +sidecars, gets the raw `.pb`. Nothing depends on the sidecars existing. + +Compressing offline makes a much higher compression level affordable than a proxy can use on a live +response (roughly 30% smaller chunks), and the server spends no CPU re-compressing bytes that never +change. + +Sidecars are written only for artifacts over 1 KB, and only when the result is actually smaller than +the original. Writes go through a temporary file and a rename, so a request either sees a complete +sidecar or none at all. + +#### Reverse proxies + +If a proxy sits in front of the server, make sure it compresses **`application/x-protobuf`** — the +type `.pb` files are served as. Most proxies only compress a default list of text types and skip +protobuf, which leaves the largest responses on the page uncompressed for any recording without +sidecars. + +**Caddy** — the type has to be added to `encode`, because naming `match` replaces the default list: + +```caddy +encode { + zstd + gzip + match { + header Content-Type text/* + header Content-Type application/json* + header Content-Type application/javascript* + header Content-Type image/svg+xml* + header Content-Type application/x-protobuf* + } +} +``` + +**nginx** — `gzip` is `off` by default, and when enabled `gzip_types` still covers only +`text/html`, so protobuf has to be listed explicitly: + +```nginx +server { + server_name ocap.example.com; + + gzip on; + gzip_vary on; # adds Vary: Accept-Encoding + gzip_comp_level 5; + gzip_min_length 1024; + gzip_proxied any; # only needed if nginx itself sits behind another proxy or CDN + # (nginx keys this off the request's Via header) + gzip_types + text/plain + text/css + application/json + application/javascript + image/svg+xml + application/x-protobuf; + + location / { + proxy_pass http://127.0.0.1:5000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +Do **not** add `proxy_set_header Accept-Encoding "";`. It is a common recipe for making nginx do the +compressing itself, but it hides the client's supported encodings from the server, so the +precompressed sidecars are never used and every response is compressed from scratch instead. + +Stock nginx has no zstd support (it needs a third-party module), but that only limits what nginx can +compress *itself*. Sidecars are passed straight through, so a client that accepts zstd still gets +the `.zst` copy — which is a good reason to run `precompress` on an nginx deployment. + +Responses served from a sidecar already carry `Content-Encoding`, and both proxies skip those, so +there is no double compression. + +Static frontend assets under `/assets/` are content-hashed and are sent with +`Cache-Control: public, max-age=31536000, immutable` plus an `ETag`. Do not override that with +`no-cache` in a proxy — the bundle is around 900 KB raw (~300 KB compressed) and would otherwise be +re-downloaded on every page load. + +#### Backfilling existing recordings + +Precompression happens during conversion, so recordings converted by an older build have no +sidecars. They still play — the server falls back to the raw file — but their transfers stay larger. +`precompress` adds the sidecars without re-converting anything: + +```bash +# Show what is missing, without writing +./ocap-webserver precompress --dry-run + +# Write the missing sidecars +./ocap-webserver precompress + +# Point at a specific data directory +./ocap-webserver precompress --data /srv/ocap/data + +# Rebuild every sidecar, including up-to-date ones +./ocap-webserver precompress --force +``` + +Notes: + +- It reads each `.pb` and writes sidecars beside it. **The artifacts themselves are never rewritten.** +- `.json.gz` recordings are skipped. Their compression is inside the file already and the server + serves them with `Content-Encoding: gzip`, so there is nothing to add. +- It is idempotent — a second run reports everything as current. A sidecar older than its artifact + is treated as stale and rebuilt. +- Safe to run against a live server, and no restart is needed afterwards: the server looks for + sidecars per request. +- It compresses every artifact at a high level, so it is CPU- and I/O-heavy on a large library. + Use `--dry-run` first to see the scale, and prefer off-peak. + +Run it once after upgrading. Recordings converted from then on get their sidecars automatically. + ### Player detail snapshots Per-player detail (inventory, medical, stamina, radio) is kept in `snapshots/.pb` instead diff --git a/cmd/ocap-webserver/cli.go b/cmd/ocap-webserver/cli.go index dd5502cee..d0fa87205 100644 --- a/cmd/ocap-webserver/cli.go +++ b/cmd/ocap-webserver/cli.go @@ -6,6 +6,7 @@ import ( "github.com/OCAP2/web/internal/convertcli" "github.com/OCAP2/web/internal/maptoolcli" + "github.com/OCAP2/web/internal/precompresscli" "github.com/OCAP2/web/internal/server" ) @@ -22,6 +23,8 @@ func runRoot(args []string, stdout, stderr io.Writer, serve func() int) int { return convertcli.Run(args[1:]) case "maptool": return maptoolcli.Run(args[1:]) + case "precompress": + return precompresscli.Run(args[1:]) case "serve": return serve() case "-h", "--help", "help": @@ -48,6 +51,7 @@ func printRootUsage(w io.Writer) { fmt.Fprintln(w, " serve Start the web server") fmt.Fprintln(w, " convert Convert mission JSON to protobuf storage") fmt.Fprintln(w, " maptool Manage the map toolchain: 'maptool tools|doctor|install|render'") + fmt.Fprintln(w, " precompress Backfill .zst/.gz sidecars for already-converted recordings") fmt.Fprintln(w, " help Show this help") fmt.Fprintln(w, " version Show build version") fmt.Fprintln(w) diff --git a/go.mod b/go.mod index 46f1708d2..3e58e4cd4 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/schema v1.4.1 // indirect + github.com/klauspost/compress v1.20.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/oasdiff/yaml v0.1.1 // indirect github.com/oasdiff/yaml3 v0.0.14 // indirect diff --git a/go.sum b/go.sum index 05acd8c70..e5697d8cf 100644 --- a/go.sum +++ b/go.sum @@ -38,6 +38,8 @@ github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= +github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= diff --git a/internal/precompresscli/precompress.go b/internal/precompresscli/precompress.go new file mode 100644 index 000000000..5b00ea5c8 --- /dev/null +++ b/internal/precompresscli/precompress.go @@ -0,0 +1,258 @@ +// Package precompresscli implements `ocap-webserver precompress`, the backfill +// that gives recordings converted before precompression existed the same +// sidecars a fresh conversion writes. +// +// Without it the feature would only ever apply to new uploads: an existing +// library keeps being compressed on every request by whatever sits in front of +// the server, and never benefits from the higher offline compression level. +package precompresscli + +import ( + "flag" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/OCAP2/web/internal/server" + "github.com/OCAP2/web/internal/storage" +) + +// Run is the entry point for `ocap-webserver precompress ...`. Returns the +// process exit code. +func Run(args []string) int { + return run(args, defaultDeps()) +} + +// deps bundles injectable dependencies for the precompress CLI. +type deps struct { + loadSettings func() (server.Setting, error) + stdout io.Writer + stderr io.Writer +} + +func defaultDeps() deps { + return deps{ + loadSettings: server.NewSetting, + stdout: os.Stdout, + stderr: os.Stderr, + } +} + +// Stats is the tally reported at the end of a run. +type Stats struct { + Scanned int + Written int + Skipped int + Failed int + RawBytes int64 + BestBytes int64 + SidecarSeen int64 +} + +func run(args []string, d deps) int { + fs := flag.NewFlagSet("precompress", flag.ContinueOnError) + fs.SetOutput(d.stderr) + + dataDir := fs.String("data", "", "Recording data directory (default: the configured OCAP_DATA)") + dryRun := fs.Bool("dry-run", false, "Report what would be written without touching the disk") + force := fs.Bool("force", false, "Rewrite sidecars even when an up-to-date one already exists") + quiet := fs.Bool("quiet", false, "Only print the summary") + + fs.Usage = func() { + fmt.Fprintf(d.stderr, "Usage: precompress [options]\n\n") + fmt.Fprintf(d.stderr, "Writes .zst and .gz sidecars next to every .pb artifact under the data\n") + fmt.Fprintf(d.stderr, "directory, so recordings converted before precompression existed are served\n") + fmt.Fprintf(d.stderr, "the same way new ones are. Safe to run against a live server.\n\n") + fmt.Fprintf(d.stderr, "Options:\n") + fs.PrintDefaults() + fmt.Fprintf(d.stderr, "\nExamples:\n") + fmt.Fprintf(d.stderr, " precompress --dry-run Show what is missing\n") + fmt.Fprintf(d.stderr, " precompress Backfill everything missing\n") + fmt.Fprintf(d.stderr, " precompress --force Rebuild every sidecar\n") + } + + if err := fs.Parse(args); err != nil { + return 2 + } + + root := *dataDir + if root == "" { + setting, err := d.loadSettings() + if err != nil { + fmt.Fprintf(d.stderr, "precompress: load settings: %v\n", err) + return 1 + } + root = setting.Data + } + if root == "" { + fmt.Fprintln(d.stderr, "precompress: no data directory configured; pass --data") + return 1 + } + if info, err := os.Stat(root); err != nil || !info.IsDir() { + fmt.Fprintf(d.stderr, "precompress: %s is not a directory\n", root) + return 1 + } + + stats, err := walk(root, *dryRun, *force, *quiet, d.stdout, d.stderr) + if err != nil { + fmt.Fprintf(d.stderr, "precompress: %v\n", err) + return 1 + } + + printSummary(d.stdout, root, stats, *dryRun) + if stats.Failed > 0 { + return 1 + } + return 0 +} + +// isArtifact reports whether a path is a protobuf artifact rather than one of +// the sidecars written next to it. +func isArtifact(path string) bool { + return strings.EqualFold(filepath.Ext(path), ".pb") +} + +// needsSidecars reports whether path is missing an up-to-date sidecar for any +// coding. A sidecar older than the artifact is treated as missing, matching the +// staleness rule the server applies when it decides whether to serve one. +func needsSidecars(path string, info os.FileInfo) bool { + for _, coding := range storage.PrecompressEncodings { + sidecar, err := os.Stat(path + storage.PrecompressSuffix[coding]) + if err != nil || sidecar.ModTime().Before(info.ModTime()) { + return true + } + } + return false +} + +// sidecarBytes returns the size of the smallest existing sidecar, which is what +// a modern browser would actually download. +func sidecarBytes(path string, fallback int64) int64 { + best := fallback + for _, coding := range storage.PrecompressEncodings { + if info, err := os.Stat(path + storage.PrecompressSuffix[coding]); err == nil { + if info.Size() < best { + best = info.Size() + } + } + } + return best +} + +func walk(root string, dryRun, force, quiet bool, stdout, stderr io.Writer) (Stats, error) { + var stats Stats + var artifacts []string + + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + fmt.Fprintf(stderr, " skip %s: %v\n", path, err) + return nil + } + if d.IsDir() || !isArtifact(path) { + return nil + } + artifacts = append(artifacts, path) + return nil + }) + if err != nil { + return stats, fmt.Errorf("walk %s: %w", root, err) + } + + // Deterministic order keeps a --dry-run diffable against the real run. + sort.Strings(artifacts) + + for _, path := range artifacts { + info, err := os.Stat(path) + if err != nil { + fmt.Fprintf(stderr, " skip %s: %v\n", path, err) + stats.Failed++ + continue + } + stats.Scanned++ + stats.RawBytes += info.Size() + + if !force && !needsSidecars(path, info) { + stats.Skipped++ + stats.BestBytes += sidecarBytes(path, info.Size()) + continue + } + + rel, relErr := filepath.Rel(root, path) + if relErr != nil { + rel = path + } + + if dryRun { + stats.Written++ + stats.BestBytes += info.Size() // unknown until written; count raw + if !quiet { + fmt.Fprintf(stdout, " would write sidecars for %s (%s)\n", rel, humanBytes(info.Size())) + } + continue + } + + data, err := os.ReadFile(path) + if err != nil { + fmt.Fprintf(stderr, " read %s: %v\n", rel, err) + stats.Failed++ + continue + } + if err := storage.WriteSidecars(path, data); err != nil { + fmt.Fprintf(stderr, " %s: %v\n", rel, err) + stats.Failed++ + continue + } + + best := sidecarBytes(path, info.Size()) + stats.Written++ + stats.BestBytes += best + if !quiet { + fmt.Fprintf(stdout, " %s %s -> %s\n", rel, humanBytes(info.Size()), humanBytes(best)) + } + } + + return stats, nil +} + +func printSummary(w io.Writer, root string, s Stats, dryRun bool) { + verb := "written" + if dryRun { + verb = "would write" + } + fmt.Fprintf(w, "\n%s\n", root) + fmt.Fprintf(w, " artifacts scanned : %d\n", s.Scanned) + fmt.Fprintf(w, " sidecars %-9s: %d\n", verb, s.Written) + fmt.Fprintf(w, " already current : %d\n", s.Skipped) + if s.Failed > 0 { + fmt.Fprintf(w, " failed : %d\n", s.Failed) + } + if !dryRun && s.RawBytes > 0 { + fmt.Fprintf(w, " raw total : %s\n", humanBytes(s.RawBytes)) + fmt.Fprintf(w, " best-coding total : %s (%.1fx smaller)\n", + humanBytes(s.BestBytes), float64(s.RawBytes)/float64(max64(s.BestBytes, 1))) + } +} + +func max64(a, b int64) int64 { + if a > b { + return a + } + return b +} + +func humanBytes(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for v := n / unit; v >= unit && exp < 3; v /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGT"[exp]) +} diff --git a/internal/precompresscli/precompress_test.go b/internal/precompresscli/precompress_test.go new file mode 100644 index 000000000..874710d13 --- /dev/null +++ b/internal/precompresscli/precompress_test.go @@ -0,0 +1,220 @@ +package precompresscli + +import ( + "bytes" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/OCAP2/web/internal/server" + "github.com/OCAP2/web/internal/storage" +) + +func testDeps(stdout, stderr *bytes.Buffer) deps { + return deps{ + loadSettings: func() (server.Setting, error) { return server.Setting{}, nil }, + stdout: stdout, + stderr: stderr, + } +} + +// legacyRecording lays out a recording the way it looks on disk after a +// conversion that predates precompression: raw artifacts, no sidecars. +func legacyRecording(t *testing.T, root, name string) []string { + t.Helper() + dir := filepath.Join(root, name) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "chunks"), 0755)) + + payload := bytes.Repeat([]byte("entity frame state "), 4096) + paths := []string{ + filepath.Join(dir, "manifest.pb"), + filepath.Join(dir, "chunks", "0000.pb"), + filepath.Join(dir, "chunks", "0001.pb"), + } + for _, p := range paths { + require.NoError(t, os.WriteFile(p, payload, 0644)) + } + return paths +} + +func run1(t *testing.T, args ...string) (int, string, string) { + t.Helper() + var stdout, stderr bytes.Buffer + code := run(args, testDeps(&stdout, &stderr)) + return code, stdout.String(), stderr.String() +} + +func TestRun_BackfillsLegacyRecording(t *testing.T) { + root := t.TempDir() + paths := legacyRecording(t, root, "old_mission") + + code, stdout, stderr := run1(t, "--data", root) + + require.Equal(t, 0, code, "stderr: %s", stderr) + assert.Contains(t, stdout, "artifacts scanned : 3") + assert.Contains(t, stdout, "sidecars written : 3") + + for _, p := range paths { + for _, coding := range storage.PrecompressEncodings { + sidecar := p + storage.PrecompressSuffix[coding] + info, err := os.Stat(sidecar) + require.NoError(t, err, "%s written", sidecar) + raw, err := os.Stat(p) + require.NoError(t, err) + assert.Less(t, info.Size(), raw.Size()) + } + } +} + +// The backfill must not touch the artifacts themselves — only add files beside +// them. A rewritten .pb would churn mtimes and, worse, risk corrupting data the +// server is mid-read on. +func TestRun_LeavesArtifactsUntouched(t *testing.T) { + root := t.TempDir() + paths := legacyRecording(t, root, "old_mission") + + before := make(map[string][]byte) + beforeMod := make(map[string]time.Time) + for _, p := range paths { + data, err := os.ReadFile(p) + require.NoError(t, err) + before[p] = data + info, err := os.Stat(p) + require.NoError(t, err) + beforeMod[p] = info.ModTime() + } + + code, _, stderr := run1(t, "--data", root) + require.Equal(t, 0, code, "stderr: %s", stderr) + + for _, p := range paths { + data, err := os.ReadFile(p) + require.NoError(t, err) + assert.Equal(t, before[p], data, "artifact bytes unchanged") + info, err := os.Stat(p) + require.NoError(t, err) + assert.Equal(t, beforeMod[p], info.ModTime(), "artifact mtime unchanged") + } +} + +func TestRun_DryRunWritesNothing(t *testing.T) { + root := t.TempDir() + paths := legacyRecording(t, root, "old_mission") + + code, stdout, stderr := run1(t, "--data", root, "--dry-run") + + require.Equal(t, 0, code, "stderr: %s", stderr) + assert.Contains(t, stdout, "would write") + for _, p := range paths { + for _, coding := range storage.PrecompressEncodings { + _, err := os.Stat(p + storage.PrecompressSuffix[coding]) + assert.ErrorIs(t, err, os.ErrNotExist) + } + } +} + +func TestRun_IsIdempotent(t *testing.T) { + root := t.TempDir() + legacyRecording(t, root, "old_mission") + + code, _, _ := run1(t, "--data", root) + require.Equal(t, 0, code) + + code, stdout, _ := run1(t, "--data", root) + require.Equal(t, 0, code) + assert.Contains(t, stdout, "sidecars written : 0") + assert.Contains(t, stdout, "already current : 3") +} + +func TestRun_ForceRewritesCurrentSidecars(t *testing.T) { + root := t.TempDir() + legacyRecording(t, root, "old_mission") + + require.Equal(t, 0, func() int { c, _, _ := run1(t, "--data", root); return c }()) + + code, stdout, _ := run1(t, "--data", root, "--force") + require.Equal(t, 0, code) + assert.Contains(t, stdout, "sidecars written : 3") +} + +// A sidecar older than its artifact is what the server treats as stale, so the +// backfill has to consider it missing and rebuild it. +func TestRun_RebuildsStaleSidecars(t *testing.T) { + root := t.TempDir() + paths := legacyRecording(t, root, "old_mission") + + require.Equal(t, 0, func() int { c, _, _ := run1(t, "--data", root); return c }()) + + stale := time.Now().Add(-2 * time.Hour) + for _, coding := range storage.PrecompressEncodings { + require.NoError(t, os.Chtimes(paths[0]+storage.PrecompressSuffix[coding], stale, stale)) + } + + code, stdout, _ := run1(t, "--data", root) + require.Equal(t, 0, code) + assert.Contains(t, stdout, "sidecars written : 1") + assert.Contains(t, stdout, "already current : 2") + + for _, coding := range storage.PrecompressEncodings { + info, err := os.Stat(paths[0] + storage.PrecompressSuffix[coding]) + require.NoError(t, err) + assert.True(t, info.ModTime().After(stale), "sidecar rebuilt") + } +} + +// Sidecars must not themselves be treated as artifacts, or a second run would +// produce manifest.pb.zst.zst. +func TestRun_DoesNotRecurseIntoSidecars(t *testing.T) { + root := t.TempDir() + paths := legacyRecording(t, root, "old_mission") + + require.Equal(t, 0, func() int { c, _, _ := run1(t, "--data", root); return c }()) + require.Equal(t, 0, func() int { c, _, _ := run1(t, "--data", root); return c }()) + + for _, coding := range storage.PrecompressEncodings { + nested := paths[0] + storage.PrecompressSuffix[coding] + storage.PrecompressSuffix[coding] + _, err := os.Stat(nested) + assert.ErrorIs(t, err, os.ErrNotExist, "no sidecar of a sidecar") + } +} + +func TestRun_IgnoresNonProtobufFiles(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "recording.json.gz"), + bytes.Repeat([]byte("json"), 2048), 0644)) + + code, stdout, _ := run1(t, "--data", root) + + require.Equal(t, 0, code) + assert.Contains(t, stdout, "artifacts scanned : 0") + _, err := os.Stat(filepath.Join(root, "recording.json.gz.zst")) + assert.ErrorIs(t, err, os.ErrNotExist, "legacy JSON uploads are left alone") +} + +func TestRun_MissingDataDir(t *testing.T) { + code, _, stderr := run1(t, "--data", filepath.Join(t.TempDir(), "nope")) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "is not a directory") +} + +func TestRun_NoDataDirConfigured(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run(nil, testDeps(&stdout, &stderr)) + assert.Equal(t, 1, code) + assert.Contains(t, stderr.String(), "no data directory configured") +} + +func TestRun_BadFlag(t *testing.T) { + code, _, _ := run1(t, "--nope") + assert.Equal(t, 2, code) +} + +func TestHumanBytes(t *testing.T) { + assert.Equal(t, "512 B", humanBytes(512)) + assert.Equal(t, "1.0 KB", humanBytes(1024)) + assert.Equal(t, "2.5 MB", humanBytes(2621440)) +} diff --git a/internal/server/encoding.go b/internal/server/encoding.go new file mode 100644 index 000000000..fd33ab298 --- /dev/null +++ b/internal/server/encoding.go @@ -0,0 +1,119 @@ +package server + +import ( + "net/http" + "os" + "path" + "strconv" + "strings" + + "github.com/OCAP2/web/internal/storage" +) + +// acceptedEncodings parses an Accept-Encoding header into coding → qvalue. +// +// Only what the negotiation below actually needs is modelled: explicit codings, +// their qvalues, and the "*" wildcard. A coding at q=0 is a refusal, which is +// how a client opts out of a coding the wildcard would otherwise allow. +func acceptedEncodings(header string) map[string]float64 { + if header == "" { + return nil + } + + out := make(map[string]float64) + for _, part := range strings.Split(header, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + coding, params, _ := strings.Cut(part, ";") + coding = strings.ToLower(strings.TrimSpace(coding)) + if coding == "" { + continue + } + + q := 1.0 + for _, param := range strings.Split(params, ";") { + key, value, ok := strings.Cut(param, "=") + if !ok || strings.ToLower(strings.TrimSpace(key)) != "q" { + continue + } + if parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64); err == nil { + q = parsed + } + } + out[coding] = q + } + return out +} + +// acceptsEncoding reports whether the client will take the given coding, either +// by naming it or by a wildcard that does not exclude it. +func acceptsEncoding(accepted map[string]float64, coding string) bool { + if q, ok := accepted[coding]; ok { + return q > 0 + } + if q, ok := accepted["*"]; ok { + return q > 0 + } + return false +} + +// servePrecompressed serves the precompressed sidecar written next to path at +// conversion time, when the client accepts one. It reports whether it wrote a +// response; a false return leaves the response untouched for the caller to +// serve the raw file. +// +// A sidecar older than the artifact it belongs to is ignored. That makes a +// stale compressed copy harmless rather than a source of served-wrong-bytes: +// the request simply falls through to the file that is definitely current. +func servePrecompressed(w http.ResponseWriter, r *http.Request, absolutePath, contentType string) bool { + accepted := acceptedEncodings(r.Header.Get("Accept-Encoding")) + if len(accepted) == 0 { + return false + } + + info, err := os.Stat(absolutePath) + if err != nil { + return false + } + + for _, coding := range storage.PrecompressEncodings { + if !acceptsEncoding(accepted, coding) { + continue + } + + sidecar := absolutePath + storage.PrecompressSuffix[coding] + sidecarInfo, err := os.Stat(sidecar) + if err != nil || sidecarInfo.IsDir() || sidecarInfo.ModTime().Before(info.ModTime()) { + continue + } + + // ServeFile would sniff the sidecar's own bytes, so the type of the + // payload it decodes to has to be set here. + if contentType != "" { + w.Header().Set("Content-Type", contentType) + } + w.Header().Set("Content-Encoding", coding) + http.ServeFile(w, r, sidecar) + return true + } + + return false +} + +// dataContentType returns the type to serve a /data file as. +// +// Protobuf artifacts are labelled application/x-protobuf rather than the +// generic octet-stream: it is the accurate type, and it is one a reverse proxy +// recognises as compressible, so a deployment that compresses at the edge does +// not have to special-case OCAP's payloads. +func dataContentType(relativePath string) string { + switch strings.ToLower(path.Ext(relativePath)) { + case ".pb": + return "application/x-protobuf" + default: + return "" + } +} diff --git a/internal/server/encoding_test.go b/internal/server/encoding_test.go new file mode 100644 index 000000000..4df6727ac --- /dev/null +++ b/internal/server/encoding_test.go @@ -0,0 +1,145 @@ +package server + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/OCAP2/web/internal/storage" +) + +func TestAcceptedEncodings(t *testing.T) { + tests := []struct { + name string + header string + want map[string]float64 + }{ + {"empty", "", nil}, + {"single", "gzip", map[string]float64{"gzip": 1}}, + {"list", "gzip, zstd", map[string]float64{"gzip": 1, "zstd": 1}}, + {"qvalues", "gzip;q=0.5, zstd;q=1.0", map[string]float64{"gzip": 0.5, "zstd": 1}}, + {"refusal", "gzip;q=0, zstd", map[string]float64{"gzip": 0, "zstd": 1}}, + {"wildcard", "*", map[string]float64{"*": 1}}, + {"messy spacing", " gzip ; q=0.8 ,zstd ", map[string]float64{"gzip": 0.8, "zstd": 1}}, + {"case folded", "GZIP, Zstd", map[string]float64{"gzip": 1, "zstd": 1}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, acceptedEncodings(tt.header)) + }) + } +} + +func TestAcceptsEncoding(t *testing.T) { + assert.True(t, acceptsEncoding(acceptedEncodings("zstd"), "zstd")) + assert.False(t, acceptsEncoding(acceptedEncodings("zstd"), "gzip")) + assert.True(t, acceptsEncoding(acceptedEncodings("*"), "gzip"), "wildcard allows any coding") + assert.False(t, acceptsEncoding(acceptedEncodings("gzip;q=0"), "gzip"), "q=0 is a refusal") + assert.False(t, acceptsEncoding(acceptedEncodings("*;q=0"), "gzip")) +} + +func TestDataContentType(t *testing.T) { + assert.Equal(t, "application/x-protobuf", dataContentType("chunks/0000.pb")) + assert.Equal(t, "application/x-protobuf", dataContentType("MANIFEST.PB")) + assert.Equal(t, "", dataContentType("recording.json")) +} + +// writeArtifactWithSidecars lays out a file the way the converter does. +func writeArtifactWithSidecars(t *testing.T, dir, name string, payload []byte) string { + t.Helper() + path := filepath.Join(dir, name) + require.NoError(t, storage.WriteArtifact(path, payload)) + return path +} + +func protoPayload() []byte { + return bytes.Repeat([]byte("entity frame state "), 4096) +} + +func TestServePrecompressed_PrefersZstd(t *testing.T) { + dir := t.TempDir() + path := writeArtifactWithSidecars(t, dir, "manifest.pb", protoPayload()) + + req := httptest.NewRequest(http.MethodGet, "/data/x/manifest.pb", nil) + req.Header.Set("Accept-Encoding", "gzip, zstd") + rec := httptest.NewRecorder() + + assert.True(t, servePrecompressed(rec, req, path, "application/x-protobuf")) + assert.Equal(t, "zstd", rec.Header().Get("Content-Encoding")) + assert.Equal(t, "application/x-protobuf", rec.Header().Get("Content-Type")) + assert.Less(t, rec.Body.Len(), len(protoPayload()), "compressed body is served") +} + +func TestServePrecompressed_FallsBackToGzip(t *testing.T) { + dir := t.TempDir() + path := writeArtifactWithSidecars(t, dir, "manifest.pb", protoPayload()) + + req := httptest.NewRequest(http.MethodGet, "/data/x/manifest.pb", nil) + req.Header.Set("Accept-Encoding", "gzip") + rec := httptest.NewRecorder() + + assert.True(t, servePrecompressed(rec, req, path, "application/x-protobuf")) + assert.Equal(t, "gzip", rec.Header().Get("Content-Encoding")) +} + +func TestServePrecompressed_DeclinesWithoutAcceptEncoding(t *testing.T) { + dir := t.TempDir() + path := writeArtifactWithSidecars(t, dir, "manifest.pb", protoPayload()) + + req := httptest.NewRequest(http.MethodGet, "/data/x/manifest.pb", nil) + rec := httptest.NewRecorder() + + assert.False(t, servePrecompressed(rec, req, path, "application/x-protobuf")) + assert.Empty(t, rec.Header().Get("Content-Encoding")) + assert.Zero(t, rec.Body.Len(), "response is left untouched for the caller") +} + +func TestServePrecompressed_DeclinesWhenNoSidecarExists(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "legacy.pb") + require.NoError(t, os.WriteFile(path, protoPayload(), 0644)) + + req := httptest.NewRequest(http.MethodGet, "/data/x/legacy.pb", nil) + req.Header.Set("Accept-Encoding", "gzip, zstd") + rec := httptest.NewRecorder() + + assert.False(t, servePrecompressed(rec, req, path, "application/x-protobuf"), + "a recording converted before sidecars existed still serves raw") +} + +// A sidecar left over from an earlier conversion must never be served in place +// of an artifact that has since been rewritten. +func TestServePrecompressed_IgnoresStaleSidecar(t *testing.T) { + dir := t.TempDir() + path := writeArtifactWithSidecars(t, dir, "manifest.pb", protoPayload()) + + // Age both sidecars behind the artifact. + stale := time.Now().Add(-time.Hour) + for _, coding := range storage.PrecompressEncodings { + require.NoError(t, os.Chtimes(path+storage.PrecompressSuffix[coding], stale, stale)) + } + + req := httptest.NewRequest(http.MethodGet, "/data/x/manifest.pb", nil) + req.Header.Set("Accept-Encoding", "gzip, zstd") + rec := httptest.NewRecorder() + + assert.False(t, servePrecompressed(rec, req, path, "application/x-protobuf")) +} + +func TestServePrecompressed_HonoursRefusal(t *testing.T) { + dir := t.TempDir() + path := writeArtifactWithSidecars(t, dir, "manifest.pb", protoPayload()) + + req := httptest.NewRequest(http.MethodGet, "/data/x/manifest.pb", nil) + req.Header.Set("Accept-Encoding", "zstd;q=0, gzip;q=0") + rec := httptest.NewRecorder() + + assert.False(t, servePrecompressed(rec, req, path, "application/x-protobuf")) +} diff --git a/internal/server/handler.go b/internal/server/handler.go index 3e3253dc7..4fadb2995 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -5,6 +5,8 @@ import ( "bytes" "compress/gzip" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -225,10 +227,10 @@ func NewHandler( } if hdlr.staticFS != nil { - // Serve the SPA frontend with fallback to index.html for client-side routing - staticHandler := spaFileServer(hdlr.staticFS, prefixURL) - noCacheMiddleware := hdlr.cacheControl(0) - fuego.Handle(g, "/{path...}", noCacheMiddleware(staticHandler)) + // Serve the SPA frontend with fallback to index.html for client-side + // routing. Cache headers are set per path inside the handler, because + // the hashed bundles and the HTML shell need opposite policies. + fuego.Handle(g, "/{path...}", spaFileServer(hdlr.staticFS, prefixURL)) } } @@ -575,6 +577,17 @@ func (h *Handler) GetData(w http.ResponseWriter, r *http.Request) { return } + // Converted artifacts carry precompressed sidecars. Either branch varies on + // Accept-Encoding, so caches have to key on it whichever one answers. + w.Header().Add("Vary", "Accept-Encoding") + contentType := dataContentType(relativePath) + if servePrecompressed(w, r, absolutePath, contentType) { + return + } + if contentType != "" { + w.Header().Set("Content-Type", contentType) + } + http.ServeFile(w, r, absolutePath) } @@ -655,16 +668,67 @@ func (h *Handler) GetMapTile(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, absolutePath) } +// hashedAssetPrefix is the directory the frontend build emits content-hashed +// bundles into. A file there is named after a hash of its own contents, so any +// change produces a new URL and the old one can never go stale. +const hashedAssetPrefix = "assets/" + +// immutableCacheControl is the policy for content-hashed bundles: cache for a +// year and never revalidate. Without it the browser refetches the whole bundle +// on every page load, which is the single largest repeat-visit cost the player +// has. +const immutableCacheControl = "public, max-age=31536000, immutable" + +// staticCacheControl picks the caching policy for one embedded file. Only the +// content-hashed bundles are safe to freeze; the HTML shell and anything else +// keeps its name across deploys and so must be revalidated. +func staticCacheControl(p string) string { + if strings.HasPrefix(p, hashedAssetPrefix) { + return immutableCacheControl + } + return "no-cache" +} + +// buildETags hashes every embedded file once at startup. +// +// The frontend is served from an embed.FS, whose entries all report a zero +// modification time — so Last-Modified carries no information and a revalidation +// can never answer 304. A content ETag is what makes "no-cache" mean "check +// first" instead of "download it all again". +func buildETags(fsys fs.FS) map[string]string { + etags := make(map[string]string) + _ = fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + data, err := fs.ReadFile(fsys, p) + if err != nil { + return nil + } + etags[p] = etagFor(data) + return nil + }) + return etags +} + +// etagFor returns a strong, quoted ETag for a body. +func etagFor(data []byte) string { + sum := sha256.Sum256(data) + return `"` + hex.EncodeToString(sum[:16]) + `"` +} + // spaFileServer returns an http.Handler that serves static files from fsys, // falling back to index.html for paths that don't match a file (SPA routing). // The prefix is injected into index.html as window.__BASE_PATH__ so the // frontend SPA can discover its base URL at runtime. func spaFileServer(fsys fs.FS, prefix string) http.Handler { handler := http.StripPrefix(prefix, http.FileServer(http.FS(fsys))) + etags := buildETags(fsys) // Pre-read index.html and inject the base path script tag once at startup. var indexContent []byte var indexModTime time.Time + var indexETag string if f, err := fsys.Open("index.html"); err == nil { defer f.Close() if stat, err := f.Stat(); err == nil { @@ -675,6 +739,8 @@ func spaFileServer(fsys fs.FS, prefix string) http.Handler { inject := fmt.Sprintf(``, base, prefix) // Inject right after so is parsed before any relative URLs indexContent = bytes.Replace(raw, []byte(""), []byte(""+inject), 1) + // Hash the injected body, not the file: that is what is served. + indexETag = etagFor(indexContent) } } @@ -686,6 +752,12 @@ func spaFileServer(fsys fs.FS, prefix string) http.Handler { // Serve existing files directly if p != "" { if _, err := fs.Stat(fsys, p); err == nil { + w.Header().Set("Cache-Control", staticCacheControl(p)) + // http.ServeContent reads this back to answer If-None-Match, + // so setting it here is what turns a revalidation into a 304. + if tag, ok := etags[p]; ok { + w.Header().Set("Etag", tag) + } handler.ServeHTTP(w, r) return } @@ -696,6 +768,10 @@ func spaFileServer(fsys fs.FS, prefix string) http.Handler { http.NotFound(w, r) return } + w.Header().Set("Cache-Control", "no-cache") + if indexETag != "" { + w.Header().Set("Etag", indexETag) + } http.ServeContent(w, r, "index.html", indexModTime, bytes.NewReader(indexContent)) }) } diff --git a/internal/server/precompress_integration_test.go b/internal/server/precompress_integration_test.go new file mode 100644 index 000000000..2396ab70f --- /dev/null +++ b/internal/server/precompress_integration_test.go @@ -0,0 +1,220 @@ +package server + +import ( + "bytes" + "compress/gzip" + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/klauspost/compress/zstd" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + "github.com/OCAP2/web/internal/storage" + pbv1 "github.com/OCAP2/web/pkg/schemas/protobuf/v1" +) + +// buildBulkyRecording produces a recording large enough that its converted +// artifacts clear the sidecar threshold, so the precompression path is actually +// exercised rather than skipped. +func buildBulkyRecording() map[string]interface{} { + const ( + entityCount = 40 + frameCount = 60 + ) + + entities := make([]map[string]interface{}, 0, entityCount) + for id := 1; id <= entityCount; id++ { + positions := make([][]interface{}, 0, frameCount) + for f := 0; f < frameCount; f++ { + positions = append(positions, []interface{}{ + []float64{float64(100 + f), float64(200 + f)}, + float64(f % 360), 1.0, 0.0, + fmt.Sprintf("Rifleman %d", id), + 1.0, + }) + } + entities = append(entities, map[string]interface{}{ + "id": id, + "type": "unit", + "startFrameNum": 0, + "name": fmt.Sprintf("Rifleman %d", id), + "group": fmt.Sprintf("Alpha-%d", id%8), + "side": "WEST", + "isPlayer": 1, + "positions": positions, + "framesFired": []interface{}{}, + }) + } + + // Events with real message payloads, the way radio traffic and Zeus camera + // samples land in a production manifest. + events := make([][]interface{}, 0, 400) + for f := 0; f < 400; f++ { + events = append(events, []interface{}{ + f, "generalEvent", 1, []interface{}{0}, + fmt.Sprintf(`{"action":"Start","channel":%d,"frequency":%d,"radio":"RT-1523G (ASIP) Big [Black]","type":"LR","unitId":%d}`, + f%8, 60+f%40, f%entityCount+1), + }) + } + + return map[string]interface{}{ + "worldName": "altis", + "missionName": "Precompression Integration Mission", + "captureDelay": 1.0, + "endFrame": frameCount - 1, + "entities": entities, + "events": events, + "times": []interface{}{}, + "Markers": []interface{}{}, + } +} + +func decodeZstd(t *testing.T, data []byte) []byte { + t.Helper() + dec, err := zstd.NewReader(nil) + require.NoError(t, err) + defer dec.Close() + out, err := dec.DecodeAll(data, nil) + require.NoError(t, err) + return out +} + +func decodeGzip(t *testing.T, data []byte) []byte { + t.Helper() + zr, err := gzip.NewReader(bytes.NewReader(data)) + require.NoError(t, err) + defer zr.Close() + out, err := io.ReadAll(zr) + require.NoError(t, err) + return out +} + +// TestIntegration_PrecompressedArtifactServing runs a real conversion and then +// serves the result through GetData, checking that a client which accepts a +// coding gets the precompressed sidecar and one that does not still gets bytes +// it can parse. +func TestIntegration_PrecompressedArtifactServing(t *testing.T) { + dir := t.TempDir() + dataDir := filepath.Join(dir, "data") + require.NoError(t, os.MkdirAll(dataDir, 0755)) + + jsonPath := filepath.Join(dataDir, "precompress_test.json.gz") + writeTestGzippedJSON(t, jsonPath, buildBulkyRecording()) + + outputPath := filepath.Join(dataDir, "precompress_test") + converter := storage.NewConverter(30) + require.NoError(t, converter.Convert(context.Background(), jsonPath, outputPath)) + + hdlr := Handler{setting: Setting{Data: dataDir}} + + artifacts := []struct { + name string + rel string + }{ + {"manifest", "precompress_test/manifest.pb"}, + {"chunk", "precompress_test/chunks/0000.pb"}, + } + + for _, artifact := range artifacts { + t.Run(artifact.name+"/conversion writes sidecars", func(t *testing.T) { + abs := filepath.Join(dataDir, filepath.FromSlash(artifact.rel)) + rawInfo, err := os.Stat(abs) + require.NoError(t, err) + require.Greater(t, rawInfo.Size(), int64(1024), + "fixture must clear the sidecar threshold for this test to mean anything") + + for _, coding := range storage.PrecompressEncodings { + info, err := os.Stat(abs + storage.PrecompressSuffix[coding]) + require.NoError(t, err, "%s sidecar written", coding) + assert.Less(t, info.Size(), rawInfo.Size(), "%s sidecar is smaller", coding) + } + }) + + t.Run(artifact.name+"/serves zstd when accepted", func(t *testing.T) { + rec := serveData(&hdlr, artifact.rel, "zstd") + + require.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "zstd", rec.Header().Get("Content-Encoding")) + assert.Equal(t, "application/x-protobuf", rec.Header().Get("Content-Type")) + assert.Contains(t, rec.Header().Values("Vary"), "Accept-Encoding") + + raw, err := os.ReadFile(filepath.Join(dataDir, filepath.FromSlash(artifact.rel))) + require.NoError(t, err) + assert.Equal(t, raw, decodeZstd(t, rec.Body.Bytes()), + "the compressed body decodes to exactly the artifact") + assert.Less(t, rec.Body.Len(), len(raw)) + }) + + t.Run(artifact.name+"/serves gzip when zstd is not accepted", func(t *testing.T) { + rec := serveData(&hdlr, artifact.rel, "gzip") + + require.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "gzip", rec.Header().Get("Content-Encoding")) + + raw, err := os.ReadFile(filepath.Join(dataDir, filepath.FromSlash(artifact.rel))) + require.NoError(t, err) + assert.Equal(t, raw, decodeGzip(t, rec.Body.Bytes())) + }) + + t.Run(artifact.name+"/serves raw when nothing is accepted", func(t *testing.T) { + rec := serveData(&hdlr, artifact.rel, "") + + require.Equal(t, http.StatusOK, rec.Code) + assert.Empty(t, rec.Header().Get("Content-Encoding")) + assert.Equal(t, "application/x-protobuf", rec.Header().Get("Content-Type")) + + raw, err := os.ReadFile(filepath.Join(dataDir, filepath.FromSlash(artifact.rel))) + require.NoError(t, err) + assert.Equal(t, raw, rec.Body.Bytes()) + }) + } + + // The decompressed manifest must still be the protobuf the player expects. + t.Run("zstd manifest parses as a Manifest", func(t *testing.T) { + rec := serveData(&hdlr, "precompress_test/manifest.pb", "zstd") + require.Equal(t, http.StatusOK, rec.Code) + + var manifest pbv1.Manifest + require.NoError(t, proto.Unmarshal(decodeZstd(t, rec.Body.Bytes()), &manifest)) + assert.Equal(t, "altis", manifest.WorldName) + assert.Equal(t, "Precompression Integration Mission", manifest.MissionName) + assert.Len(t, manifest.Entities, 40) + }) + + // A recording converted before sidecars existed has none on disk; the + // request has to fall through to the raw file rather than 404 or error. + t.Run("legacy artifact without sidecars still serves", func(t *testing.T) { + legacyDir := filepath.Join(dataDir, "legacy_recording") + require.NoError(t, os.MkdirAll(legacyDir, 0755)) + raw, err := os.ReadFile(filepath.Join(dataDir, "precompress_test", "manifest.pb")) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(legacyDir, "manifest.pb"), raw, 0644)) + + rec := serveData(&hdlr, "legacy_recording/manifest.pb", "gzip, zstd") + + require.Equal(t, http.StatusOK, rec.Code) + assert.Empty(t, rec.Header().Get("Content-Encoding")) + assert.Equal(t, raw, rec.Body.Bytes()) + }) +} + +// serveData drives GetData the way the router does, with the given +// Accept-Encoding. +func serveData(hdlr *Handler, relPath, acceptEncoding string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/data/"+relPath, nil) + req.SetPathValue("path", relPath) + if acceptEncoding != "" { + req.Header.Set("Accept-Encoding", acceptEncoding) + } + rec := httptest.NewRecorder() + hdlr.GetData(rec, req) + return rec +} diff --git a/internal/server/static_cache_test.go b/internal/server/static_cache_test.go new file mode 100644 index 000000000..7cf9fc6fc --- /dev/null +++ b/internal/server/static_cache_test.go @@ -0,0 +1,127 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func frontendFS() fstest.MapFS { + return fstest.MapFS{ + "index.html": &fstest.MapFile{Data: []byte("")}, + "assets/index-BsiBu4cN.js": &fstest.MapFile{Data: []byte("console.log('app')")}, + "assets/index-BOnpnqGh.css": &fstest.MapFile{Data: []byte("body{color:red}")}, + "favicon.ico": &fstest.MapFile{Data: []byte("icon-bytes")}, + } +} + +func TestStaticCacheControl(t *testing.T) { + assert.Equal(t, immutableCacheControl, staticCacheControl("assets/index-BsiBu4cN.js")) + assert.Equal(t, immutableCacheControl, staticCacheControl("assets/index-BOnpnqGh.css")) + assert.Equal(t, "no-cache", staticCacheControl("index.html"), + "the shell keeps its name across deploys, so it must revalidate") + assert.Equal(t, "no-cache", staticCacheControl("favicon.ico")) +} + +// Content-hashed bundles are the largest repeat-visit cost; they must be +// frozen, and they must carry exactly one Cache-Control value. +func TestSpaFileServer_HashedAssetsAreImmutable(t *testing.T) { + handler := spaFileServer(frontendFS(), "") + + req := httptest.NewRequest(http.MethodGet, "/assets/index-BsiBu4cN.js", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, []string{immutableCacheControl}, rec.Header().Values("Cache-Control")) + assert.NotEmpty(t, rec.Header().Get("Etag")) +} + +func TestSpaFileServer_IndexIsRevalidated(t *testing.T) { + handler := spaFileServer(frontendFS(), "") + + req := httptest.NewRequest(http.MethodGet, "/app/route", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control")) + assert.NotEmpty(t, rec.Header().Get("Etag")) + assert.Contains(t, rec.Body.String(), "__BASE_PATH__") +} + +// embed.FS reports a zero modification time, so Last-Modified can never answer +// a revalidation. The ETag is what turns "no-cache" into a 304 instead of a +// full re-download. +func TestSpaFileServer_ConditionalRequestReturns304(t *testing.T) { + handler := spaFileServer(frontendFS(), "") + + req := httptest.NewRequest(http.MethodGet, "/assets/index-BsiBu4cN.js", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + etag := rec.Header().Get("Etag") + require.NotEmpty(t, etag) + + conditional := httptest.NewRequest(http.MethodGet, "/assets/index-BsiBu4cN.js", nil) + conditional.Header.Set("If-None-Match", etag) + rec2 := httptest.NewRecorder() + handler.ServeHTTP(rec2, conditional) + + assert.Equal(t, http.StatusNotModified, rec2.Code) + assert.Zero(t, rec2.Body.Len(), "a 304 carries no body") +} + +func TestSpaFileServer_IndexConditionalRequestReturns304(t *testing.T) { + handler := spaFileServer(frontendFS(), "") + + req := httptest.NewRequest(http.MethodGet, "/app/route", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + etag := rec.Header().Get("Etag") + require.NotEmpty(t, etag) + + conditional := httptest.NewRequest(http.MethodGet, "/app/route", nil) + conditional.Header.Set("If-None-Match", etag) + rec2 := httptest.NewRecorder() + handler.ServeHTTP(rec2, conditional) + + assert.Equal(t, http.StatusNotModified, rec2.Code) +} + +// The ETag must track the served bytes, which for index.html means the body +// after the base-path injection, not the file on disk. +func TestSpaFileServer_IndexETagCoversInjectedBody(t *testing.T) { + plain := spaFileServer(frontendFS(), "") + prefixed := spaFileServer(frontendFS(), "/aar") + + rec := httptest.NewRecorder() + plain.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/route", nil)) + + rec2 := httptest.NewRecorder() + prefixed.ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/aar/route", nil)) + + require.NotEmpty(t, rec.Header().Get("Etag")) + require.NotEmpty(t, rec2.Header().Get("Etag")) + assert.NotEqual(t, rec.Header().Get("Etag"), rec2.Header().Get("Etag"), + "different injected base paths are different bodies") +} + +func TestSpaFileServer_ETagChangesWithContent(t *testing.T) { + first := frontendFS() + second := frontendFS() + second["assets/index-BsiBu4cN.js"] = &fstest.MapFile{Data: []byte("console.log('changed')")} + + get := func(fsys fstest.MapFS) string { + rec := httptest.NewRecorder() + spaFileServer(fsys, "").ServeHTTP(rec, + httptest.NewRequest(http.MethodGet, "/assets/index-BsiBu4cN.js", nil)) + return rec.Header().Get("Etag") + } + + assert.NotEqual(t, get(first), get(second)) +} diff --git a/internal/storage/player_snapshots.go b/internal/storage/player_snapshots.go index 436d3c4f2..c40715521 100644 --- a/internal/storage/player_snapshots.go +++ b/internal/storage/player_snapshots.go @@ -117,7 +117,7 @@ func WritePlayerSnapshots(outputPath string, byUnit map[uint32][]*pbv1.Event) er return fmt.Errorf("marshal snapshots for unit %d: %w", unitID, err) } path := filepath.Join(dir, fmt.Sprintf("%d.pb", unitID)) - if err := os.WriteFile(path, data, 0644); err != nil { + if err := WriteArtifact(path, data); err != nil { return fmt.Errorf("write snapshots for unit %d: %w", unitID, err) } } diff --git a/internal/storage/precompress.go b/internal/storage/precompress.go new file mode 100644 index 000000000..976dc4c16 --- /dev/null +++ b/internal/storage/precompress.go @@ -0,0 +1,192 @@ +package storage + +import ( + "bytes" + "compress/gzip" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sync" + + "github.com/klauspost/compress/zstd" +) + +// Content codings written alongside every protobuf artifact. +// +// A converted recording is immutable, so its bytes are worth compressing once +// here rather than on every request: the web server hands the sidecar straight +// to any client that accepts the coding, which both removes the per-request +// compression cost and buys a ratio a live compressor could not afford. On a +// real 3h45m recording a 2.4 MB chunk lands at ~28 KB this way against ~55 KB +// from on-the-fly gzip. +// +// zstd is preferred — smaller and much faster to decode — with gzip kept for +// clients that do not advertise zstd. +const ( + EncodingZstd = "zstd" + EncodingGzip = "gzip" +) + +// PrecompressEncodings lists the codings written for each artifact, best first. +// The server walks this order when picking a sidecar for a request. +var PrecompressEncodings = []string{EncodingZstd, EncodingGzip} + +// PrecompressSuffix maps a content coding to its sidecar file extension. +var PrecompressSuffix = map[string]string{ + EncodingZstd: ".zst", + EncodingGzip: ".gz", +} + +// precompressMinSize is the size below which an artifact is written on its own. +// A sidecar for a payload this small saves nothing worth an extra file: the +// response fits in a single packet either way. +const precompressMinSize = 1024 + +var ( + zstdOnce sync.Once + zstdEnc *zstd.Encoder + zstdErr error +) + +// zstdEncoder returns the shared encoder. zstd.Encoder.EncodeAll is safe for +// concurrent use, so one encoder serves every conversion worker. +func zstdEncoder() (*zstd.Encoder, error) { + zstdOnce.Do(func() { + zstdEnc, zstdErr = zstd.NewWriter(nil, + zstd.WithEncoderLevel(zstd.SpeedBestCompression), + zstd.WithEncoderConcurrency(1), + ) + }) + return zstdEnc, zstdErr +} + +// compress encodes data with the given coding. +func compress(encoding string, data []byte) ([]byte, error) { + switch encoding { + case EncodingZstd: + enc, err := zstdEncoder() + if err != nil { + return nil, fmt.Errorf("zstd encoder: %w", err) + } + return enc.EncodeAll(data, nil), nil + case EncodingGzip: + var buf bytes.Buffer + zw, err := gzip.NewWriterLevel(&buf, gzip.BestCompression) + if err != nil { + return nil, fmt.Errorf("gzip writer: %w", err) + } + if _, err := zw.Write(data); err != nil { + return nil, fmt.Errorf("gzip write: %w", err) + } + if err := zw.Close(); err != nil { + return nil, fmt.Errorf("gzip close: %w", err) + } + return buf.Bytes(), nil + default: + return nil, fmt.Errorf("unknown encoding %q", encoding) + } +} + +// WriteArtifact writes a converted protobuf artifact and its precompressed +// sidecars. The raw file stays authoritative — a client that accepts no coding, +// and every existing reader of these files, is unaffected. +func WriteArtifact(path string, data []byte) error { + if err := os.WriteFile(path, data, 0644); err != nil { + return err + } + return WriteSidecars(path, data) +} + +// WriteSidecars writes just the precompressed variants of an artifact whose raw +// bytes are already on disk. Conversion reaches it through WriteArtifact; the +// backfill command uses it directly, so an existing recording gains sidecars +// without its artifacts being rewritten. +// +// Sidecars are removed rather than left behind when they are not written, so a +// re-conversion that shrinks a file below the threshold (or one running against +// a directory from an older build) can never leave a stale compressed copy for +// the server to serve in place of the current bytes. +func WriteSidecars(path string, data []byte) error { + if len(data) < precompressMinSize { + return removeSidecars(path) + } + + for _, encoding := range PrecompressEncodings { + sidecar := path + PrecompressSuffix[encoding] + encoded, err := compress(encoding, data) + if err != nil { + return fmt.Errorf("compress %s: %w", sidecar, err) + } + // A sidecar that did not shrink the payload is pure overhead; drop it + // and let the request fall through to the raw file. + if len(encoded) >= len(data) { + if err := removeSidecar(sidecar); err != nil { + return err + } + continue + } + if err := writeFileAtomic(sidecar, encoded); err != nil { + return err + } + } + + return nil +} + +// writeFileAtomic writes through a temporary file in the same directory and +// renames it into place. +// +// The rename is what makes the backfill safe to run against a server that is +// already serving these recordings: a request either sees no sidecar and falls +// back to the raw artifact, or sees a complete one. It can never read a +// half-written compressed file and hand the browser truncated bytes. +func writeFileAtomic(path string, data []byte) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp*") + if err != nil { + return fmt.Errorf("create temp for %s: %w", path, err) + } + tmpName := tmp.Name() + + cleanup := func() { + tmp.Close() + os.Remove(tmpName) + } + + if _, err := tmp.Write(data); err != nil { + cleanup() + return fmt.Errorf("write %s: %w", tmpName, err) + } + if err := tmp.Close(); err != nil { + os.Remove(tmpName) + return fmt.Errorf("close %s: %w", tmpName, err) + } + if err := os.Chmod(tmpName, 0644); err != nil { + os.Remove(tmpName) + return fmt.Errorf("chmod %s: %w", tmpName, err) + } + if err := os.Rename(tmpName, path); err != nil { + os.Remove(tmpName) + return fmt.Errorf("rename onto %s: %w", path, err) + } + return nil +} + +// removeSidecars deletes every precompressed variant of path. +func removeSidecars(path string) error { + for _, encoding := range PrecompressEncodings { + if err := removeSidecar(path + PrecompressSuffix[encoding]); err != nil { + return err + } + } + return nil +} + +func removeSidecar(path string) error { + if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("remove %s: %w", path, err) + } + return nil +} diff --git a/internal/storage/precompress_test.go b/internal/storage/precompress_test.go new file mode 100644 index 000000000..d03dd88d6 --- /dev/null +++ b/internal/storage/precompress_test.go @@ -0,0 +1,140 @@ +package storage + +import ( + "bytes" + "compress/gzip" + "io" + "os" + "path/filepath" + "testing" + + "github.com/klauspost/compress/zstd" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// compressiblePayload returns data that is well above the sidecar threshold and +// compresses heavily, the way a real protobuf chunk does. +func compressiblePayload() []byte { + return bytes.Repeat([]byte("ocap entity state frame "), 4096) +} + +func readGzip(t *testing.T, path string) []byte { + t.Helper() + f, err := os.Open(path) + require.NoError(t, err) + defer f.Close() + zr, err := gzip.NewReader(f) + require.NoError(t, err) + defer zr.Close() + out, err := io.ReadAll(zr) + require.NoError(t, err) + return out +} + +func readZstd(t *testing.T, path string) []byte { + t.Helper() + raw, err := os.ReadFile(path) + require.NoError(t, err) + dec, err := zstd.NewReader(nil) + require.NoError(t, err) + defer dec.Close() + out, err := dec.DecodeAll(raw, nil) + require.NoError(t, err) + return out +} + +func TestWriteArtifact_WritesRawAndSidecars(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "manifest.pb") + payload := compressiblePayload() + + require.NoError(t, WriteArtifact(path, payload)) + + raw, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, payload, raw, "raw artifact stays authoritative") + + assert.Equal(t, payload, readZstd(t, path+".zst"), "zstd sidecar round-trips") + assert.Equal(t, payload, readGzip(t, path+".gz"), "gzip sidecar round-trips") + + zstdInfo, err := os.Stat(path + ".zst") + require.NoError(t, err) + assert.Less(t, zstdInfo.Size(), int64(len(payload)), "sidecar is smaller than the payload") +} + +func TestWriteArtifact_SkipsSidecarsForSmallPayloads(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "tiny.pb") + + require.NoError(t, WriteArtifact(path, []byte("small"))) + + for _, suffix := range []string{".zst", ".gz"} { + _, err := os.Stat(path + suffix) + assert.ErrorIs(t, err, os.ErrNotExist, "no sidecar for %s", suffix) + } +} + +// A re-conversion that shrinks an artifact below the threshold must not leave +// the previous run's sidecar behind — the server would otherwise hand out stale +// bytes for a file that has since changed. +func TestWriteArtifact_RemovesStaleSidecarsWhenPayloadShrinks(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "chunk.pb") + + require.NoError(t, WriteArtifact(path, compressiblePayload())) + require.FileExists(t, path+".zst") + require.FileExists(t, path+".gz") + + require.NoError(t, WriteArtifact(path, []byte("now tiny"))) + + for _, suffix := range []string{".zst", ".gz"} { + _, err := os.Stat(path + suffix) + assert.ErrorIs(t, err, os.ErrNotExist, "stale %s sidecar removed", suffix) + } + raw, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, []byte("now tiny"), raw) +} + +// Random data does not compress; writing a sidecar bigger than the payload +// would make the response worse, so none is kept. +func TestWriteArtifact_SkipsSidecarThatDoesNotShrink(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "random.pb") + + payload := make([]byte, 4096) + for i := range payload { + payload[i] = byte(i * 7919 % 251) + } + // Defeat any structure the generator above leaves behind. + for i := 0; i < len(payload); i += 3 { + payload[i] ^= byte(i >> 3) + } + + require.NoError(t, WriteArtifact(path, payload)) + + raw, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, payload, raw) + + for _, suffix := range []string{".zst", ".gz"} { + if info, err := os.Stat(path + suffix); err == nil { + assert.Less(t, info.Size(), int64(len(payload)), + "a kept %s sidecar must be smaller than the payload", suffix) + } + } +} + +func TestWriteArtifact_OverwritesPreviousSidecars(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "manifest.pb") + + require.NoError(t, WriteArtifact(path, compressiblePayload())) + + updated := bytes.Repeat([]byte("different frame payload "), 4096) + require.NoError(t, WriteArtifact(path, updated)) + + assert.Equal(t, updated, readZstd(t, path+".zst")) + assert.Equal(t, updated, readGzip(t, path+".gz")) +} diff --git a/internal/storage/streaming_converter.go b/internal/storage/streaming_converter.go index d0a815c78..e4d8dba0c 100644 --- a/internal/storage/streaming_converter.go +++ b/internal/storage/streaming_converter.go @@ -278,7 +278,7 @@ func (sc *Converter) Convert(ctx context.Context, jsonPath, outputPath string) e if err != nil { return fmt.Errorf("marshal manifest: %w", err) } - if err := os.WriteFile(filepath.Join(outputPath, "manifest.pb"), data, 0644); err != nil { + if err := WriteArtifact(filepath.Join(outputPath, "manifest.pb"), data); err != nil { return fmt.Errorf("write manifest: %w", err) } @@ -331,7 +331,7 @@ func (sc *Converter) assembleChunk(bucket *ChunkBucket, chunksDir string, chunkI } path := filepath.Join(chunksDir, fmt.Sprintf("%04d.pb", chunkIdx)) - if err := os.WriteFile(path, data, 0644); err != nil { + if err := WriteArtifact(path, data); err != nil { return fmt.Errorf("write chunk: %w", err) } diff --git a/internal/storage/writer_protobuf_v1.go b/internal/storage/writer_protobuf_v1.go index e102e2fb1..6a1632dbc 100644 --- a/internal/storage/writer_protobuf_v1.go +++ b/internal/storage/writer_protobuf_v1.go @@ -46,7 +46,7 @@ func (w *ProtobufWriterV1) WriteManifest(ctx context.Context, outputPath string, } // Write file - if err := os.WriteFile(filepath.Join(outputPath, "manifest.pb"), data, 0644); err != nil { + if err := WriteArtifact(filepath.Join(outputPath, "manifest.pb"), data); err != nil { return fmt.Errorf("write manifest: %w", err) } @@ -291,7 +291,7 @@ func (w *ProtobufWriterV1) writeChunk(chunksDir string, index uint32, chunk *pbv return fmt.Errorf("marshal chunk: %w", err) } - if err := os.WriteFile(path, data, 0644); err != nil { + if err := WriteArtifact(path, data); err != nil { return fmt.Errorf("write chunk: %w", err) } From a6efbcb687ceea93146785fd2639883bb65d98a8 Mon Sep 17 00:00:00 2001 From: Yethe Samartaka Date: Tue, 8 Sep 2026 18:35:07 +0200 Subject: [PATCH 21/39] orbat improvements --- ui/src/config/sideColors.ts | 12 +++ ui/src/i18n/locales.ts | 28 ++++-- .../__tests__/orbat.test.ts | 71 ++++++++++++++ .../components/OrbatTab.tsx | 92 +++++++++++++++++-- .../components/UnitsTab.tsx | 12 +-- ui/src/pages/recording-playback/orbat.ts | 79 ++++++++++++++-- 6 files changed, 260 insertions(+), 34 deletions(-) diff --git a/ui/src/config/sideColors.ts b/ui/src/config/sideColors.ts index fde67e1c2..ab7c88ae1 100644 --- a/ui/src/config/sideColors.ts +++ b/ui/src/config/sideColors.ts @@ -1,5 +1,17 @@ import type { Side } from "../data/types"; +/** Every side, in the order the UI lists them. */ +export const SIDES: Side[] = ["WEST", "EAST", "GUER", "CIV", "VIRTUAL"]; + +/** Faction names as a briefing writes them, not the engine's side ids. */ +export const SIDE_LABELS: Record = { + WEST: "BLUFOR", + EAST: "OPFOR", + GUER: "IND", + CIV: "CIV", + VIRTUAL: "VIRTUAL", +}; + /** Bright colors for UI text (event log, unit list). Keep in sync with --side-* in variables.css */ export const SIDE_COLORS_BRIGHT: Record = { WEST: "#00a8ff", diff --git a/ui/src/i18n/locales.ts b/ui/src/i18n/locales.ts index 42405d630..2346fb05c 100644 --- a/ui/src/i18n/locales.ts +++ b/ui/src/i18n/locales.ts @@ -1547,15 +1547,25 @@ export const translations: Record> = { fi: "Ei yksiköitä tällä puolella juuri nyt", uk: "На цій стороні зараз немає юнітів", }, - orbat_no_vehicles: { - ru: "В этой записи никто не садился в технику", - en: "No vehicle was ever crewed in this recording", - de: "In dieser Aufzeichnung war kein Fahrzeug besetzt", - cs: "V tomto záznamu nebylo obsazeno žádné vozidlo", - it: "Nessun veicolo è mai stato occupato in questa registrazione", - fr: "Aucun véhicule n'a été occupé dans cet enregistrement", - fi: "Yhdessäkään ajoneuvossa ei ollut miehistöä", - uk: "У цьому записі ніхто не сідав у техніку", + orbat_no_players: { + ru: "На этой стороне нет игроков", + en: "No player units on this side", + de: "Keine Spielereinheiten auf dieser Seite", + cs: "Na této straně nejsou žádní hráči", + it: "Nessuna unità giocante per questa fazione", + fr: "Aucune unité joueur dans ce camp", + fi: "Ei pelaajayksiköitä tällä puolella", + uk: "На цій стороні немає гравців", + }, + orbat_no_player_vehicles: { + ru: "Игроки этой стороны не садились в технику", + en: "No vehicle was crewed by players on this side", + de: "Kein Fahrzeug wurde von Spielern dieser Seite besetzt", + cs: "Hráči této strany neobsadili žádné vozidlo", + it: "Nessun veicolo è stato occupato dai giocatori di questa fazione", + fr: "Aucun véhicule n'a été occupé par les joueurs de ce camp", + fi: "Tämän puolen pelaajat eivät miehittäneet yhtään ajoneuvoa", + uk: "Гравці цієї сторони не сідали в техніку", }, orbat_loading_vehicles: { ru: "Загрузка всей миссии…", diff --git a/ui/src/pages/recording-playback/__tests__/orbat.test.ts b/ui/src/pages/recording-playback/__tests__/orbat.test.ts index 70399c0b3..1dd4f83ac 100644 --- a/ui/src/pages/recording-playback/__tests__/orbat.test.ts +++ b/ui/src/pages/recording-playback/__tests__/orbat.test.ts @@ -164,6 +164,52 @@ describe("buildOrbat", () => { expect(group.alive).toBe(1); }); + it("keeps a player whose per-frame flag drops when they die or disconnect", () => { + // The recorder keeps writing the body as an AI-flagged unit, and a squad + // that empties out over the mission is exactly what the ORBAT must not do. + const casualty = unit(1, "Player", "WEST", "Alpha", [ + state({ groupName: "Alpha", isPlayer: true }), + state({ groupName: "Alpha", isPlayer: false, alive: 0 }), + ]); + const engine = fakeEngine([casualty], 1); + + const group = buildOrbat(engine, "WEST", 1)[0]; + expect(group.members.map((m) => m.name)).toEqual(["Player"]); + expect(group.alive).toBe(0); + }); + + it("lists a player once when a respawn left two live entities", () => { + const abandoned = unit(1, "Player", "WEST", "Alpha", [ + state({ groupName: "Alpha" }), + state({ groupName: "Alpha" }), + state({ groupName: "Alpha" }), + ]); + const respawned = unit(2, "Player", "WEST", "Alpha", [ + state({ groupName: "Alpha" }), + state({ groupName: "Alpha" }), + ]); + const engine = fakeEngine([abandoned, respawned], 2); + + const group = buildOrbat(engine, "WEST", 1)[0]; + expect(group.members).toHaveLength(1); + // The longer-lived entity is the real slot. + expect(group.members[0].unitId).toBe(1); + }); + + it("comes back empty for a side that has only AI", () => { + const states = [state({ groupName: "Alpha" })]; + const engine = fakeEngine( + [ + unit(1, "Player", "WEST", "Alpha", states), + unit(2, "AI One", "EAST", "Bravo", states, { isPlayer: false }), + unit(3, "AI Two", "EAST", "Bravo", states, { isPlayer: false }), + ], + 0, + ); + + expect(buildOrbat(engine, "EAST", 0)).toEqual([]); + }); + it("keeps the full roster when playersOnly is off", () => { const states = [state({ groupName: "Alpha" })]; const engine = fakeEngine( @@ -240,6 +286,31 @@ describe("buildVehicleOccupancy", () => { expect(occupancy.samples.map((s) => s.crewIds.length)).toEqual([0, 2, 3, 0]); }); + it("keeps only what a player of that side crewed when asked", () => { + const truck = new Vehicle(9, "Player Truck", "truck", 0, 1, "truck", [ + state({ crewIds: [1] }), + state({ crewIds: [1] }), + ]); + const aiTruck = new Vehicle(10, "AI Truck", "truck", 0, 1, "truck", [ + state({ crewIds: [2] }), + state({ crewIds: [2] }), + ]); + const enemyTruck = new Vehicle(11, "Enemy Truck", "truck", 0, 1, "truck", [ + state({ crewIds: [3] }), + state({ crewIds: [3] }), + ]); + const driver = unit(1, "Driver", "WEST", "Alpha", [state(), state()]); + const ai = unit(2, "AI", "WEST", "Alpha", [state(), state()], { isPlayer: false }); + const enemy = unit(3, "Enemy", "EAST", "Bravo", [state(), state()]); + const engine = fakeEngine([truck, aiTruck, enemyTruck, driver, ai, enemy], 1); + + const names = buildVehicleOccupancy(engine, 0, 2, { + side: "WEST", + playersOnly: true, + }).map((v) => v.name); + expect(names).toEqual(["Player Truck"]); + }); + it("reports whether it is occupied at the playhead", () => { const truck = new Vehicle(9, "Truck", "truck", 0, 3, "truck", [ state({ crewIds: [] }), diff --git a/ui/src/pages/recording-playback/components/OrbatTab.tsx b/ui/src/pages/recording-playback/components/OrbatTab.tsx index 8123110fd..b70c89e94 100644 --- a/ui/src/pages/recording-playback/components/OrbatTab.tsx +++ b/ui/src/pages/recording-playback/components/OrbatTab.tsx @@ -1,12 +1,18 @@ import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; import type { JSX } from "solid-js"; +import type { Side } from "../../../data/types"; import { useEngine } from "../../../hooks/useEngine"; import { useI18n } from "../../../hooks/useLocale"; -import { SIDE_COLORS_UI } from "../../../config/sideColors"; +import { + SIDE_COLORS_UI, + SIDE_BG_COLORS, + SIDES, + SIDE_LABELS, +} from "../../../config/sideColors"; import { formatElapsedTime } from "../../../playback/time"; import { buildOrbat, buildVehicleOccupancy, isLeader } from "../orbat"; -import { activeSide } from "../shortcuts"; +import { activeSide, setActiveSide } from "../shortcuts"; import styles from "./OrbatTab.module.css"; import panel from "./SidePanel.module.css"; @@ -17,6 +23,44 @@ export function OrbatTab(): JSX.Element { const [chunksReady, setChunksReady] = createSignal(false); const [search, setSearch] = createSignal(""); const query = createMemo(() => search().trim().toLowerCase()); + /** Set once the faction was chosen here, which stops the auto-pick below. */ + const [sidePicked, setSidePicked] = createSignal(false); + + /** + * Players on a side across the whole recording. + * + * Entity-level and mission-wide on purpose: it labels the faction tabs and + * decides whether a faction has an ORBAT at all, and both questions are about + * the recording rather than the frame under the playhead. `getBySide` already + * folds a respawn or a reconnect back into one slot. + */ + const playerCount = (side: Side): number => { + engine.endFrame(); + return engine.entityManager.getBySide(side).filter((u) => u.isPlayer).length; + }; + + /** + * Every faction present in the recording, players or not. A faction with only + * AI still gets a tab — the roster it opens is empty, and that is the answer + * to "who was on this side", not a missing button. + */ + const sides = createMemo(() => { + engine.endFrame(); + return SIDES.filter((side) => engine.entityManager.getBySide(side).length > 0); + }); + + /** + * Open on a faction that has players, since the ORBAT is empty on any other. + * Only until the faction is chosen here: after that an empty AI faction is a + * deliberate choice and switching away from it would fight the click. + */ + createEffect(() => { + if (sidePicked()) return; + const withPlayers = sides().filter((side) => playerCount(side) > 0); + if (withPlayers.length > 0 && !withPlayers.includes(activeSide())) { + setActiveSide(withPlayers[0]); + } + }); // The vehicle strips span the whole mission, so a chunked recording has to // have every chunk in hand. Playback itself only ever loads the chunk under @@ -46,10 +90,14 @@ export function OrbatTab(): JSX.Element { }); }); + /** Only what the players of this faction rode; the AI motor pool is noise here. */ const allVehicles = createMemo(() => { chunksReady(); engine.endFrame(); - return buildVehicleOccupancy(engine, engine.currentFrame()); + return buildVehicleOccupancy(engine, engine.currentFrame(), 160, { + side: activeSide(), + playersOnly: true, + }); }); /** Vehicles match on their own name or on anyone who ever crewed them. */ @@ -87,6 +135,33 @@ export function OrbatTab(): JSX.Element { return ( <> + {/* Faction tabs: every side in the recording, AI-only ones included */} +
+ + {(side) => { + const isActive = () => activeSide() === side; + return ( + + ); + }} + +
+ {/* Search: group, player or vehicle name */}
- {t("orbat_groups")} · {activeSide()} + {t("orbat_groups")} ·{" "} + {SIDE_LABELS[activeSide()]}
0} fallback={
- {query() ? t("orbat_no_matches") : t("orbat_no_groups")} + {query() + ? t("orbat_no_matches") + : playerCount(activeSide()) === 0 + ? t("orbat_no_players") + : t("orbat_no_groups")}
} > @@ -167,7 +247,7 @@ export function OrbatTab(): JSX.Element { when={vehicles().length > 0} fallback={
- {query() ? t("orbat_no_vehicle_matches") : t("orbat_no_vehicles")} + {query() ? t("orbat_no_vehicle_matches") : t("orbat_no_player_vehicles")}
} > diff --git a/ui/src/pages/recording-playback/components/UnitsTab.tsx b/ui/src/pages/recording-playback/components/UnitsTab.tsx index d43860c03..d50d7a1b7 100644 --- a/ui/src/pages/recording-playback/components/UnitsTab.tsx +++ b/ui/src/pages/recording-playback/components/UnitsTab.tsx @@ -3,7 +3,7 @@ import type { JSX, Accessor } from "solid-js"; import { Portal } from "solid-js/web"; import type { Side } from "../../../data/types"; import { Unit } from "../../../playback/entities/unit"; -import { SIDE_COLORS_UI, SIDE_BG_COLORS } from "../../../config/sideColors"; +import { SIDE_COLORS_UI, SIDE_BG_COLORS, SIDES, SIDE_LABELS } from "../../../config/sideColors"; import { useEngine } from "../../../hooks/useEngine"; import { useCustomize } from "../../../hooks/useCustomize"; import { useI18n } from "../../../hooks/useLocale"; @@ -12,16 +12,6 @@ import { CrosshairIcon, ChevronRightIcon } from "../../../components/Icons"; import styles from "./SidePanel.module.css"; import { PlayerProfileCard } from "./PlayerProfileCard"; -const SIDES: Side[] = ["WEST", "EAST", "GUER", "CIV", "VIRTUAL"]; - -const SIDE_LABELS: Record = { - WEST: "BLUFOR", - EAST: "OPFOR", - GUER: "IND", - CIV: "CIV", - VIRTUAL: "VIRTUAL", -}; - interface GroupData { name: string; units: Unit[]; diff --git a/ui/src/pages/recording-playback/orbat.ts b/ui/src/pages/recording-playback/orbat.ts index 5bed6836a..3a40040c3 100644 --- a/ui/src/pages/recording-playback/orbat.ts +++ b/ui/src/pages/recording-playback/orbat.ts @@ -1,7 +1,7 @@ import type { PlaybackEngine } from "../../playback/engine"; import { Unit } from "../../playback/entities/unit"; import { Vehicle } from "../../playback/entities/vehicle"; -import type { Side } from "../../data/types"; +import type { EntityState, Side } from "../../data/types"; /** * Force structure read at a frame. @@ -38,6 +38,19 @@ function isLeader(role: string): boolean { return LEADER_ROLES.some((candidate) => normalised.includes(candidate)); } +/** + * True when this entity is a player slot at, or as of, `frame`. + * + * Both sources are needed. The per-frame flag alone loses a player the moment + * they are killed or disconnect — the recorder keeps writing their body as an + * AI-flagged unit — so a squad would empty out over the mission. The entity + * flag alone misses someone who took over an AI slot mid-mission, because that + * is only ever recorded per frame. + */ +function isPlayerSlot(entity: Unit, state: EntityState): boolean { + return entity.isPlayer || state.isPlayer === true; +} + /** * Build the order of battle for one side at a frame. * @@ -48,7 +61,8 @@ function isLeader(role: string): boolean { * whom, and on a populated mission the AI outnumbers the players heavily enough * to bury exactly that: filler squads the players never interacted with become * most of the list. A group with no players in it drops out entirely rather than - * appearing empty. Pass `playersOnly: false` for the full roster. + * appearing empty. A side with no players at all comes back empty. Pass + * `playersOnly: false` for the full roster. */ export function buildOrbat( engine: PlaybackEngine, @@ -59,6 +73,15 @@ export function buildOrbat( const playersOnly = options.playersOnly ?? true; const groups = new Map(); + /** + * One row per named slot, so a respawn or a reconnect does not list the same + * player twice. Both entities can be live at this frame — the recorder keeps + * writing the abandoned body — and the longer-lived one is the real slot. + * Unnamed entities are never folded together; they are distinct bodies. + */ + const bySlot = new Map(); + const unnamed: Array<{ group: string; member: OrbatMember }> = []; + for (const entity of engine.entityManager.getAll()) { if (!(entity instanceof Unit)) continue; if (frame < entity.startFrame || frame > entity.endFrame) continue; @@ -67,15 +90,14 @@ export function buildOrbat( if (!state) continue; if ((state.side ?? entity.side) !== side) continue; - // Read from the frame rather than the entity: someone who took over an AI - // slot mid-mission is a player from that point on. - const isPlayer = state.isPlayer ?? entity.isPlayer; + const isPlayer = isPlayerSlot(entity, state); if (playersOnly && !isPlayer) continue; const groupName = state.groupName || entity.groupName || ""; + const name = state.name || entity.name; const member: OrbatMember = { unitId: entity.id, - name: state.name || entity.name, + name, role: state.role ?? "", isPlayer, alive: !!state.alive, @@ -83,9 +105,20 @@ export function buildOrbat( regrouped: !!entity.groupName && !!groupName && groupName !== entity.groupName, }; - const list = groups.get(groupName); + if (!name) { + unnamed.push({ group: groupName, member }); + continue; + } + const key = `${groupName}\u0000${name}`; + const span = entity.endFrame - entity.startFrame; + const seen = bySlot.get(key); + if (!seen || span > seen.span) bySlot.set(key, { group: groupName, member, span }); + } + + for (const { group, member } of [...bySlot.values(), ...unnamed]) { + const list = groups.get(group); if (list) list.push(member); - else groups.set(groupName, [member]); + else groups.set(group, [member]); } return [...groups.entries()] @@ -121,6 +154,24 @@ export interface VehicleOccupancy { occupiedNow: boolean; } +/** + * Unit ids that belong to a player, optionally narrowed to one side. + * + * Entity-level only, deliberately: this answers "did a player ever ride this + * vehicle", a question about the whole recording, which the per-frame flag can + * only answer one frame at a time. Side is read the same way. + */ +function playerUnitIds(engine: PlaybackEngine, side?: Side): Set { + const ids = new Set(); + for (const entity of engine.entityManager.getAll()) { + if (!(entity instanceof Unit)) continue; + if (!entity.isPlayer) continue; + if (side && entity.side !== side) continue; + ids.add(entity.id); + } + return ids; +} + /** * Sample who was aboard each vehicle across the whole recording. * @@ -128,15 +179,24 @@ export interface VehicleOccupancy { * frames and the strip is a couple of hundred pixels wide, so reading every * frame would cost far more than it could ever show. A vehicle nobody ever * boarded is dropped — an empty strip says nothing. + * + * `playersOnly` keeps only the vehicles a player crewed at some point and + * `side` narrows that to one faction, which is what the ORBAT asks for: on a + * populated mission the AI motor pool is most of the list and none of the + * story. The crew lists themselves stay whole — who a player rode with is + * part of it. */ export function buildVehicleOccupancy( engine: PlaybackEngine, frame: number, sampleCount = 160, + options: { side?: Side; playersOnly?: boolean } = {}, ): VehicleOccupancy[] { const endFrame = engine.endFrame(); if (endFrame <= 0) return []; + const players = options.playersOnly ? playerUnitIds(engine, options.side) : null; + const step = Math.max(1, Math.floor(endFrame / sampleCount)); const result: VehicleOccupancy[] = []; @@ -146,6 +206,7 @@ export function buildVehicleOccupancy( const samples: CrewSample[] = []; let peak = 0; let occupiedNow = false; + let carriedPlayer = false; let nearestDistance = Number.POSITIVE_INFINITY; for (let f = entity.startFrame; f <= Math.min(entity.endFrame, endFrame); f += step) { @@ -153,6 +214,7 @@ export function buildVehicleOccupancy( const crewIds = state?.crewIds ?? []; samples.push({ frame: f, crewIds }); if (crewIds.length > peak) peak = crewIds.length; + if (players && !carriedPlayer) carriedPlayer = crewIds.some((id) => players.has(id)); const distance = Math.abs(f - frame); if (distance < nearestDistance) { @@ -162,6 +224,7 @@ export function buildVehicleOccupancy( } if (peak === 0) continue; + if (players && !carriedPlayer) continue; result.push({ vehicleId: entity.id, name: entity.name, samples, peak, occupiedNow }); } From 06115adbb802994dc399c409ffe879712dcc676b Mon Sep 17 00:00:00 2001 From: Yethe Samartaka Date: Mon, 14 Sep 2026 20:00:00 +0200 Subject: [PATCH 22/39] Add tracking of Unit traits --- ui/src/data/types.ts | 26 ++++ ui/src/i18n/locales.ts | 18 +++ .../__tests__/PlayerProfileCard.test.tsx | 37 +++++ .../__tests__/traits.test.ts | 81 +++++++++++ .../components/PlayerProfileCard.module.css | 9 ++ .../components/PlayerProfileCard.tsx | 105 ++++++++++++-- ui/src/pages/recording-playback/traits.ts | 129 ++++++++++++++++++ 7 files changed, 394 insertions(+), 11 deletions(-) create mode 100644 ui/src/pages/recording-playback/__tests__/traits.test.ts create mode 100644 ui/src/pages/recording-playback/traits.ts diff --git a/ui/src/data/types.ts b/ui/src/data/types.ts index d39ad4852..79883c21a 100644 --- a/ui/src/data/types.ts +++ b/ui/src/data/types.ts @@ -136,10 +136,36 @@ export interface GearContainer extends GearItem { items: GearItem[]; } +/** + * What a unit is allowed to do. The engine traits come from `getAllUnitTraits`; + * `ace` holds the graded training ACE reads instead of them, present only for + * the ACE components loaded on the client. + */ +export interface UnitTraits { + medic?: boolean; + engineer?: boolean; + explosiveSpecialist?: boolean; + uavHacker?: boolean; + camouflageCoef?: number; + audibleCoef?: number; + loadCoef?: number; + /** Traits a mission or mod added with `setUnitTrait`, by name. */ + custom?: Record; + ace?: { + /** 0 none, 1 medic, 2 doctor. */ + medic?: number; + /** 0 none, 1 engineer, 2 advanced engineer. */ + engineer?: number; + eod?: boolean; + }; +} + export interface InventorySnapshot { unitId: number; playerUid?: string; reason?: "periodic" | "death" | string; + /** Recorded with the loadout, so a diff carries it only when it changes. */ + traits?: UnitTraits; /** Raw Arma mass units. */ massUnits?: number; load: number; diff --git a/ui/src/i18n/locales.ts b/ui/src/i18n/locales.ts index 2346fb05c..e3192be18 100644 --- a/ui/src/i18n/locales.ts +++ b/ui/src/i18n/locales.ts @@ -3205,6 +3205,24 @@ export const translations: Record> = { profile_stance_1: { en: "Standing", ru: "Стоя", de: "Stehend", cs: "Ve stoje", it: "In piedi", fr: "Debout", fi: "Seisten", uk: "Стоячи" }, profile_stance_2: { en: "Crouched", ru: "Присев", de: "Kniend", cs: "V podřepu", it: "Accucciata", fr: "Accroupi", fi: "Kyykyssä", uk: "Присівши" }, profile_stance_3: { en: "Prone", ru: "Лёжа", de: "Liegend", cs: "V leže", it: "Prona", fr: "Couché", fi: "Makuulla", uk: "Лежачи" }, + profile_tab_traits: { en: "Traits", ru: "Навыки", de: "Fähigkeiten", cs: "Vlastnosti", it: "Tratti", fr: "Traits", fi: "Taidot", uk: "Навички" }, + profile_traits_none: { en: "No traits recorded", ru: "Навыки не записаны", de: "Keine Fähigkeiten erfasst", cs: "Žádné vlastnosti nezaznamenány", it: "Nessun tratto registrato", fr: "Aucun trait enregistré", fi: "Taitoja ei tallennettu", uk: "Навички не записано" }, + profile_traits_engine: { en: "Engine traits", ru: "Навыки движка", de: "Engine-Fähigkeiten", cs: "Vlastnosti enginu", it: "Tratti del motore", fr: "Traits du moteur", fi: "Pelimoottorin taidot", uk: "Навички рушія" }, + profile_traits_ace: { en: "ACE training", ru: "Подготовка ACE", de: "ACE-Ausbildung", cs: "Výcvik ACE", it: "Addestramento ACE", fr: "Formation ACE", fi: "ACE-koulutus", uk: "Підготовка ACE" }, + profile_traits_coefficients: { en: "Coefficients", ru: "Коэффициенты", de: "Koeffizienten", cs: "Koeficienty", it: "Coefficienti", fr: "Coefficients", fi: "Kertoimet", uk: "Коефіцієнти" }, + profile_traits_custom: { en: "Custom traits", ru: "Пользовательские навыки", de: "Eigene Fähigkeiten", cs: "Vlastní vlastnosti", it: "Tratti personalizzati", fr: "Traits personnalisés", fi: "Mukautetut taidot", uk: "Користувацькі навички" }, + profile_trait_medic: { en: "Medic", ru: "Медик", de: "Sanitäter", cs: "Zdravotník", it: "Medico", fr: "Médecin", fi: "Lääkintämies", uk: "Медик" }, + profile_trait_doctor: { en: "Doctor", ru: "Врач", de: "Arzt", cs: "Doktor", it: "Dottore", fr: "Docteur", fi: "Lääkäri", uk: "Лікар" }, + profile_trait_engineer: { en: "Engineer", ru: "Инженер", de: "Pionier", cs: "Ženista", it: "Ingegnere", fr: "Ingénieur", fi: "Pioneeri", uk: "Інженер" }, + profile_trait_engineer_advanced: { en: "Advanced Engineer", ru: "Продвинутый инженер", de: "Erweiterter Pionier", cs: "Pokročilý ženista", it: "Ingegnere avanzato", fr: "Ingénieur avancé", fi: "Edistynyt pioneeri", uk: "Досвідчений інженер" }, + profile_trait_eod: { en: "Explosive Specialist", ru: "Сапёр", de: "Sprengstoffexperte", cs: "Pyrotechnik", it: "Artificiere", fr: "Démineur", fi: "Räjähdeasiantuntija", uk: "Сапер" }, + profile_trait_uav: { en: "UAV Hacker", ru: "Оператор БПЛА", de: "UAV-Hacker", cs: "UAV operátor", it: "Hacker UAV", fr: "Pilote de drone", fi: "UAV-hakkeri", uk: "Оператор БПЛА" }, + profile_trait_ace_medic: { en: "Medical level", ru: "Медицинский уровень", de: "Sanitätsstufe", cs: "Zdravotnická úroveň", it: "Livello medico", fr: "Niveau médical", fi: "Lääkintätaso", uk: "Медичний рівень" }, + profile_trait_ace_engineer: { en: "Engineer level", ru: "Инженерный уровень", de: "Pionierstufe", cs: "Ženijní úroveň", it: "Livello ingegnere", fr: "Niveau ingénieur", fi: "Pioneeritaso", uk: "Інженерний рівень" }, + profile_trait_ace_eod: { en: "EOD", ru: "Сапёр (EOD)", de: "EOD", cs: "EOD", it: "EOD", fr: "EOD", fi: "EOD", uk: "EOD" }, + profile_trait_load_coef: { en: "Load", ru: "Нагрузка", de: "Last", cs: "Zátěž", it: "Carico", fr: "Charge", fi: "Kuorma", uk: "Навантаження" }, + profile_trait_camo_coef: { en: "Camouflage", ru: "Маскировка", de: "Tarnung", cs: "Maskování", it: "Mimetizzazione", fr: "Camouflage", fi: "Naamiointi", uk: "Маскування" }, + profile_trait_audible_coef: { en: "Audibility", ru: "Слышимость", de: "Hörbarkeit", cs: "Slyšitelnost", it: "Udibilità", fr: "Audibilité", fi: "Kuuluvuus", uk: "Чутність" }, profile_load: { en: "Load", ru: "Нагрузка", de: "Last", cs: "Zátěž", it: "Carico", fr: "Charge", fi: "Kuorma", uk: "Навантаження" }, profile_tab_gear: { en: "Gear", ru: "Снаряжение", de: "Ausrüstung", cs: "Výstroj", it: "Equipaggiamento", fr: "Équipement", fi: "Varusteet", uk: "Спорядження" }, profile_export_arsenal: { en: "Export to Arsenal", ru: "Экспорт в Arsenal", de: "In Arsenal exportieren", cs: "Exportovat do Arsenalu", it: "Esporta in Arsenal", fr: "Exporter vers Arsenal", fi: "Vie Arsenaliin", uk: "Експорт до Arsenal" }, diff --git a/ui/src/pages/recording-playback/__tests__/PlayerProfileCard.test.tsx b/ui/src/pages/recording-playback/__tests__/PlayerProfileCard.test.tsx index 0508ff6c8..9abec072d 100644 --- a/ui/src/pages/recording-playback/__tests__/PlayerProfileCard.test.tsx +++ b/ui/src/pages/recording-playback/__tests__/PlayerProfileCard.test.tsx @@ -67,6 +67,43 @@ function renderCard( } describe("PlayerProfileCard", () => { + it("offers a Traits tab listing every recorded trait", () => { + renderCard([ + snapshotEvent(5, "inventorySnapshot", { + weapons: [], + magazines: [], + assignedItems: [], + traits: { + medic: true, + engineer: false, + uavHacker: false, + loadCoef: 0.9, + custom: { pilot: true }, + ace: { medic: 2, engineer: 1, eod: true }, + }, + }), + ]); + fireEvent.click(screen.getByRole("tab", { name: "Traits" })); + const text = screen.getByTestId("profile-traits").textContent ?? ""; + expect(text).toContain("Engine traits"); + expect(text).toContain("MedicYes"); + expect(text).toContain("EngineerNo"); + expect(text).toContain("ACE training"); + expect(text).toContain("Medical levelDoctor"); + expect(text).toContain("Engineer levelEngineer"); + expect(text).toContain("EODYes"); + expect(text).toContain("Load×0.9"); + expect(text).toContain("Custom traits"); + expect(text).toContain("pilotYes"); + }); + + it("does not offer a Traits tab when the recording carries no traits", () => { + renderCard([snapshotEvent(5, "inventorySnapshot", { weapons: [], magazines: [], assignedItems: [] })]); + expect(screen.queryByRole("tab", { name: "Traits" })).toBeNull(); + expect(screen.queryByTestId("profile-traits")).toBeNull(); + }); + + it("exports the visible gear from buttons shown only on the gear tab", async () => { const writeText = vi.fn().mockResolvedValue(undefined); Object.defineProperty(navigator, "clipboard", { diff --git a/ui/src/pages/recording-playback/__tests__/traits.test.ts b/ui/src/pages/recording-playback/__tests__/traits.test.ts new file mode 100644 index 000000000..b510e279e --- /dev/null +++ b/ui/src/pages/recording-playback/__tests__/traits.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { diffTraits, formatMultiplier, traitGroups, traitLevelKey } from "../traits"; + +describe("traitGroups", () => { + it("groups engine flags, ACE levels, coefficients and custom traits", () => { + const groups = traitGroups({ + medic: true, + engineer: false, + explosiveSpecialist: true, + uavHacker: false, + camouflageCoef: 1, + audibleCoef: 0.5, + loadCoef: 1.2, + custom: { pilot: true, rank: "Sgt", score: 3 }, + ace: { medic: 2, engineer: 1, eod: false }, + }); + expect(groups.map((g) => g.id)).toEqual(["engine", "ace", "coefficients", "custom"]); + expect(groups[0].fields.map((f) => [f.id, f.value])).toEqual([ + ["medic", true], + ["engineer", false], + ["explosiveSpecialist", true], + ["uavHacker", false], + ]); + expect(groups[1].fields.map((f) => [f.id, f.kind, f.value])).toEqual([ + ["ace.medic", "level", 2], + ["ace.engineer", "level", 1], + ["ace.eod", "bool", false], + ]); + expect(groups[2].fields.map((f) => [f.id, f.value])).toEqual([ + ["loadCoef", 1.2], + ["camouflageCoef", 1], + ["audibleCoef", 0.5], + ]); + expect(groups[3].fields.map((f) => [f.labelKey, f.kind, f.value])).toEqual([ + ["pilot", "bool", true], + ["rank", "text", "Sgt"], + ["score", "number", 3], + ]); + }); + + it("omits groups the recording does not carry", () => { + const groups = traitGroups({ medic: false, loadCoef: 1 }); + expect(groups.map((g) => g.id)).toEqual(["engine", "coefficients"]); + expect(traitGroups(undefined)).toEqual([]); + }); + + it("resolves ACE levels to labels", () => { + const [ace] = traitGroups({ ace: { medic: 0, engineer: 2 } }); + expect(traitLevelKey(ace.fields[0], 0)).toBe("profile_none"); + expect(traitLevelKey(ace.fields[0], 2)).toBe("profile_trait_doctor"); + expect(traitLevelKey(ace.fields[1], 2)).toBe("profile_trait_engineer_advanced"); + expect(traitLevelKey(ace.fields[1], 9)).toBe("profile_trait_engineer_advanced"); + }); + + it("formats multipliers", () => { + expect(formatMultiplier(1)).toBe("×1"); + expect(formatMultiplier(0.8)).toBe("×0.8"); + expect(formatMultiplier(1.25)).toBe("×1.25"); + expect(formatMultiplier(1 / 3)).toBe("×0.33"); + }); +}); + +describe("diffTraits", () => { + it("lists only fields whose value changed, plus fields that disappeared", () => { + const result = diffTraits( + { medic: true, uavHacker: true, loadCoef: 1, custom: { pilot: true }, ace: { engineer: 1 } }, + { medic: true, uavHacker: false, loadCoef: 0.9, ace: { engineer: 2 } }, + ); + expect(result.map((d) => [d.id, d.from, d.to])).toEqual([ + ["uavHacker", true, false], + ["ace.engineer", 1, 2], + ["loadCoef", 1, 0.9], + ["custom:pilot", true, undefined], + ]); + }); + + it("is empty when nothing changed", () => { + const traits = { medic: true, ace: { medic: 1 } }; + expect(diffTraits(traits, { ...traits })).toEqual([]); + }); +}); diff --git a/ui/src/pages/recording-playback/components/PlayerProfileCard.module.css b/ui/src/pages/recording-playback/components/PlayerProfileCard.module.css index a80c9dbef..49efd6be1 100644 --- a/ui/src/pages/recording-playback/components/PlayerProfileCard.module.css +++ b/ui/src/pages/recording-playback/components/PlayerProfileCard.module.css @@ -121,6 +121,15 @@ text-transform: uppercase; } +/* Traits tab: one dataGrid per group (engine, ACE, coefficients, custom). */ +.traitGroup + .traitGroup { + margin-top: 10px; +} + +.traitActive b { + color: var(--accent-success); +} + /* Shown while a chunked recording fetches this player's snapshot sidecar. */ .loading { padding: 12px; diff --git a/ui/src/pages/recording-playback/components/PlayerProfileCard.tsx b/ui/src/pages/recording-playback/components/PlayerProfileCard.tsx index e5459d949..e50e730cd 100644 --- a/ui/src/pages/recording-playback/components/PlayerProfileCard.tsx +++ b/ui/src/pages/recording-playback/components/PlayerProfileCard.tsx @@ -61,6 +61,7 @@ import { } from "../profileDiff"; import styles from "./PlayerProfileCard.module.css"; import { exportAceArsenal, exportVanillaArsenal } from "../arsenalExport"; +import { diffTraits, formatMultiplier, traitGroups, traitLevelKey, type TraitField } from "../traits"; type RangeMode = "simple" | "approx"; @@ -104,6 +105,9 @@ const TAB_DEFS = [ { id: "medical", type: "medicalSnapshot", labelKey: "profile_tab_medical", missingKey: "profile_label_medical" }, { id: "stamina", type: "staminaSnapshot", labelKey: "profile_tab_stamina", missingKey: "profile_label_stamina" }, { id: "radio", type: "radioSnapshot", labelKey: "profile_tab_radios", missingKey: "profile_label_radio" }, + // Traits ride inside the inventory snapshot; the tab is offered only when the + // recording actually carries a trait block (see availableTabs). + { id: "traits", type: "inventorySnapshot", labelKey: "profile_tab_traits", missingKey: "profile_label_gear" }, ] as const; type TabId = (typeof TAB_DEFS)[number]["id"]; @@ -386,17 +390,6 @@ export function PlayerProfileCard(props: Props): JSX.Element { } return kinds; }); - const availableTabs = createMemo(() => TAB_DEFS.filter((entry) => trackedKinds().has(entry.type))); - const tabIsTracked = (id: TabId) => availableTabs().some((entry) => entry.id === id); - - createEffect(() => { - const tabs = availableTabs(); - if (tabs.length === 0) return; - if (!tabs.some((entry) => entry.id === tab())) { - setTab(tabs[0].id); - } - }); - const snapshots = createMemo(() => { engine.currentFrame(); engine.playerSnapshotsVersion(); @@ -416,6 +409,24 @@ export function PlayerProfileCard(props: Props): JSX.Element { const radio = () => payload("radioSnapshot"); const fromInventory = () => payloadAt(fromFrame(), "inventorySnapshot"); const toInventory = () => payloadAt(toFrame(), "inventorySnapshot"); + // The trait block is optional inside the inventory snapshot, so the Traits tab + // is keyed on the snapshot in view, falling back to the last one of the + // recording so it does not flicker while scrubbing before the first sample. + const traitsTracked = () => + (inventory() ?? payloadAt(engine.endFrame(), "inventorySnapshot"))?.traits !== undefined; + const availableTabs = createMemo(() => + TAB_DEFS.filter((entry) => trackedKinds().has(entry.type) && (entry.id !== "traits" || traitsTracked())), + ); + const tabIsTracked = (id: TabId) => availableTabs().some((entry) => entry.id === id); + + createEffect(() => { + const tabs = availableTabs(); + if (tabs.length === 0) return; + if (!tabs.some((entry) => entry.id === tab())) { + setTab(tabs[0].id); + } + }); + const fromMedical = () => payloadAt(fromFrame(), "medicalSnapshot"); const toMedical = () => payloadAt(toFrame(), "medicalSnapshot"); const fromStamina = () => payloadAt(fromFrame(), "staminaSnapshot"); @@ -637,6 +648,24 @@ export function PlayerProfileCard(props: Props): JSX.Element { props.unit.name ? t("profile_aria_named", { name: props.unit.name }) : t("profile_aria", { id: props.unit.id }); + const traitSections = createMemo(() => traitGroups((diffing() ? toInventory() : inventory())?.traits)); + const traitChanges = createMemo(() => diffTraits(fromInventory()?.traits, toInventory()?.traits)); + const traitValue = (field: TraitField, value: unknown): string => { + if (value === undefined || value === null || value === "") return t("profile_none"); + switch (field.kind) { + case "bool": + return value === true ? t("profile_yes") : value === false ? t("profile_no") : String(value); + case "level": { + const key = traitLevelKey(field, value); + return key ? t(key) : String(value); + } + case "number": + return field.multiplier ? formatMultiplier(value) : String(value); + default: + return String(value); + } + }; + const killsAt = (frame: number) => engine.eventManager.getKillDeathCounts(frame).kills.get(props.unit.id) ?? 0; const deathsAt = (frame: number) => @@ -1347,6 +1376,60 @@ export function PlayerProfileCard(props: Props): JSX.Element {
+ + + + 0} + fallback={} + > +
+ + {(entry) => ( + + {t(entry.labelKey)} + + {traitValue(entry, entry.from)} → {traitValue(entry, entry.to)} + + + )} + +
+
+
+ + 0} + fallback={
{t("profile_traits_none")}
} + > + +
+ + {(group) => ( +
+
{t(group.titleKey)}
+
+ + {(field) => ( + 0), + }} + > + {t(field.labelKey)} + {traitValue(field, field.value)} + + )} + +
+
+ )} +
+
+
+
+
); diff --git a/ui/src/pages/recording-playback/traits.ts b/ui/src/pages/recording-playback/traits.ts new file mode 100644 index 000000000..ee62ac899 --- /dev/null +++ b/ui/src/pages/recording-playback/traits.ts @@ -0,0 +1,129 @@ +import type { UnitTraits } from "../../data/types"; + +/** + * One line in the profile card's Traits tab. `labelKey` is a locale key, or the + * custom trait's own name (t() hands an unknown key straight back). A "level" + * field is a small integer the card resolves through `levelKeys`. + */ +export interface TraitField { + id: string; + labelKey: string; + kind: "bool" | "number" | "level" | "text"; + value: unknown; + levelKeys?: readonly string[]; + /** Non-1 coefficient values are shown as a multiplier. */ + multiplier?: boolean; +} + +export interface TraitGroup { + id: "engine" | "ace" | "coefficients" | "custom"; + titleKey: string; + fields: TraitField[]; +} + +export interface TraitDelta extends TraitField { + from: unknown; + to: unknown; +} + +const ACE_MEDIC_LEVELS = ["profile_none", "profile_trait_medic", "profile_trait_doctor"] as const; +const ACE_ENGINEER_LEVELS = [ + "profile_none", + "profile_trait_engineer", + "profile_trait_engineer_advanced", +] as const; + +const ENGINE_FLAGS: ReadonlyArray<[keyof UnitTraits, string]> = [ + ["medic", "profile_trait_medic"], + ["engineer", "profile_trait_engineer"], + ["explosiveSpecialist", "profile_trait_eod"], + ["uavHacker", "profile_trait_uav"], +]; + +const COEFFICIENTS: ReadonlyArray<[keyof UnitTraits, string]> = [ + ["loadCoef", "profile_trait_load_coef"], + ["camouflageCoef", "profile_trait_camo_coef"], + ["audibleCoef", "profile_trait_audible_coef"], +]; + +/** + * Every trait the recording carries, grouped the way the tab shows them. Only + * groups with at least one field are returned, so a vanilla recording has no + * ACE section and a unit without custom traits has no custom section. + */ +export function traitGroups(traits: UnitTraits | undefined): TraitGroup[] { + if (!traits) return []; + const groups: TraitGroup[] = []; + + const engine: TraitField[] = []; + for (const [key, labelKey] of ENGINE_FLAGS) { + const value = traits[key]; + if (typeof value === "boolean") engine.push({ id: key, labelKey, kind: "bool", value }); + } + if (engine.length) groups.push({ id: "engine", titleKey: "profile_traits_engine", fields: engine }); + + const ace: TraitField[] = []; + if (typeof traits.ace?.medic === "number") { + ace.push({ id: "ace.medic", labelKey: "profile_trait_ace_medic", kind: "level", value: traits.ace.medic, levelKeys: ACE_MEDIC_LEVELS }); + } + if (typeof traits.ace?.engineer === "number") { + ace.push({ id: "ace.engineer", labelKey: "profile_trait_ace_engineer", kind: "level", value: traits.ace.engineer, levelKeys: ACE_ENGINEER_LEVELS }); + } + if (typeof traits.ace?.eod === "boolean") { + ace.push({ id: "ace.eod", labelKey: "profile_trait_ace_eod", kind: "bool", value: traits.ace.eod }); + } + if (ace.length) groups.push({ id: "ace", titleKey: "profile_traits_ace", fields: ace }); + + const coefficients: TraitField[] = []; + for (const [key, labelKey] of COEFFICIENTS) { + const value = traits[key]; + if (typeof value === "number" && Number.isFinite(value)) { + coefficients.push({ id: key, labelKey, kind: "number", value, multiplier: true }); + } + } + if (coefficients.length) groups.push({ id: "coefficients", titleKey: "profile_traits_coefficients", fields: coefficients }); + + const custom: TraitField[] = []; + for (const [name, value] of Object.entries(traits.custom ?? {})) { + custom.push({ + id: `custom:${name}`, + labelKey: name, + kind: typeof value === "boolean" ? "bool" : typeof value === "number" ? "number" : "text", + value, + }); + } + if (custom.length) groups.push({ id: "custom", titleKey: "profile_traits_custom", fields: custom }); + + return groups; +} + +/** The short label of a "level" field, as a locale key. */ +export function traitLevelKey(field: TraitField, value: unknown): string | undefined { + if (!field.levelKeys) return undefined; + const index = Number(value); + return field.levelKeys[Math.max(0, Math.min(field.levelKeys.length - 1, Number.isFinite(index) ? index : 0))]; +} + +/** A multiplier written the way the Arsenal and the editor show one: ×0.8, ×1.25. */ +export function formatMultiplier(value: unknown): string { + const n = Number(value); + if (!Number.isFinite(n)) return String(value); + return `×${Number.isInteger(n) ? n : n.toFixed(2).replace(/0+$/, "").replace(/\.$/, "")}`; +} + +/** Fields whose value differs between the two snapshots, in tab order. */ +export function diffTraits(from: UnitTraits | undefined, to: UnitTraits | undefined): TraitDelta[] { + const before = new Map(); + for (const group of traitGroups(from)) for (const field of group.fields) before.set(field.id, field); + const result: TraitDelta[] = []; + for (const group of traitGroups(to)) { + for (const field of group.fields) { + const prev = before.get(field.id); + before.delete(field.id); + if (prev?.value === field.value) continue; + result.push({ ...field, from: prev?.value, to: field.value }); + } + } + for (const field of before.values()) result.push({ ...field, from: field.value, to: undefined }); + return result; +} From 2c82c8e59bfc5de06f562d9e0f05bc6f7a19cbe1 Mon Sep 17 00:00:00 2001 From: Yethe Samartaka Date: Mon, 14 Sep 2026 20:51:08 +0200 Subject: [PATCH 23/39] Added weather data --- internal/storage/converted_recording_test.go | 4 +- internal/storage/parser_v1.go | 4 +- internal/storage/player_snapshot_test.go | 28 +++ .../storage/player_snapshot_writer_test.go | 3 +- internal/storage/player_snapshots.go | 7 +- internal/storage/player_snapshots_test.go | 19 +- ui/src/data/__tests__/weather.test.ts | 76 ++++++++ .../decoders/__tests__/jsonDecoder.test.ts | 40 +++++ .../__tests__/protobufDecoder.test.ts | 40 +++++ ui/src/data/decoders/jsonDecoder.ts | 6 + ui/src/data/decoders/protobufDecoder.ts | 10 ++ ui/src/data/types.ts | 26 +++ ui/src/data/weather.ts | 72 ++++++++ ui/src/i18n/locales.ts | 170 ++++++++++++++++++ .../__tests__/StatsTab.test.tsx | 90 ++++++++++ .../components/SidePanel.module.css | 50 ++++++ .../components/StatsTab.tsx | 86 +++++++++ .../playback/__tests__/weatherStats.test.ts | 89 +++++++++ ui/src/playback/engine.ts | 3 + ui/src/playback/eventManager.ts | 18 +- ui/src/playback/events/weatherEvent.ts | 13 ++ 21 files changed, 839 insertions(+), 15 deletions(-) create mode 100644 ui/src/data/__tests__/weather.test.ts create mode 100644 ui/src/data/weather.ts create mode 100644 ui/src/playback/__tests__/weatherStats.test.ts create mode 100644 ui/src/playback/events/weatherEvent.ts diff --git a/internal/storage/converted_recording_test.go b/internal/storage/converted_recording_test.go index 17cc0f651..4253650fc 100644 --- a/internal/storage/converted_recording_test.go +++ b/internal/storage/converted_recording_test.go @@ -51,7 +51,7 @@ func TestParserV1_ParsesConvertedRecording(t *testing.T) { switch event.Type { case "inventorySnapshot", "medicalSnapshot", "staminaSnapshot", "radioSnapshot", "serverFps", - "zeusEntity", "zeusRemoteControl", "zeusCamera": + "weather", "zeusEntity", "zeusRemoteControl", "zeusCamera": // The payload survives as JSON in the generic message field, which is // what the web decoder parses back out on the other side. require.NotEmpty(t, event.Message, "%s at frame %d lost its payload", event.Type, event.FrameNum) @@ -59,7 +59,7 @@ func TestParserV1_ParsesConvertedRecording(t *testing.T) { require.NoError(t, json.Unmarshal([]byte(event.Message), &payload), "%s at frame %d is not valid JSON", event.Type, event.FrameNum) switch event.Type { - case "serverFps": + case "serverFps", "weather": case "zeusEntity", "zeusRemoteControl", "zeusCamera": require.Contains(t, payload, "curatorId", "%s at frame %d has no curatorId to key it by", event.Type, event.FrameNum) diff --git a/internal/storage/parser_v1.go b/internal/storage/parser_v1.go index e8c31e679..7c7f3e00c 100644 --- a/internal/storage/parser_v1.go +++ b/internal/storage/parser_v1.go @@ -167,13 +167,13 @@ func parseEventArray(evtArr []interface{}) *Event { return event } - // Additive player detail and server FPS events keep their structured payload as JSON in + // Additive player-detail, weather, and server FPS events keep their structured payload as JSON in // the generic protobuf message field. Older readers safely ignore these // unknown event names, while newer readers can reconstruct the object. if event.Type == "inventorySnapshot" || event.Type == "medicalSnapshot" || event.Type == "staminaSnapshot" || event.Type == "radioSnapshot" || event.Type == "tfarSettings" || event.Type == "acreSettings" || - event.Type == "serverFps" || event.Type == "zeusEntity" || + event.Type == "serverFps" || event.Type == "weather" || event.Type == "zeusEntity" || event.Type == "zeusRemoteControl" || event.Type == "zeusCamera" || event.Type == "zeusPing" || event.Type == "explosion" || event.Type == "serviceEvent" || event.Type == "staticWeapon" || diff --git a/internal/storage/player_snapshot_test.go b/internal/storage/player_snapshot_test.go index dae938674..e80702e3c 100644 --- a/internal/storage/player_snapshot_test.go +++ b/internal/storage/player_snapshot_test.go @@ -147,6 +147,34 @@ func TestParseServerFpsEventPreservesPayload(t *testing.T) { require.Equal(t, 47.25, payload["fps"]) } +func TestParseWeatherEventPreservesPayload(t *testing.T) { + event := parseEventArray([]interface{}{ + float64(60), + "weather", + map[string]interface{}{ + "overcast": 0.42, + "rain": 0.1, + "fog": 0.2, + "windSpeed": 3.4, + "windDir": 247.0, + "humidity": 0.68, + "moonPhase": 0.81, + "temperature": 16.3, + "ace": true, + }, + }) + + require.NotNil(t, event) + require.Equal(t, "weather", event.Type) + require.Equal(t, uint32(60), event.FrameNum) + + var payload map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(event.Message), &payload)) + require.Equal(t, 0.42, payload["overcast"]) + require.Equal(t, 16.3, payload["temperature"]) + require.Equal(t, true, payload["ace"]) +} + func TestParseZeusPingEventPreservesPayload(t *testing.T) { event := parseEventArray([]interface{}{ float64(120), diff --git a/internal/storage/player_snapshot_writer_test.go b/internal/storage/player_snapshot_writer_test.go index 837f54456..b687cab18 100644 --- a/internal/storage/player_snapshot_writer_test.go +++ b/internal/storage/player_snapshot_writer_test.go @@ -25,6 +25,7 @@ func TestProtobufWriterV1MovesPlayerSnapshotsToSidecars(t *testing.T) { WorldName: "altis", MissionName: "snapshot writer", EndFrame: 99, ChunkSize: 50, Events: []Event{ {FrameNum: 1, Type: "serverFps", Message: `{"fps":48}`}, + {FrameNum: 1, Type: "weather", Message: `{"overcast":0.4,"rain":0,"fog":0.1,"moonPhase":0.8}`}, {FrameNum: 1, Type: "tfarSettings", Message: `{"terrainInterceptionCoefficient":7,"globalRadioRangeCoef":1}`}, {FrameNum: 1, Type: "acreSettings", Message: `{"terrainLoss":1,"signalModel":2}`}, {FrameNum: 5, Type: "inventorySnapshot", Message: `{"unitId":7,"massUnits":695}`}, @@ -50,7 +51,7 @@ func TestProtobufWriterV1MovesPlayerSnapshotsToSidecars(t *testing.T) { assert.False(t, IsPlayerSnapshotEvent(e.Type), "player snapshot %s left in manifest", e.Type) kept[e.Type]++ } - assert.Equal(t, map[string]int{"serverFps": 1, "tfarSettings": 1, "acreSettings": 1, "killed": 1}, kept) + assert.Equal(t, map[string]int{"serverFps": 1, "weather": 1, "tfarSettings": 1, "acreSettings": 1, "killed": 1}, kept) require.Equal(t, []uint32{7, 12}, manifest.SnapshotUnitIds) for _, unitID := range manifest.SnapshotUnitIds { diff --git a/internal/storage/player_snapshots.go b/internal/storage/player_snapshots.go index c40715521..d4073fa34 100644 --- a/internal/storage/player_snapshots.go +++ b/internal/storage/player_snapshots.go @@ -21,9 +21,10 @@ const SnapshotsDirName = "snapshots" // one unit at a time, so the protobuf writer moves them out of the manifest // into snapshots/.pb and the UI fetches the one series it needs. // -// serverFps, tfarSettings, acreSettings and the Zeus events are deliberately -// absent: they are needed for map-wide playback (Stats, range circles, VIRTUAL -// faction, camera cones) without selecting a unit, so they stay in the manifest. +// serverFps, weather, tfarSettings, acreSettings and the Zeus events are +// deliberately absent: they are needed for map-wide playback (Stats, range +// circles, VIRTUAL faction, camera cones) without selecting a unit, so they +// stay in the manifest. var playerSnapshotEventTypes = map[string]struct{}{ "inventorySnapshot": {}, "medicalSnapshot": {}, diff --git a/internal/storage/player_snapshots_test.go b/internal/storage/player_snapshots_test.go index 60d62f3e1..6c1447100 100644 --- a/internal/storage/player_snapshots_test.go +++ b/internal/storage/player_snapshots_test.go @@ -19,6 +19,7 @@ func snapshotEvent(frame uint32, eventType, message string) *pbv1.Event { func TestSplitPlayerSnapshots(t *testing.T) { events := []*pbv1.Event{ snapshotEvent(1, "serverFps", `{"fps":48}`), + snapshotEvent(1, "weather", `{"overcast":0.4,"moonPhase":0.8}`), snapshotEvent(5, "inventorySnapshot", `{"unitId":7,"massUnits":695}`), snapshotEvent(6, "killed", ""), snapshotEvent(9, "medicalSnapshot", `{"unitId":12,"ace":{"heartRate":80}}`), @@ -31,12 +32,13 @@ func TestSplitPlayerSnapshots(t *testing.T) { manifest, byUnit := SplitPlayerSnapshots(events) // Global events stay where every reader already looks for them. - require.Len(t, manifest, 5) + require.Len(t, manifest, 6) require.Equal(t, "serverFps", manifest[0].Type) - require.Equal(t, "killed", manifest[1].Type) - require.Equal(t, "tfarSettings", manifest[2].Type) - require.Equal(t, "zeusEntity", manifest[3].Type) - require.Equal(t, "zeusCamera", manifest[4].Type) + require.Equal(t, "weather", manifest[1].Type) + require.Equal(t, "killed", manifest[2].Type) + require.Equal(t, "tfarSettings", manifest[3].Type) + require.Equal(t, "zeusEntity", manifest[4].Type) + require.Equal(t, "zeusCamera", manifest[5].Type) require.Equal(t, []uint32{7, 12}, SortedUnitIDs(byUnit)) require.Len(t, byUnit[7], 2) @@ -113,6 +115,7 @@ func TestConverterWritesPlayerSnapshotSidecars(t *testing.T) { ], "events": [ [1, "serverFps", {"fps": 48}], + [1, "weather", {"overcast": 0.4, "moonPhase": 0.8, "ace": false}], [2, "zeusEntity", {"curatorId": 90, "name": "Danny", "playerUid": "7656", "bodyUnitId": -1}], [3, "zeusCamera", {"curatorId": 90, "x": 100, "y": 200, "dir": 90, "fov": 0.75}], [4, "zeusRemoteControl", {"curatorId": 90, "unitId": 7, "active": true}], @@ -138,13 +141,17 @@ func TestConverterWritesPlayerSnapshotSidecars(t *testing.T) { require.False(t, IsPlayerSnapshotEvent(e.Type), "player snapshot %s left in manifest", e.Type) } // The global samples are untouched by the split. - var sawFps bool + var sawFps, sawWeather bool for _, e := range manifest.Events { if e.Type == "serverFps" { sawFps = true } + if e.Type == "weather" { + sawWeather = true + } } require.True(t, sawFps, "serverFps must stay in the manifest") + require.True(t, sawWeather, "weather must stay in the manifest") var sawZeus int for _, e := range manifest.Events { if e.Type == "zeusEntity" || e.Type == "zeusCamera" || e.Type == "zeusRemoteControl" { diff --git a/ui/src/data/__tests__/weather.test.ts b/ui/src/data/__tests__/weather.test.ts new file mode 100644 index 000000000..d88a8e98d --- /dev/null +++ b/ui/src/data/__tests__/weather.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { + compassFromDegrees, + formatPercent, + moonPhaseEmoji, + moonPhaseName, + parseWeatherPayload, +} from "../weather"; + +describe("parseWeatherPayload", () => { + it("requires a finite overcast reading", () => { + expect(parseWeatherPayload(null)).toBeNull(); + expect(parseWeatherPayload([])).toBeNull(); + expect(parseWeatherPayload({ rain: 0.5 })).toBeNull(); + expect(parseWeatherPayload({ overcast: "0.4" })).toBeNull(); + }); + + it("keeps optional ACE fields and drops malformed extras", () => { + expect(parseWeatherPayload({ + overcast: 0.42, + rain: 0.1, + fog: 0.2, + windSpeed: 3.4, + windDir: 247, + humidity: 0.68, + moonPhase: 0.81, + temperature: 16.3, + ace: true, + gusts: "nope", + })).toEqual({ + overcast: 0.42, + rain: 0.1, + fog: 0.2, + windSpeed: 3.4, + windDir: 247, + humidity: 0.68, + moonPhase: 0.81, + temperature: 16.3, + ace: true, + }); + }); + + it("accepts a vanilla-only sample without temperature", () => { + expect(parseWeatherPayload({ overcast: 0, rain: 0, fog: 0, ace: false })).toEqual({ + overcast: 0, + rain: 0, + fog: 0, + ace: false, + }); + }); +}); + +describe("weather display helpers", () => { + it("maps meteorological windDir to an 8-point compass", () => { + expect(compassFromDegrees(0)).toBe("N"); + expect(compassFromDegrees(45)).toBe("NE"); + expect(compassFromDegrees(247)).toBe("SW"); + expect(compassFromDegrees(360)).toBe("N"); + expect(compassFromDegrees(-45)).toBe("NW"); + }); + + it("names moon illumination without claiming waxing or waning", () => { + expect(moonPhaseName(0)).toBe("new"); + expect(moonPhaseName(0.2)).toBe("crescent"); + expect(moonPhaseName(0.5)).toBe("quarter"); + expect(moonPhaseName(0.8)).toBe("gibbous"); + expect(moonPhaseName(1)).toBe("full"); + expect(moonPhaseEmoji(0)).toBe("🌑"); + expect(moonPhaseEmoji(1)).toBe("🌕"); + }); + + it("formats 0-1 readings as percents", () => { + expect(formatPercent(0.42)).toBe("42%"); + expect(formatPercent(0)).toBe("0%"); + }); +}); diff --git a/ui/src/data/decoders/__tests__/jsonDecoder.test.ts b/ui/src/data/decoders/__tests__/jsonDecoder.test.ts index 1ec0d2a8b..1ef88e4bb 100644 --- a/ui/src/data/decoders/__tests__/jsonDecoder.test.ts +++ b/ui/src/data/decoders/__tests__/jsonDecoder.test.ts @@ -41,6 +41,46 @@ describe("JsonDecoder.decodeManifest", () => { expect(manifest.events).toEqual([{ frameNum: 0, type: "serverFps", fps: 47.25 }]); }); + it("decodes additive weather events and rejects samples without overcast", () => { + const manifest = decoder.decodeManifest(toBuffer({ + worldName: "Altis", + missionName: "Weather", + endFrame: 180, + captureDelay: 1, + events: [ + [0, "weather", { + overcast: 0.42, + rain: 0.1, + fog: 0.2, + windSpeed: 3.4, + windDir: 247, + humidity: 0.68, + moonPhase: 0.81, + temperature: 16.3, + ace: true, + }], + [60, "weather", { rain: 0.5 }], + [120, "weather", { overcast: "bad" }], + ], + })); + + expect(manifest.events).toEqual([{ + frameNum: 0, + type: "weather", + payload: { + overcast: 0.42, + rain: 0.1, + fog: 0.2, + windSpeed: 3.4, + windDir: 247, + humidity: 0.68, + moonPhase: 0.81, + temperature: 16.3, + ace: true, + }, + }]); + }); + it("decodes additive Zeus events and skips malformed payloads", () => { const manifest = decoder.decodeManifest(toBuffer({ worldName: "Altis", diff --git a/ui/src/data/decoders/__tests__/protobufDecoder.test.ts b/ui/src/data/decoders/__tests__/protobufDecoder.test.ts index adc220656..046e812de 100644 --- a/ui/src/data/decoders/__tests__/protobufDecoder.test.ts +++ b/ui/src/data/decoders/__tests__/protobufDecoder.test.ts @@ -140,6 +140,46 @@ describe("ProtobufDecoder.decodeManifest", () => { ]); }); + it("decodes additive weather payloads from the generic event message", () => { + const buffer = encodePb(PbManifest, { + version: 1, + worldName: "Altis", + missionName: "Weather", + endFrame: 180, + chunkSize: 300, + captureDelayMs: 1000, + chunkCount: 1, + events: [ + { + frameNum: 60, + type: "weather", + message: JSON.stringify({ + overcast: 0.42, + rain: 0.1, + moonPhase: 0.81, + temperature: 16.3, + ace: true, + }), + }, + { frameNum: 120, type: "weather", message: JSON.stringify({ rain: 0.5 }) }, + ], + }); + + expect(decoder.decodeManifest(buffer).events).toEqual([ + { + frameNum: 60, + type: "weather", + payload: { + overcast: 0.42, + rain: 0.1, + moonPhase: 0.81, + temperature: 16.3, + ace: true, + }, + }, + ]); + }); + it("decodes additive Zeus payloads from the generic event message", () => { const buffer = encodePb(PbManifest, { version: 1, diff --git a/ui/src/data/decoders/jsonDecoder.ts b/ui/src/data/decoders/jsonDecoder.ts index 79f0c098a..d3872c962 100644 --- a/ui/src/data/decoders/jsonDecoder.ts +++ b/ui/src/data/decoders/jsonDecoder.ts @@ -16,6 +16,7 @@ import { parseAcreSettingsPayload, parseTfarSettingsPayload, } from "../radioPropagation"; +import { parseWeatherPayload } from "../weather"; // ───────── Legacy JSON shape ───────── @@ -339,6 +340,11 @@ function convertEvent(raw: RawJsonEvent): EventDef | null { if (typeof fps !== "number" || !Number.isFinite(fps) || fps < 0) return null; return { frameNum, type, fps }; } + case "weather": { + const payload = parseWeatherPayload(raw[2]); + if (!payload) return null; + return { frameNum, type, payload }; + } case "explosion": { const payload = raw[2]; if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; diff --git a/ui/src/data/decoders/protobufDecoder.ts b/ui/src/data/decoders/protobufDecoder.ts index 2db5ab72c..7df75c82e 100644 --- a/ui/src/data/decoders/protobufDecoder.ts +++ b/ui/src/data/decoders/protobufDecoder.ts @@ -17,6 +17,7 @@ import { parseAcreSettingsPayload, parseTfarSettingsPayload, } from "../radioPropagation"; +import { parseWeatherPayload } from "../weather"; import { Manifest as PbManifest, Chunk as PbChunk, @@ -198,6 +199,15 @@ function convertEvent(pb: PbEvent): EventDef | null { return null; } } + case "weather": { + try { + const payload = parseWeatherPayload(JSON.parse(pb.message)); + if (!payload) return null; + return { frameNum, type, payload }; + } catch { + return null; + } + } case "explosion": { try { const payload = JSON.parse(pb.message); diff --git a/ui/src/data/types.ts b/ui/src/data/types.ts index 79883c21a..f632c9c22 100644 --- a/ui/src/data/types.ts +++ b/ui/src/data/types.ts @@ -301,6 +301,31 @@ export interface ServerFpsEventDef { fps: number; } +/** + * One global weather sample. Vanilla environment commands are always present. + * `temperature` and ACE humidity are written only when ACE Weather is enabled + * via CBA; older recordings omit the whole event. + */ +export interface WeatherPayload { + overcast: number; + rain?: number; + fog?: number; + windSpeed?: number; + windDir?: number; + gusts?: number; + lightnings?: number; + humidity?: number; + waves?: number; + moonPhase?: number; + temperature?: number; + ace?: boolean; +} + +export interface WeatherEventDef { + type: "weather"; + payload: WeatherPayload; +} + export interface ZeusEntityPayload { curatorId: number; name: string; @@ -460,6 +485,7 @@ export type EventDef = { frameNum: number } & ( | TfarSettingsEventDef | AcreSettingsEventDef | ServerFpsEventDef + | WeatherEventDef | ZeusEntityEventDef | ZeusRemoteControlEventDef | ZeusCameraEventDef diff --git a/ui/src/data/weather.ts b/ui/src/data/weather.ts new file mode 100644 index 000000000..7b52d03e3 --- /dev/null +++ b/ui/src/data/weather.ts @@ -0,0 +1,72 @@ +import type { WeatherPayload } from "./types"; + +function finiteNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +/** + * Accept a weather sample if it carries the core vanilla overcast reading. + * Every other field is optional so a partial payload still plays, and a + * recording made before this event existed is simply skipped. + */ +export function parseWeatherPayload(raw: unknown): WeatherPayload | null { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + const overcast = finiteNumber((raw as { overcast?: unknown }).overcast); + if (overcast === undefined) return null; + + const payload: WeatherPayload = { overcast }; + const source = raw as Record; + const rain = finiteNumber(source.rain); + if (rain !== undefined) payload.rain = rain; + const fog = finiteNumber(source.fog); + if (fog !== undefined) payload.fog = fog; + const windSpeed = finiteNumber(source.windSpeed); + if (windSpeed !== undefined) payload.windSpeed = windSpeed; + const windDir = finiteNumber(source.windDir); + if (windDir !== undefined) payload.windDir = windDir; + const gusts = finiteNumber(source.gusts); + if (gusts !== undefined) payload.gusts = gusts; + const lightnings = finiteNumber(source.lightnings); + if (lightnings !== undefined) payload.lightnings = lightnings; + const humidity = finiteNumber(source.humidity); + if (humidity !== undefined) payload.humidity = humidity; + const waves = finiteNumber(source.waves); + if (waves !== undefined) payload.waves = waves; + const moonPhase = finiteNumber(source.moonPhase); + if (moonPhase !== undefined) payload.moonPhase = moonPhase; + const temperature = finiteNumber(source.temperature); + if (temperature !== undefined) payload.temperature = temperature; + if (typeof source.ace === "boolean") payload.ace = source.ace; + return payload; +} + +const COMPASS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] as const; + +/** Meteorological windDir: degrees the wind is coming FROM. */ +export function compassFromDegrees(degrees: number): string { + const normalized = ((degrees % 360) + 360) % 360; + return COMPASS[Math.round(normalized / 45) % 8]; +} + +export type MoonPhaseName = "new" | "crescent" | "quarter" | "gibbous" | "full"; + +/** `moonPhase` is illumination only (0 new, 1 full); it does not say waxing vs waning. */ +export function moonPhaseName(phase: number): MoonPhaseName { + if (phase < 0.03) return "new"; + if (phase < 0.35) return "crescent"; + if (phase < 0.65) return "quarter"; + if (phase < 0.97) return "gibbous"; + return "full"; +} + +export function moonPhaseEmoji(phase: number): string { + if (phase < 0.125) return "🌑"; + if (phase < 0.375) return "🌒"; + if (phase < 0.625) return "🌓"; + if (phase < 0.875) return "🌔"; + return "🌕"; +} + +export function formatPercent(value: number): string { + return `${Math.round(value * 100)}%`; +} diff --git a/ui/src/i18n/locales.ts b/ui/src/i18n/locales.ts index e3192be18..d682059d1 100644 --- a/ui/src/i18n/locales.ts +++ b/ui/src/i18n/locales.ts @@ -1967,6 +1967,176 @@ export const translations: Record> = { fi: "Mediaani", uk: "Медіана", }, + weather: { + en: "Weather", + ru: "Погода", + de: "Wetter", + cs: "Počasí", + it: "Meteo", + fr: "Météo", + fi: "Sää", + uk: "Погода", + }, + weather_overcast: { + en: "Overcast", + ru: "Облачность", + de: "Bewölkung", + cs: "Oblačnost", + it: "Nuvolosità", + fr: "Couverture", + fi: "Pilvisyys", + uk: "Хмарність", + }, + weather_rain: { + en: "Rain", + ru: "Дождь", + de: "Regen", + cs: "Déšť", + it: "Pioggia", + fr: "Pluie", + fi: "Sade", + uk: "Дощ", + }, + weather_fog: { + en: "Fog", + ru: "Туман", + de: "Nebel", + cs: "Mlha", + it: "Nebbia", + fr: "Brouillard", + fi: "Sumu", + uk: "Туман", + }, + weather_wind: { + en: "Wind", + ru: "Ветер", + de: "Wind", + cs: "Vítr", + it: "Vento", + fr: "Vent", + fi: "Tuuli", + uk: "Вітер", + }, + weather_humidity: { + en: "Humidity", + ru: "Влажность", + de: "Luftfeuchte", + cs: "Vlhkost", + it: "Umidità", + fr: "Humidité", + fi: "Kosteus", + uk: "Вологість", + }, + weather_temperature: { + en: "Temperature", + ru: "Температура", + de: "Temperatur", + cs: "Teplota", + it: "Temperatura", + fr: "Température", + fi: "Lämpötila", + uk: "Температура", + }, + weather_gusts: { + en: "Gusts", + ru: "Порывы", + de: "Böen", + cs: "Poryvy", + it: "Raffiche", + fr: "Rafales", + fi: "Puuskat", + uk: "Пориви", + }, + weather_lightnings: { + en: "Lightning", + ru: "Молнии", + de: "Blitze", + cs: "Blesky", + it: "Fulmini", + fr: "Foudre", + fi: "Salamat", + uk: "Блискавки", + }, + weather_waves: { + en: "Waves", + ru: "Волны", + de: "Wellen", + cs: "Vlny", + it: "Onde", + fr: "Vagues", + fi: "Aallot", + uk: "Хвилі", + }, + weather_ace: { + en: "ACE", + ru: "ACE", + de: "ACE", + cs: "ACE", + it: "ACE", + fr: "ACE", + fi: "ACE", + uk: "ACE", + }, + weather_vanilla: { + en: "Vanilla", + ru: "Ваниль", + de: "Vanilla", + cs: "Vanilla", + it: "Vanilla", + fr: "Vanilla", + fi: "Vanilla", + uk: "Ваніль", + }, + weather_moon_new: { + en: "New Moon", + ru: "Новолуние", + de: "Neumond", + cs: "Novoluní", + it: "Luna nuova", + fr: "Nouvelle lune", + fi: "Uusikuu", + uk: "Молодик", + }, + weather_moon_crescent: { + en: "Crescent", + ru: "Серп", + de: "Sichel", + cs: "Srp", + it: "Falce", + fr: "Croissant", + fi: "Sirppi", + uk: "Серп", + }, + weather_moon_quarter: { + en: "Quarter", + ru: "Четверть", + de: "Viertel", + cs: "Čtvrť", + it: "Quarto", + fr: "Quartier", + fi: "Neljännes", + uk: "Чверть", + }, + weather_moon_gibbous: { + en: "Gibbous", + ru: "Выпуклая", + de: "Gibbous", + cs: "Vypuklý", + it: "Gibbosa", + fr: "Gibbeuse", + fi: "Kupera", + uk: "Опуклий", + }, + weather_moon_full: { + en: "Full Moon", + ru: "Полнолуние", + de: "Vollmond", + cs: "Úplněk", + it: "Luna piena", + fr: "Pleine lune", + fi: "Täysikuu", + uk: "Повня", + }, strength: { ru: "Численность", en: "Strength", diff --git a/ui/src/pages/recording-playback/__tests__/StatsTab.test.tsx b/ui/src/pages/recording-playback/__tests__/StatsTab.test.tsx index d36fa3948..c1b93102d 100644 --- a/ui/src/pages/recording-playback/__tests__/StatsTab.test.tsx +++ b/ui/src/pages/recording-playback/__tests__/StatsTab.test.tsx @@ -58,6 +58,7 @@ describe("StatsTab", () => { )); expect(screen.queryByText("Server FPS")).toBeNull(); + expect(screen.queryByText("Weather")).toBeNull(); expect(screen.getByText("Force Summary")).toBeTruthy(); }); @@ -107,6 +108,95 @@ describe("StatsTab", () => { } }); + it("shows playhead-scoped weather after the leaderboard when samples exist", () => { + const { engine, renderer } = createTestEngine(); + engine.loadRecording(makeManifest( + [unitDef({ id: 1, name: "Alpha", side: "WEST", isPlayer: true })], + [ + killedEvent(10, 1, 1), + { + frameNum: 0, + type: "weather", + payload: { + overcast: 0.1, + rain: 0, + fog: 0, + moonPhase: 0, + ace: false, + }, + }, + { + frameNum: 60, + type: "weather", + payload: { + overcast: 0.42, + rain: 0.1, + fog: 0.2, + windSpeed: 3.4, + windDir: 247, + humidity: 0.68, + moonPhase: 0.81, + temperature: 16.3, + ace: true, + }, + }, + { + frameNum: 120, + type: "weather", + payload: { + overcast: 0.9, + rain: 1, + humidity: 1, + moonPhase: 1, + ace: true, + }, + }, + ], + 180, + )); + engine.seekTo(60); + + render(() => ( + + + + )); + + expect(screen.getByText("Weather")).toBeTruthy(); + expect(screen.getByText("ACE")).toBeTruthy(); + expect(screen.getByText("42%")).toBeTruthy(); + expect(screen.getByText("10%")).toBeTruthy(); + expect(screen.getByText("20%")).toBeTruthy(); + expect(screen.getByText("3.4 m/s")).toBeTruthy(); + expect(screen.getByText("Wind SW 247°")).toBeTruthy(); + expect(screen.getByText("68%")).toBeTruthy(); + expect(screen.getByText("16.3 °C")).toBeTruthy(); + expect(screen.getByText("Gibbous")).toBeTruthy(); + expect(screen.queryByText("Full Moon")).toBeNull(); + expect(screen.queryByText("90%")).toBeNull(); + + const forceHeading = screen.getByText("Force Summary"); + const leaderboardHeading = screen.getByText("Leaderboard"); + const weatherHeading = screen.getByText("Weather"); + expect(forceHeading.compareDocumentPosition(leaderboardHeading) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(leaderboardHeading.compareDocumentPosition(weatherHeading) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it("hides weather for backwards-compatible recordings without samples", () => { + const { engine, renderer } = createTestEngine(); + engine.loadRecording(makeManifest([unitDef({ id: 1, side: "WEST" })])); + + render(() => ( + + + + )); + + expect(screen.queryByText("Weather")).toBeNull(); + expect(screen.queryByText("Overcast")).toBeNull(); + expect(screen.getByText("Force Summary")).toBeTruthy(); + }); + it("shows force cards only for sides with units", () => { const { engine, renderer } = createTestEngine(); engine.loadRecording( diff --git a/ui/src/pages/recording-playback/components/SidePanel.module.css b/ui/src/pages/recording-playback/components/SidePanel.module.css index 3a84ed0ed..e4adb6479 100644 --- a/ui/src/pages/recording-playback/components/SidePanel.module.css +++ b/ui/src/pages/recording-playback/components/SidePanel.module.css @@ -646,6 +646,56 @@ text-transform: uppercase; } +.weatherHeader { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.weatherSource { + color: var(--text-dimmer); + font-family: var(--font-mono); + font-size: var(--font-size-xs); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.weatherCard { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 4px; + margin-top: 8px; + padding: 8px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; +} + +.weatherMetric { + padding: 7px 3px; + text-align: center; + border-radius: 5px; +} + +.weatherValue { + font-family: var(--font-mono); + font-size: var(--font-size-md); + font-weight: 700; + line-height: 1.15; + color: var(--text-on-accent); +} + +.weatherLabel { + margin-top: 4px; + color: var(--text-on-accent); + font-family: var(--font-mono); + font-size: var(--font-size-xs); + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + .forceSummary { display: flex; flex-direction: column; diff --git a/ui/src/pages/recording-playback/components/StatsTab.tsx b/ui/src/pages/recording-playback/components/StatsTab.tsx index c0f375038..22e4a4cd2 100644 --- a/ui/src/pages/recording-playback/components/StatsTab.tsx +++ b/ui/src/pages/recording-playback/components/StatsTab.tsx @@ -9,6 +9,12 @@ import { buildMovementStats, formatDistance } from "../movement"; import styles from "./SidePanel.module.css"; import commsStyles from "./CommsTab.module.css"; import movementStyles from "./MovementTable.module.css"; +import { + compassFromDegrees, + formatPercent, + moonPhaseEmoji, + moonPhaseName, +} from "../../../data/weather"; const SIDES: Side[] = ["WEST", "EAST", "GUER", "CIV", "VIRTUAL"]; @@ -48,6 +54,63 @@ export function StatsTab(): JSX.Element { engine.eventManager.getServerFpsStats(engine.currentFrame()), ); + const weather = createMemo(() => + engine.eventManager.getWeatherAt(engine.currentFrame()), + ); + + const weatherMetrics = createMemo(() => { + const sample = weather(); + if (!sample) return undefined; + const metrics: { value: string; label: string }[] = [ + { value: formatPercent(sample.overcast), label: t("weather_overcast") }, + ]; + if (sample.rain !== undefined) { + metrics.push({ value: formatPercent(sample.rain), label: t("weather_rain") }); + } + if (sample.fog !== undefined) { + metrics.push({ value: formatPercent(sample.fog), label: t("weather_fog") }); + } + if (sample.windSpeed !== undefined) { + const dir = sample.windDir !== undefined + ? `${compassFromDegrees(sample.windDir)} ${Math.round(sample.windDir)}°` + : ""; + metrics.push({ + value: `${sample.windSpeed.toFixed(1)} m/s`, + label: dir ? `${t("weather_wind")} ${dir}` : t("weather_wind"), + }); + } else if (sample.windDir !== undefined) { + metrics.push({ + value: `${compassFromDegrees(sample.windDir)} ${Math.round(sample.windDir)}°`, + label: t("weather_wind"), + }); + } + if (sample.humidity !== undefined) { + metrics.push({ value: formatPercent(sample.humidity), label: t("weather_humidity") }); + } + if (sample.temperature !== undefined) { + metrics.push({ + value: `${sample.temperature.toFixed(1)} °C`, + label: t("weather_temperature"), + }); + } + if (sample.moonPhase !== undefined) { + metrics.push({ + value: `${moonPhaseEmoji(sample.moonPhase)} ${formatPercent(sample.moonPhase)}`, + label: t(`weather_moon_${moonPhaseName(sample.moonPhase)}`), + }); + } + if (sample.gusts !== undefined) { + metrics.push({ value: formatPercent(sample.gusts), label: t("weather_gusts") }); + } + if (sample.lightnings !== undefined) { + metrics.push({ value: formatPercent(sample.lightnings), label: t("weather_lightnings") }); + } + if (sample.waves !== undefined) { + metrics.push({ value: formatPercent(sample.waves), label: t("weather_waves") }); + } + return metrics; + }); + // Distance covers the whole mission, so a chunked recording needs every chunk; // a JSON one resolves immediately and this never blocks. const [movementReady, setMovementReady] = createSignal(false); @@ -272,6 +335,29 @@ export function StatsTab(): JSX.Element {
+ + {(metrics) => ( +
+
+
{t("weather")}
+
+ {weather()?.ace ? t("weather_ace") : t("weather_vanilla")} +
+
+
+ + {(metric) => ( +
+
{metric.value}
+
{metric.label}
+
+ )} +
+
+
+ )} +
+ {/* ── Distance and pace ── */}
{t("movement")}
{ + let mgr: EventManager; + + beforeEach(() => { + mgr = new EventManager(); + }); + + const sample = (overrides: Partial = {}): WeatherPayload => ({ + overcast: 0.42, + rain: 0.1, + fog: 0.2, + moonPhase: 0.81, + ace: false, + ...overrides, + }); + + const addSamples = (entries: [number, WeatherPayload][]) => { + entries.forEach(([frame, payload], index) => { + mgr.addEvent(new WeatherEvent(frame, index, payload)); + }); + }; + + it("returns undefined when the recording carries no samples", () => { + expect(mgr.getWeatherAt(0)).toBeUndefined(); + expect(mgr.getWeatherAt(9999)).toBeUndefined(); + }); + + it("reports the first sample at frame 0", () => { + addSamples([[0, sample({ overcast: 0.2 })]]); + expect(mgr.getWeatherAt(0)?.overcast).toBe(0.2); + }); + + it("stays undefined while the playhead sits before the first sample", () => { + addSamples([[10, sample()]]); + expect(mgr.getWeatherAt(9)).toBeUndefined(); + expect(mgr.getWeatherAt(10)?.overcast).toBe(0.42); + }); + + it("returns the latest sample at or before the playhead", () => { + addSamples([ + [0, sample({ overcast: 0.1, ace: false })], + [60, sample({ overcast: 0.42, temperature: 16.3, ace: true })], + [120, sample({ overcast: 0.9 })], + ]); + + expect(mgr.getWeatherAt(0)?.overcast).toBe(0.1); + expect(mgr.getWeatherAt(59)?.overcast).toBe(0.1); + expect(mgr.getWeatherAt(60)?.temperature).toBe(16.3); + expect(mgr.getWeatherAt(60)?.ace).toBe(true); + expect(mgr.getWeatherAt(119)?.overcast).toBe(0.42); + expect(mgr.getWeatherAt(120)?.overcast).toBe(0.9); + }); + + it("orders samples by frame regardless of insertion order", () => { + addSamples([ + [120, sample({ overcast: 0.9 })], + [0, sample({ overcast: 0.1 })], + [60, sample({ overcast: 0.42 })], + ]); + + expect(mgr.getWeatherAt(60)?.overcast).toBe(0.42); + expect(mgr.getWeatherAt(120)?.overcast).toBe(0.9); + }); + + it("drops the samples on clear", () => { + addSamples([[0, sample()]]); + mgr.clear(); + expect(mgr.getWeatherAt(9999)).toBeUndefined(); + }); + + it("keeps weather samples out of the events feed", () => { + const killed = new GameEvent(15, "killed", 99); + mgr.addEvent(new WeatherEvent(0, 1, sample())); + mgr.addEvent(new ServerFpsEvent(0, 2, 40)); + mgr.addEvent(killed); + + expect(mgr.getAll()).toEqual([killed]); + expect(mgr.getActiveEvents(9999)).toEqual([killed]); + expect(mgr.getEventsAtFrame(0)).toEqual([]); + expect(mgr.getWeatherAt(0)).toBeDefined(); + }); +}); diff --git a/ui/src/playback/engine.ts b/ui/src/playback/engine.ts index 871a55950..5fb87de19 100644 --- a/ui/src/playback/engine.ts +++ b/ui/src/playback/engine.ts @@ -24,6 +24,7 @@ import { CapturedEvent } from "./events/capturedEvent"; import { TerminalHackEvent } from "./events/terminalHackEvent"; import { PlayerSnapshotEvent } from "./events/playerSnapshotEvent"; import { ServerFpsEvent } from "./events/serverFpsEvent"; +import { WeatherEvent } from "./events/weatherEvent"; import { ZeusCameraEvent, ZeusEntityEvent, @@ -87,6 +88,8 @@ function createGameEvent(def: EventDef): GameEvent | null { return null; case "serverFps": return new ServerFpsEvent(def.frameNum, id, def.fps); + case "weather": + return new WeatherEvent(def.frameNum, id, def.payload); case "zeusEntity": return new ZeusEntityEvent(def.frameNum, id, def.payload); case "zeusRemoteControl": diff --git a/ui/src/playback/eventManager.ts b/ui/src/playback/eventManager.ts index 0bd085b4f..74311350f 100644 --- a/ui/src/playback/eventManager.ts +++ b/ui/src/playback/eventManager.ts @@ -5,6 +5,7 @@ import { GameEvent } from "./events/gameEvent"; import { HitKilledEvent } from "./events/hitKilledEvent"; import { PlayerSnapshotEvent } from "./events/playerSnapshotEvent"; import { ServerFpsEvent } from "./events/serverFpsEvent"; +import { WeatherEvent } from "./events/weatherEvent"; import { ZeusCameraEvent, ZeusEntityEvent, @@ -13,7 +14,7 @@ import { } from "./events/zeusEvents"; import { ExplosionEvent, RadioTransmissionEvent } from "./events/supportEvents"; import { applySnapshotDiff, isSnapshotDiff } from "../data/snapshotDiff"; -import type { PlayerSnapshotPayload, PlayerSnapshotType } from "../data/types"; +import type { PlayerSnapshotPayload, PlayerSnapshotType, WeatherPayload } from "../data/types"; import { ZEUS_RC_KILL_GRACE_FRAMES, type ZeusCameraSample, type ZeusEntityInfo, type ZeusFrameState } from "./zeus"; /** @@ -26,6 +27,7 @@ export class EventManager { private frameIndex: Map = new Map(); private playerSnapshots = new Map>(); private serverFpsEvents: ServerFpsEvent[] = []; + private weatherEvents: WeatherEvent[] = []; private zeusEntities = new Map(); private zeusRemoteControl: ZeusRemoteControlEvent[] = []; private zeusCameras = new Map(); @@ -59,6 +61,11 @@ export class EventManager { this.serverFpsEvents.sort((a, b) => a.frameNum - b.frameNum); return; } + if (event instanceof WeatherEvent) { + this.weatherEvents.push(event); + this.weatherEvents.sort((a, b) => a.frameNum - b.frameNum); + return; + } if (event instanceof ZeusEntityEvent) { this.zeusEntities.set(event.payload.curatorId, { curatorId: event.payload.curatorId, @@ -213,6 +220,14 @@ export class EventManager { return { current: samples[samples.length - 1].fps, average, median }; } + /** Latest weather sample at or before the requested playback frame. */ + getWeatherAt(frame: number): WeatherPayload | undefined { + for (let i = this.weatherEvents.length - 1; i >= 0; i--) { + if (this.weatherEvents[i].frameNum <= frame) return this.weatherEvents[i].payload; + } + return undefined; + } + /** * Pings whose map lifetime covers `frame`, oldest first. * A ping is drawn from its own frame until `frameNum + lifetimeFrames`. @@ -497,6 +512,7 @@ export class EventManager { this.frameIndex = new Map(); this.playerSnapshots = new Map(); this.serverFpsEvents = []; + this.weatherEvents = []; this.zeusEntities = new Map(); this.zeusRemoteControl = []; this.zeusCameras = new Map(); diff --git a/ui/src/playback/events/weatherEvent.ts b/ui/src/playback/events/weatherEvent.ts new file mode 100644 index 000000000..bcf1a7364 --- /dev/null +++ b/ui/src/playback/events/weatherEvent.ts @@ -0,0 +1,13 @@ +import type { WeatherPayload } from "../../data/types"; +import { GameEvent } from "./gameEvent"; + +/** One global weather sample embedded in the mission recording. */ +export class WeatherEvent extends GameEvent { + constructor( + frameNum: number, + id: number, + public readonly payload: WeatherPayload, + ) { + super(frameNum, "weather", id); + } +} From 69cfa2d6167108b064dec98769bad913d25edb4f Mon Sep 17 00:00:00 2001 From: Yethe Samartaka Date: Tue, 15 Sep 2026 22:34:49 +0200 Subject: [PATCH 24/39] zeus module tracking --- internal/storage/parser_v1.go | 2 +- internal/storage/player_snapshot_test.go | 36 ++++++ internal/storage/player_snapshots.go | 4 +- internal/storage/player_snapshots_test.go | 5 +- .../decoders/__tests__/jsonDecoder.test.ts | 3 + .../__tests__/protobufDecoder.test.ts | 2 + ui/src/data/decoders/jsonDecoder.ts | 10 ++ ui/src/data/decoders/protobufDecoder.ts | 12 ++ ui/src/data/types.ts | 23 ++++ ui/src/i18n/locales.ts | 30 +++++ .../__tests__/SidePanel.test.tsx | 36 ++++++ .../components/EventFilters.tsx | 4 + .../components/EventsTab.tsx | 37 ++++++- .../recording-playback/useRenderBridge.ts | 101 +++++++++++++++++ ui/src/playback/__tests__/zeus.test.ts | 84 ++++++++++++++ ui/src/playback/engine.ts | 3 + ui/src/playback/eventManager.ts | 17 +++ ui/src/playback/events/zeusEvents.ts | 23 ++++ ui/src/playback/zeus.ts | 103 +++++++++++++++++- ui/src/styles/leaflet.css | 5 + 20 files changed, 532 insertions(+), 8 deletions(-) diff --git a/internal/storage/parser_v1.go b/internal/storage/parser_v1.go index 7c7f3e00c..31a6d7fc8 100644 --- a/internal/storage/parser_v1.go +++ b/internal/storage/parser_v1.go @@ -175,7 +175,7 @@ func parseEventArray(evtArr []interface{}) *Event { event.Type == "tfarSettings" || event.Type == "acreSettings" || event.Type == "serverFps" || event.Type == "weather" || event.Type == "zeusEntity" || event.Type == "zeusRemoteControl" || event.Type == "zeusCamera" || - event.Type == "zeusPing" || event.Type == "explosion" || + event.Type == "zeusPing" || event.Type == "zeusModule" || event.Type == "explosion" || event.Type == "serviceEvent" || event.Type == "staticWeapon" || event.Type == "radioTransmission" { if len(evtArr) > 2 { diff --git a/internal/storage/player_snapshot_test.go b/internal/storage/player_snapshot_test.go index e80702e3c..1c495c34b 100644 --- a/internal/storage/player_snapshot_test.go +++ b/internal/storage/player_snapshot_test.go @@ -209,6 +209,42 @@ func TestZeusPingIsNotAPlayerSnapshotEvent(t *testing.T) { require.False(t, IsPlayerSnapshotEvent("zeusPing")) } +func TestParseZeusModuleEventPreservesPayload(t *testing.T) { + event := parseEventArray([]interface{}{ + float64(80), + "zeusModule", + map[string]interface{}{ + "curatorId": float64(90), + "curatorName": "Danny", + "className": "zen_modules_moduleHeal", + "displayName": "Heal", + "category": "ACE", + "source": "zen", + "x": float64(3411), + "y": float64(9002), + "attachedUnitId": float64(7), + "attachedName": "Allen", + }, + }) + + require.NotNil(t, event) + require.Equal(t, "zeusModule", event.Type) + require.Equal(t, uint32(80), event.FrameNum) + + var payload map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(event.Message), &payload)) + require.Equal(t, float64(90), payload["curatorId"]) + require.Equal(t, "Heal", payload["displayName"]) + require.Equal(t, "zen_modules_moduleHeal", payload["className"]) + require.Equal(t, "Danny", payload["curatorName"]) + require.Equal(t, float64(3411), payload["x"]) + require.Equal(t, float64(9002), payload["y"]) +} + +func TestZeusModuleIsNotAPlayerSnapshotEvent(t *testing.T) { + require.False(t, IsPlayerSnapshotEvent("zeusModule")) +} + func TestParseExplosionEventPreservesPayload(t *testing.T) { event := parseEventArray([]interface{}{ float64(310), diff --git a/internal/storage/player_snapshots.go b/internal/storage/player_snapshots.go index d4073fa34..371600593 100644 --- a/internal/storage/player_snapshots.go +++ b/internal/storage/player_snapshots.go @@ -23,8 +23,8 @@ const SnapshotsDirName = "snapshots" // // serverFps, weather, tfarSettings, acreSettings and the Zeus events are // deliberately absent: they are needed for map-wide playback (Stats, range -// circles, VIRTUAL faction, camera cones) without selecting a unit, so they -// stay in the manifest. +// circles, VIRTUAL faction, camera cones, pings, modules) without selecting a +// unit, so they stay in the manifest. var playerSnapshotEventTypes = map[string]struct{}{ "inventorySnapshot": {}, "medicalSnapshot": {}, diff --git a/internal/storage/player_snapshots_test.go b/internal/storage/player_snapshots_test.go index 6c1447100..851c8566f 100644 --- a/internal/storage/player_snapshots_test.go +++ b/internal/storage/player_snapshots_test.go @@ -119,6 +119,7 @@ func TestConverterWritesPlayerSnapshotSidecars(t *testing.T) { [2, "zeusEntity", {"curatorId": 90, "name": "Danny", "playerUid": "7656", "bodyUnitId": -1}], [3, "zeusCamera", {"curatorId": 90, "x": 100, "y": 200, "dir": 90, "fov": 0.75}], [4, "zeusRemoteControl", {"curatorId": 90, "unitId": 7, "active": true}], + [4, "zeusModule", {"curatorId": 90, "curatorName": "Danny", "className": "zen_modules_moduleHeal", "displayName": "Heal", "x": 100, "y": 200}], [5, "inventorySnapshot", {"unitId": 7, "massUnits": 695}], [6, "killed", [7, 7, "rifle", 10]], [9, "medicalSnapshot", {"unitId": 12, "ace": {"heartRate": 80}}], @@ -154,12 +155,12 @@ func TestConverterWritesPlayerSnapshotSidecars(t *testing.T) { require.True(t, sawWeather, "weather must stay in the manifest") var sawZeus int for _, e := range manifest.Events { - if e.Type == "zeusEntity" || e.Type == "zeusCamera" || e.Type == "zeusRemoteControl" { + if e.Type == "zeusEntity" || e.Type == "zeusCamera" || e.Type == "zeusRemoteControl" || e.Type == "zeusModule" { sawZeus++ require.NotEmpty(t, e.Message, "%s lost its JSON payload", e.Type) } } - require.Equal(t, 3, sawZeus, "Zeus events must stay in the protobuf manifest") + require.Equal(t, 4, sawZeus, "Zeus events must stay in the protobuf manifest") data, err = os.ReadFile(filepath.Join(outputPath, SnapshotsDirName, "7.pb")) require.NoError(t, err) diff --git a/ui/src/data/decoders/__tests__/jsonDecoder.test.ts b/ui/src/data/decoders/__tests__/jsonDecoder.test.ts index 1ef88e4bb..2f1280e89 100644 --- a/ui/src/data/decoders/__tests__/jsonDecoder.test.ts +++ b/ui/src/data/decoders/__tests__/jsonDecoder.test.ts @@ -94,6 +94,8 @@ describe("JsonDecoder.decodeManifest", () => { [9, "zeusCamera", { x: 1, y: 2 }], [12, "zeusPing", { curatorId: 90, unitId: 7, name: "Danny", side: "WEST", x: 3411, y: 9002 }], [13, "zeusPing", { unitId: 7, x: 1, y: 2 }], + [16, "zeusModule", { curatorId: 90, curatorName: "Danny", className: "zen_modules_moduleHeal", displayName: "Heal", category: "ACE", source: "zen", x: 3411, y: 9002, attachedUnitId: 7, attachedName: "Allen" }], + [17, "zeusModule", { curatorId: 90, displayName: "Heal" }], ], })); @@ -102,6 +104,7 @@ describe("JsonDecoder.decodeManifest", () => { { frameNum: 4, type: "zeusCamera", payload: { curatorId: 90, x: 1, y: 2, dir: 90, fov: 0.75 } }, { frameNum: 8, type: "zeusRemoteControl", payload: { curatorId: 90, unitId: 7, active: true } }, { frameNum: 12, type: "zeusPing", payload: { curatorId: 90, unitId: 7, name: "Danny", side: "WEST", x: 3411, y: 9002 } }, + { frameNum: 16, type: "zeusModule", payload: { curatorId: 90, curatorName: "Danny", className: "zen_modules_moduleHeal", displayName: "Heal", category: "ACE", source: "zen", x: 3411, y: 9002, attachedUnitId: 7, attachedName: "Allen" } }, ]); }); diff --git a/ui/src/data/decoders/__tests__/protobufDecoder.test.ts b/ui/src/data/decoders/__tests__/protobufDecoder.test.ts index 046e812de..c16fb9f31 100644 --- a/ui/src/data/decoders/__tests__/protobufDecoder.test.ts +++ b/ui/src/data/decoders/__tests__/protobufDecoder.test.ts @@ -194,6 +194,7 @@ describe("ProtobufDecoder.decodeManifest", () => { { frameNum: 4, type: "zeusCamera", message: JSON.stringify({ curatorId: 90, x: 1, y: 2, dir: 90, fov: 0.75 }) }, { frameNum: 8, type: "zeusRemoteControl", message: JSON.stringify({ curatorId: 90, unitId: 7, active: true }) }, { frameNum: 12, type: "zeusPing", message: JSON.stringify({ curatorId: 90, unitId: 7, name: "Danny", side: "WEST", x: 3411, y: 9002 }) }, + { frameNum: 16, type: "zeusModule", message: JSON.stringify({ curatorId: 90, curatorName: "Danny", className: "zen_modules_moduleHeal", displayName: "Heal", x: 3411, y: 9002 }) }, ], }); @@ -202,6 +203,7 @@ describe("ProtobufDecoder.decodeManifest", () => { { frameNum: 4, type: "zeusCamera", payload: { curatorId: 90, x: 1, y: 2, dir: 90, fov: 0.75 } }, { frameNum: 8, type: "zeusRemoteControl", payload: { curatorId: 90, unitId: 7, active: true } }, { frameNum: 12, type: "zeusPing", payload: { curatorId: 90, unitId: 7, name: "Danny", side: "WEST", x: 3411, y: 9002 } }, + { frameNum: 16, type: "zeusModule", payload: { curatorId: 90, curatorName: "Danny", className: "zen_modules_moduleHeal", displayName: "Heal", x: 3411, y: 9002 } }, ]); }); diff --git a/ui/src/data/decoders/jsonDecoder.ts b/ui/src/data/decoders/jsonDecoder.ts index d3872c962..a53e093c3 100644 --- a/ui/src/data/decoders/jsonDecoder.ts +++ b/ui/src/data/decoders/jsonDecoder.ts @@ -377,6 +377,16 @@ function convertEvent(raw: RawJsonEvent): EventDef | null { if (typeof (payload as { curatorId?: unknown }).curatorId !== "number") return null; return { frameNum, type, payload } as EventDef; } + case "zeusModule": { + const payload = raw[2]; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + if (typeof (payload as { curatorId?: unknown }).curatorId !== "number") return null; + if (typeof (payload as { displayName?: unknown }).displayName !== "string") return null; + if (!(payload as { displayName: string }).displayName) return null; + if (typeof (payload as { x?: unknown }).x !== "number") return null; + if (typeof (payload as { y?: unknown }).y !== "number") return null; + return { frameNum, type, payload } as EventDef; + } default: // Unknown event types: skip return null; diff --git a/ui/src/data/decoders/protobufDecoder.ts b/ui/src/data/decoders/protobufDecoder.ts index 7df75c82e..c62915a08 100644 --- a/ui/src/data/decoders/protobufDecoder.ts +++ b/ui/src/data/decoders/protobufDecoder.ts @@ -252,6 +252,18 @@ function convertEvent(pb: PbEvent): EventDef | null { return null; } } + case "zeusModule": { + try { + const payload = JSON.parse(pb.message); + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + if (typeof payload.curatorId !== "number") return null; + if (typeof payload.displayName !== "string" || !payload.displayName) return null; + if (typeof payload.x !== "number" || typeof payload.y !== "number") return null; + return { frameNum, type, payload } as EventDef; + } catch { + return null; + } + } case "capturedFlag": return { frameNum, diff --git a/ui/src/data/types.ts b/ui/src/data/types.ts index f632c9c22..cdcf0f3d5 100644 --- a/ui/src/data/types.ts +++ b/ui/src/data/types.ts @@ -360,6 +360,23 @@ export interface ZeusPingPayload { y: number; } +/** + * A Zeus placed a module. Names are English-only at record time; the UI must + * not localize `displayName`. + */ +export interface ZeusModulePayload { + curatorId: number; + curatorName?: string; + className?: string; + displayName: string; + category?: string; + source?: string; + x: number; + y: number; + attachedUnitId?: number; + attachedName?: string; +} + /** * A detonation. `radius` is the ammo's indirectHitRange straight out of CfgAmmo, * so it is the area the blast could damage, not a rendering choice. @@ -473,6 +490,11 @@ export interface ZeusPingEventDef { payload: ZeusPingPayload; } +export interface ZeusModuleEventDef { + type: "zeusModule"; + payload: ZeusModulePayload; +} + export type EventDef = { frameNum: number } & ( | HitKilledEventDef | ConnectEventDef @@ -490,6 +512,7 @@ export type EventDef = { frameNum: number } & ( | ZeusRemoteControlEventDef | ZeusCameraEventDef | ZeusPingEventDef + | ZeusModuleEventDef | ExplosionEventDef | ServiceEventDef | StaticWeaponEventDef diff --git a/ui/src/i18n/locales.ts b/ui/src/i18n/locales.ts index d682059d1..adcf8b7ee 100644 --- a/ui/src/i18n/locales.ts +++ b/ui/src/i18n/locales.ts @@ -1247,6 +1247,36 @@ export const translations: Record> = { fi: "pingasi Zeusta", uk: "запросив увагу Зевса", }, + zeus_modules: { + ru: "Модули Зевса", + en: "Zeus modules", + de: "Zeus-Module", + cs: "Moduly Zeuse", + it: "Moduli Zeus", + fr: "Modules Zeus", + fi: "Zeus-moduulit", + uk: "Модулі Зевса", + }, + used_zeus_module: { + ru: "использовал", + en: "used", + de: "verwendete", + cs: "použil", + it: "ha usato", + fr: "a utilisé", + fi: "käytti", + uk: "використав", + }, + on_target: { + ru: "на", + en: "on", + de: "auf", + cs: "na", + it: "su", + fr: "sur", + fi: "kohteeseen", + uk: "на", + }, explosions: { ru: "Взрывы", en: "Explosions", diff --git a/ui/src/pages/recording-playback/__tests__/SidePanel.test.tsx b/ui/src/pages/recording-playback/__tests__/SidePanel.test.tsx index f45d43235..e9e172b73 100644 --- a/ui/src/pages/recording-playback/__tests__/SidePanel.test.tsx +++ b/ui/src/pages/recording-playback/__tests__/SidePanel.test.tsx @@ -124,4 +124,40 @@ describe("SidePanel", () => { expect(screen.queryByText("BLUFOR")).toBeNull(); }); + it("lists a Zeus module event with its recorded English name", () => { + const { engine, renderer } = createTestEngine(); + engine.loadRecording( + makeManifest( + [unitDef({ id: 1, name: "Allen", side: "WEST", groupName: "Alpha" })], + [ + { + frameNum: 0, + type: "zeusModule", + payload: { + curatorId: 90, + curatorName: "Danny", + className: "zen_modules_moduleHeal", + displayName: "Heal", + x: 100, + y: 200, + attachedName: "Allen", + }, + }, + ], + ), + ); + + const [activeTab] = createSignal("events"); + + render(() => ( + + {}} /> + + )); + + expect(screen.getByText(/Danny/)).toBeTruthy(); + expect(screen.getByText(/Heal/)).toBeTruthy(); + expect(screen.getByText(/Allen/)).toBeTruthy(); + }); + }); diff --git a/ui/src/pages/recording-playback/components/EventFilters.tsx b/ui/src/pages/recording-playback/components/EventFilters.tsx index dbb4dbbd1..bf59136b5 100644 --- a/ui/src/pages/recording-playback/components/EventFilters.tsx +++ b/ui/src/pages/recording-playback/components/EventFilters.tsx @@ -15,6 +15,7 @@ export interface EventFilterState { showTerminalHacks: boolean; showMissionEvents: boolean; showZeusPings: boolean; + showZeusModules: boolean; showExplosions: boolean; showServicing: boolean; showConstructions: boolean; @@ -30,6 +31,7 @@ export const DEFAULT_EVENT_FILTERS: EventFilterState = { showTerminalHacks: true, showMissionEvents: true, showZeusPings: true, + showZeusModules: true, // Off by default like hits: a mortar mission writes one per round, and the // blast circles are on the map either way. showExplosions: false, @@ -56,6 +58,7 @@ const EVENT_TYPE_ITEMS: EventTypeItem[] = [ { key: "showTerminalHacks", labelKey: "terminal_hacks" }, { key: "showMissionEvents", labelKey: "mission_events" }, { key: "showZeusPings", labelKey: "zeus_pings" }, + { key: "showZeusModules", labelKey: "zeus_modules" }, { key: "showExplosions", labelKey: "explosions" }, { key: "showServicing", labelKey: "servicing" }, { key: "showConstructions", labelKey: "constructions" }, @@ -77,6 +80,7 @@ function isNonDefault(state: EventFilterState): boolean { state.showTerminalHacks !== DEFAULT_EVENT_FILTERS.showTerminalHacks || state.showMissionEvents !== DEFAULT_EVENT_FILTERS.showMissionEvents || state.showZeusPings !== DEFAULT_EVENT_FILTERS.showZeusPings || + state.showZeusModules !== DEFAULT_EVENT_FILTERS.showZeusModules || state.showExplosions !== DEFAULT_EVENT_FILTERS.showExplosions || state.showServicing !== DEFAULT_EVENT_FILTERS.showServicing || state.showConstructions !== DEFAULT_EVENT_FILTERS.showConstructions || diff --git a/ui/src/pages/recording-playback/components/EventsTab.tsx b/ui/src/pages/recording-playback/components/EventsTab.tsx index 411da628f..c1028e76e 100644 --- a/ui/src/pages/recording-playback/components/EventsTab.tsx +++ b/ui/src/pages/recording-playback/components/EventsTab.tsx @@ -9,7 +9,7 @@ import { GeneralMissionEvent } from "../../../playback/events/generalEvent"; import { CapturedEvent } from "../../../playback/events/capturedEvent"; import { TerminalHackEvent } from "../../../playback/events/terminalHackEvent"; import { PlayerSnapshotEvent } from "../../../playback/events/playerSnapshotEvent"; -import { ZeusPingEvent } from "../../../playback/events/zeusEvents"; +import { ZeusModuleEvent, ZeusPingEvent } from "../../../playback/events/zeusEvents"; import { ExplosionEvent, RadioTransmissionEvent, @@ -20,7 +20,7 @@ import { formatNet } from "../../../playback/comms"; import type { GameEvent } from "../../../playback/events/gameEvent"; import { SIDE_COLORS_UI } from "../../../config/sideColors"; import { formatElapsedTime } from "../../../playback/time"; -import { SkullIcon, BulletIcon, LinkIcon, ClockIcon, DoorExitIcon, ActivityIcon, FlagIcon, AlertTriangleIcon, TerminalIcon, TargetIcon, ExplosionIcon, WrenchIcon, StaticWeaponIcon, RadioIcon } from "../../../components/Icons"; +import { SkullIcon, BulletIcon, LinkIcon, ClockIcon, DoorExitIcon, ActivityIcon, FlagIcon, AlertTriangleIcon, TerminalIcon, TargetIcon, ExplosionIcon, WrenchIcon, StaticWeaponIcon, RadioIcon, ZapIcon } from "../../../components/Icons"; import { EventFilters, DEFAULT_EVENT_FILTERS } from "./EventFilters"; import type { EventFilterState } from "./EventFilters"; import styles from "./SidePanel.module.css"; @@ -59,6 +59,9 @@ function eventStyle(event: GameEvent): { icon: JSX.Element; color: string } { if (event instanceof ZeusPingEvent) { return { icon: , color: SIDE_COLORS_UI.VIRTUAL }; } + if (event instanceof ZeusModuleEvent) { + return { icon: , color: SIDE_COLORS_UI.VIRTUAL }; + } if (event instanceof ExplosionEvent) { return { icon: , color: "var(--accent-warning)" }; } @@ -91,6 +94,7 @@ export function EventsTab(): JSX.Element { if (event instanceof CapturedEvent) return f.showCaptures; if (event instanceof TerminalHackEvent) return f.showTerminalHacks; if (event instanceof ZeusPingEvent) return f.showZeusPings; + if (event instanceof ZeusModuleEvent) return f.showZeusModules; if (event instanceof ExplosionEvent) return f.showExplosions; if (event instanceof ServiceEvent) return f.showServicing; if (event instanceof StaticWeaponEvent) return f.showConstructions; @@ -143,6 +147,14 @@ export function EventsTab(): JSX.Element { if (!event.unitName.toLowerCase().includes(text)) return false; } else if (event instanceof ZeusPingEvent) { if (!event.payload.name.toLowerCase().includes(text)) return false; + } else if (event instanceof ZeusModuleEvent) { + const haystack = [ + event.payload.curatorName ?? "", + event.payload.displayName ?? "", + event.payload.attachedName ?? "", + event.payload.className ?? "", + ].join(" ").toLowerCase(); + if (!haystack.includes(text)) return false; } else if (event instanceof ExplosionEvent) { if (!event.payload.name.toLowerCase().includes(text)) return false; } else if (event instanceof ServiceEvent) { @@ -178,6 +190,8 @@ export function EventsTab(): JSX.Element { engine.panToPosition(event.position); } else if (event instanceof ZeusPingEvent) { engine.panToPosition(event.position); + } else if (event instanceof ZeusModuleEvent) { + engine.panToPosition(event.position); } else if (event instanceof ExplosionEvent) { engine.panToPosition(event.position); } else if (event instanceof ServiceEvent) { @@ -340,6 +354,25 @@ export function EventsTab(): JSX.Element { + ) : event instanceof ZeusModuleEvent ? ( + <> + + + {event.payload.curatorName || `Zeus ${event.payload.curatorId}`} + + {" "}{t("used_zeus_module")}{" "} + {event.payload.displayName} + + {" "}{t("on_target")} {event.payload.attachedName} + + + + + + {timeStr(event.frameNum)} + + + ) : event instanceof ExplosionEvent ? ( <> diff --git a/ui/src/pages/recording-playback/useRenderBridge.ts b/ui/src/pages/recording-playback/useRenderBridge.ts index 487c23a22..356a0d571 100644 --- a/ui/src/pages/recording-playback/useRenderBridge.ts +++ b/ui/src/pages/recording-playback/useRenderBridge.ts @@ -14,9 +14,14 @@ import { Unit } from "../../playback/entities/unit"; import { HitKilledEvent } from "../../playback/events/hitKilledEvent"; import { groupZeusPings, + groupZeusModules, zeusPingAlpha, zeusPingLifetimeFrames, + zeusModuleAlpha, + zeusModuleFadeFrames, + zeusModuleLifetimeFrames, type ZeusPingGroup, + type ZeusModuleGroup, } from "../../playback/zeus"; import { explosionAlpha, @@ -99,6 +104,27 @@ function pingLabel( .join("
"); } +function moduleColor(): string { + const color = SIDE_COLORS_BRIGHT.VIRTUAL ?? "#9b59b6"; + return color.replace("#", ""); +} + +/** + * Text beside a module icon. The display name is the English string recorded + * in the event — do not translate it. + */ +function moduleLabel( + group: ZeusModuleGroup, + format: (frame: number) => string, +): string { + const zeus = escapeHtml(group.curatorName || `Zeus ${group.curatorId}`); + const moduleName = escapeHtml(group.displayName); + const target = group.attachedName ? ` - ${escapeHtml(group.attachedName)}` : ""; + return group.frames + .map((frame) => `[Zeus module] - ${zeus} - ${moduleName}${target} - ${escapeHtml(format(frame))}`) + .join("
"); +} + interface PingRender { icon: BriefingMarkerHandle; label: BriefingMarkerHandle; @@ -283,6 +309,81 @@ export function useRenderBridge( } }); + // Zeus modules → transient map overlay, same pattern as pings but 30 s. + const moduleRenders = new Map(); + + createEffect(() => { + const frame = engine.currentFrame(); + const mode = timeMode(); + const delay = engine.captureDelayMs(); + const lifetime = zeusModuleLifetimeFrames(delay); + const fade = zeusModuleFadeFrames(delay); + const groups = groupZeusModules( + engine.eventManager.getActiveZeusModules(frame, lifetime), + ); + + const config = engine.timeConfig; + const format = (f: number) => formatTime(f, mode, config); + + const live = new Set(); + + for (const group of groups) { + live.add(group.key); + const labelText = moduleLabel(group, format); + const color = moduleColor(); + + let render = moduleRenders.get(group.key); + if (render && render.labelText !== labelText) { + renderer.removeBriefingMarker(render.label); + render.label = renderer.createBriefingMarker({ + shape: "ICON", + type: "Empty", + color, + text: labelText, + side: "GLOBAL", + layer: "systemMarkers", + textClass: "zeus-module-label", + }); + render.labelText = labelText; + } + + if (!render) { + render = { + icon: renderer.createBriefingMarker({ + shape: "ICON", + type: "zeus", + color, + side: "GLOBAL", + layer: "systemMarkers", + }), + label: renderer.createBriefingMarker({ + shape: "ICON", + type: "Empty", + color, + text: labelText, + side: "GLOBAL", + layer: "systemMarkers", + textClass: "zeus-module-label", + }), + labelText, + }; + moduleRenders.set(group.key, render); + } + + const alpha = zeusModuleAlpha(frame, group.latestFrame, lifetime, fade); + const state = { position: group.position, direction: 0, alpha }; + renderer.updateBriefingMarker(render.icon, state); + renderer.updateBriefingMarker(render.label, state); + } + + for (const [key, render] of moduleRenders) { + if (live.has(key)) continue; + renderer.removeBriefingMarker(render.icon); + renderer.removeBriefingMarker(render.label); + moduleRenders.delete(key); + } + }); + // Explosions → transient blast circles. // The circle is the ammo's own indirectHitRange, so it shows the area the // blast could actually damage. Recomputing the active set from the frame keeps diff --git a/ui/src/playback/__tests__/zeus.test.ts b/ui/src/playback/__tests__/zeus.test.ts index 5f0ea207d..3051675fb 100644 --- a/ui/src/playback/__tests__/zeus.test.ts +++ b/ui/src/playback/__tests__/zeus.test.ts @@ -6,16 +6,22 @@ import { HitKilledEvent } from "../events/hitKilledEvent"; import { ZeusCameraEvent, ZeusEntityEvent, + ZeusModuleEvent, ZeusPingEvent, ZeusRemoteControlEvent, } from "../events/zeusEvents"; import { Unit } from "../entities/unit"; import { + ZEUS_MODULE_MAP_SECONDS, ZEUS_PING_MAP_SECONDS, armaFovToDegrees, formatUnitTypeLabel, formatZeusOccupancyName, + groupZeusModules, groupZeusPings, + zeusModuleAlpha, + zeusModuleFadeFrames, + zeusModuleLifetimeFrames, zeusPingAlpha, zeusPingLifetimeFrames, } from "../zeus"; @@ -468,3 +474,81 @@ describe("Zeus pings", () => { }); }); }); + +describe("Zeus modules", () => { + const placed = (frameNum: number, overrides: Record = {}) => + new ZeusModuleEvent(frameNum, frameNum, { + curatorId: 90, + curatorName: "Danny", + className: "zen_modules_moduleHeal", + displayName: "Heal", + x: 3411, + y: 9002, + ...overrides, + }); + + describe("zeusModuleLifetimeFrames", () => { + it("is 30 frames at the default 1 s capture delay", () => { + expect(zeusModuleLifetimeFrames(1000)).toBe(ZEUS_MODULE_MAP_SECONDS); + }); + + it("scales with a non-default capture delay", () => { + expect(zeusModuleLifetimeFrames(500)).toBe(60); + expect(zeusModuleLifetimeFrames(2000)).toBe(15); + }); + }); + + describe("getActiveZeusModules", () => { + let events: EventManager; + + beforeEach(() => { + events = new EventManager(); + events.addEvent(placed(100)); + }); + + it("shows the module on its own frame and until the lifetime elapses", () => { + expect(events.getActiveZeusModules(100, 30)).toHaveLength(1); + expect(events.getActiveZeusModules(129, 30)).toHaveLength(1); + }); + + it("drops the module exactly at the end of its 30 s map life", () => { + expect(events.getActiveZeusModules(130, 30)).toEqual([]); + }); + + it("keeps the module in the event log after it leaves the map", () => { + expect(events.getActiveEvents(5000)).toHaveLength(1); + expect(events.getActiveZeusModules(5000, 30)).toEqual([]); + }); + }); + + describe("groupZeusModules", () => { + it("collapses co-located uses of the same module by one Zeus", () => { + const groups = groupZeusModules([placed(100), placed(110, { x: 3420, y: 9010 })]); + expect(groups).toHaveLength(1); + expect(groups[0].frames).toEqual([110, 100]); + expect(groups[0].displayName).toBe("Heal"); + }); + + it("keeps different module names separate even at the same spot", () => { + const groups = groupZeusModules([ + placed(100), + placed(100, { displayName: "Earthquake", className: "zen_modules_moduleEarthquake" }), + ]); + expect(groups).toHaveLength(2); + }); + }); + + describe("zeusModuleAlpha", () => { + it("is solid for most of the 30 s life and fades in the last 5 s", () => { + expect(zeusModuleAlpha(100, 100, 30, 5)).toBe(1); + expect(zeusModuleAlpha(124, 100, 30, 5)).toBe(1); + expect(zeusModuleAlpha(128, 100, 30, 5)).toBeLessThan(1); + expect(zeusModuleFadeFrames(1000)).toBe(5); + }); + + it("is zero outside the lifetime", () => { + expect(zeusModuleAlpha(130, 100, 30, 5)).toBe(0); + expect(zeusModuleAlpha(99, 100, 30, 5)).toBe(0); + }); + }); +}); diff --git a/ui/src/playback/engine.ts b/ui/src/playback/engine.ts index 5fb87de19..4693120bf 100644 --- a/ui/src/playback/engine.ts +++ b/ui/src/playback/engine.ts @@ -28,6 +28,7 @@ import { WeatherEvent } from "./events/weatherEvent"; import { ZeusCameraEvent, ZeusEntityEvent, + ZeusModuleEvent, ZeusPingEvent, ZeusRemoteControlEvent, } from "./events/zeusEvents"; @@ -98,6 +99,8 @@ function createGameEvent(def: EventDef): GameEvent | null { return new ZeusCameraEvent(def.frameNum, id, def.payload); case "zeusPing": return new ZeusPingEvent(def.frameNum, id, def.payload); + case "zeusModule": + return new ZeusModuleEvent(def.frameNum, id, def.payload); case "explosion": return new ExplosionEvent(def.frameNum, id, def.payload); case "serviceEvent": diff --git a/ui/src/playback/eventManager.ts b/ui/src/playback/eventManager.ts index 74311350f..cf8c2697b 100644 --- a/ui/src/playback/eventManager.ts +++ b/ui/src/playback/eventManager.ts @@ -9,6 +9,7 @@ import { WeatherEvent } from "./events/weatherEvent"; import { ZeusCameraEvent, ZeusEntityEvent, + ZeusModuleEvent, ZeusPingEvent, ZeusRemoteControlEvent, } from "./events/zeusEvents"; @@ -32,6 +33,7 @@ export class EventManager { private zeusRemoteControl: ZeusRemoteControlEvent[] = []; private zeusCameras = new Map(); private zeusPings: ZeusPingEvent[] = []; + private zeusModules: ZeusModuleEvent[] = []; private explosions: ExplosionEvent[] = []; private transmissions: RadioTransmissionEvent[] = []; @@ -111,6 +113,10 @@ export class EventManager { this.zeusPings.push(event); this.zeusPings.sort((a, b) => a.frameNum - b.frameNum); } + if (event instanceof ZeusModuleEvent) { + this.zeusModules.push(event); + this.zeusModules.sort((a, b) => a.frameNum - b.frameNum); + } this.events.push(event); const existing = this.frameIndex.get(event.frameNum); @@ -238,6 +244,16 @@ export class EventManager { ); } + /** + * Module placements whose map lifetime covers `frame`, oldest first. + * Drawn from the placement frame until `frameNum + lifetimeFrames`. + */ + getActiveZeusModules(frame: number, lifetimeFrames: number): ZeusModuleEvent[] { + return this.zeusModules.filter( + (event) => event.frameNum <= frame && frame < event.frameNum + lifetimeFrames, + ); + } + /** * Blasts whose map lifetime covers `frame`, oldest first. A blast is drawn * from its own frame until `frameNum + lifetimeFrames`. @@ -517,6 +533,7 @@ export class EventManager { this.zeusRemoteControl = []; this.zeusCameras = new Map(); this.zeusPings = []; + this.zeusModules = []; this.explosions = []; this.transmissions = []; this.explosionsSorted = true; diff --git a/ui/src/playback/events/zeusEvents.ts b/ui/src/playback/events/zeusEvents.ts index 7aa1dbc8c..b77684370 100644 --- a/ui/src/playback/events/zeusEvents.ts +++ b/ui/src/playback/events/zeusEvents.ts @@ -2,6 +2,7 @@ import { GameEvent } from "./gameEvent"; import type { ZeusCameraPayload, ZeusEntityPayload, + ZeusModulePayload, ZeusPingPayload, ZeusRemoteControlPayload, } from "../../data/types"; @@ -61,3 +62,25 @@ export class ZeusPingEvent extends GameEvent { return [this.payload.x, this.payload.y]; } } + +/** + * A Zeus placed a module. Human-readable mission event: stays in the event + * log forever, and is drawn on the map for a short fade like a ping. + */ +export class ZeusModuleEvent extends GameEvent { + constructor( + frameNum: number, + id: number, + public readonly payload: ZeusModulePayload, + ) { + super(frameNum, "zeusModule", id); + } + + get curatorId(): number { + return this.payload.curatorId; + } + + get position(): [number, number] { + return [this.payload.x, this.payload.y]; + } +} diff --git a/ui/src/playback/zeus.ts b/ui/src/playback/zeus.ts index c92f86bb7..31c0f01e5 100644 --- a/ui/src/playback/zeus.ts +++ b/ui/src/playback/zeus.ts @@ -1,4 +1,4 @@ -import type { Side, ZeusPingPayload } from "../data/types"; +import type { Side, ZeusModulePayload, ZeusPingPayload } from "../data/types"; /** * Frames after a remote-control stop during which that unit's kills still @@ -123,6 +123,107 @@ export function zeusPingAlpha( return Math.max(0.15, remaining / fade); } +/** + * How long a Zeus module placement stays drawn on the map, in seconds of + * playback time. After this it disappears from the map but remains in the + * event log forever. + */ +export const ZEUS_MODULE_MAP_SECONDS = 30; + +/** Seconds over which a module marker fades out at the end of its map life. */ +export const ZEUS_MODULE_FADE_SECONDS = 5; + +export function zeusModuleLifetimeFrames(captureDelayMs: number): number { + if (!Number.isFinite(captureDelayMs) || captureDelayMs <= 0) { + return ZEUS_MODULE_MAP_SECONDS; + } + return Math.max(1, Math.round((ZEUS_MODULE_MAP_SECONDS * 1000) / captureDelayMs)); +} + +export function zeusModuleFadeFrames(captureDelayMs: number): number { + if (!Number.isFinite(captureDelayMs) || captureDelayMs <= 0) { + return ZEUS_MODULE_FADE_SECONDS; + } + return Math.max(1, Math.round((ZEUS_MODULE_FADE_SECONDS * 1000) / captureDelayMs)); +} + +export interface ZeusModuleLike { + frameNum: number; + payload: ZeusModulePayload; +} + +export interface ZeusModuleGroup { + key: string; + curatorId: number; + curatorName: string; + displayName: string; + attachedName: string; + position: [number, number]; + frames: number[]; + latestFrame: number; +} + +export function groupZeusModules(events: ZeusModuleLike[]): ZeusModuleGroup[] { + const groups: ZeusModuleGroup[] = []; + const anchors: Array<[number, number]> = []; + + for (const event of events) { + const { curatorId, curatorName, displayName, attachedName, x, y } = event.payload; + const attached = attachedName ?? ""; + + let matched = false; + for (let i = 0; i < groups.length; i++) { + const group = groups[i]; + if (group.curatorId !== curatorId) continue; + if (group.displayName !== displayName) continue; + if (group.attachedName !== attached) continue; + const [ax, ay] = anchors[i]; + if (Math.hypot(x - ax, y - ay) > ZEUS_PING_GROUP_RADIUS_M) continue; + + group.frames.push(event.frameNum); + if (event.frameNum > group.latestFrame) { + group.latestFrame = event.frameNum; + group.position = [x, y]; + } + matched = true; + break; + } + if (matched) continue; + + groups.push({ + key: `${curatorId}:${displayName}:${attached}:${event.frameNum}`, + curatorId, + curatorName, + displayName, + attachedName: attached, + position: [x, y], + frames: [event.frameNum], + latestFrame: event.frameNum, + }); + anchors.push([x, y]); + } + + for (const group of groups) { + group.frames.sort((a, b) => b - a); + } + + return groups.sort((a, b) => a.latestFrame - b.latestFrame); +} + +export function zeusModuleAlpha( + frame: number, + latestFrame: number, + lifetimeFrames: number, + fadeFrames: number, +): number { + const age = frame - latestFrame; + if (age < 0 || age >= lifetimeFrames) return 0; + const fade = Math.min(fadeFrames, lifetimeFrames); + const remaining = lifetimeFrames - age; + if (remaining >= fade) return 1; + return Math.max(0.15, remaining / fade); +} + /** Convert Arma getObjectFOV (tan of half-angle) to horizontal degrees. */ export function armaFovToDegrees(fov: number): number { if (!Number.isFinite(fov) || fov <= 0) return 75; diff --git a/ui/src/styles/leaflet.css b/ui/src/styles/leaflet.css index ed37c7061..78d233fa5 100644 --- a/ui/src/styles/leaflet.css +++ b/ui/src/styles/leaflet.css @@ -68,6 +68,11 @@ line-height: 1.3; } +.marker-text-label.zeus-module-label span { + font-size: 11px; + line-height: 1.3; +} + .leaflet-popup-vehicle .leaflet-popup-content-wrapper { text-align: left; min-width: 200px; From 1fe0abb6c4c616b36765ba4d693919e3c6aadc33 Mon Sep 17 00:00:00 2001 From: Yethe Samartaka Date: Wed, 16 Sep 2026 18:49:49 +0200 Subject: [PATCH 25/39] Added faction filters for events and fixed scale bars --- ui/src/i18n/locales.ts | 10 + .../__tests__/EventFilters.test.tsx | 73 +++++ .../__tests__/EventsTab.test.tsx | 238 +++++++++++++++ .../components/EventFilters.tsx | 71 ++++- .../components/EventsTab.tsx | 36 ++- .../leaflet/__tests__/leafletControls.test.ts | 263 ++++++++++++++++- .../leaflet/__tests__/scaleBar.test.ts | 275 ++++++++++++++++++ ui/src/renderers/leaflet/leafletControls.ts | 69 ++++- ui/src/renderers/leaflet/leafletRenderer.ts | 4 +- ui/src/renderers/leaflet/scaleBar.ts | 64 ++++ 10 files changed, 1088 insertions(+), 15 deletions(-) create mode 100644 ui/src/renderers/leaflet/__tests__/scaleBar.test.ts create mode 100644 ui/src/renderers/leaflet/scaleBar.ts diff --git a/ui/src/i18n/locales.ts b/ui/src/i18n/locales.ts index adcf8b7ee..5ffd88274 100644 --- a/ui/src/i18n/locales.ts +++ b/ui/src/i18n/locales.ts @@ -1897,6 +1897,16 @@ export const translations: Record> = { fi: "Piilota omien tuli", uk: "Сховати дружній вогонь", }, + section_faction_filter: { + ru: "ФРАКЦИЯ", + en: "FACTION", + de: "FRAKTION", + cs: "FRAKCE", + it: "FAZIONE", + fr: "FACTION", + fi: "FAKTIO", + uk: "ФРАКЦІЯ", + }, friendly_fire: { ru: "Дружеский огонь", en: "Friendly Fire", diff --git a/ui/src/pages/recording-playback/__tests__/EventFilters.test.tsx b/ui/src/pages/recording-playback/__tests__/EventFilters.test.tsx index 896617915..4b76d3cea 100644 --- a/ui/src/pages/recording-playback/__tests__/EventFilters.test.tsx +++ b/ui/src/pages/recording-playback/__tests__/EventFilters.test.tsx @@ -4,6 +4,8 @@ import { render, screen, cleanup, fireEvent } from "@solidjs/testing-library"; import { EventFilters, DEFAULT_EVENT_FILTERS, + DEFAULT_FACTION_FILTER, + allFactionsEnabled, type EventFilterState, } from "../components/EventFilters"; import { I18nProvider } from "../../../hooks/useLocale"; @@ -16,6 +18,7 @@ function renderEventFilters(initial?: Partial) { const [state, setStateSignal] = createSignal({ ...DEFAULT_EVENT_FILTERS, ...initial, + factions: { ...DEFAULT_FACTION_FILTER, ...initial?.factions }, }); const setState = (updater: (prev: EventFilterState) => EventFilterState) => { setStateSignal((prev) => updater(prev)); @@ -46,6 +49,7 @@ describe("EventFilters - panel open/close", () => { openPanel(); expect(screen.getByText("EVENT TYPES")).toBeTruthy(); expect(screen.getByText("SIDE FILTER")).toBeTruthy(); + expect(screen.getByText("FACTION")).toBeTruthy(); }); it("closes on second button click", () => { @@ -74,6 +78,16 @@ describe("EventFilters - panel open/close", () => { expect(screen.getByText("Friendly fire only")).toBeTruthy(); expect(screen.getByText("Hide friendly fire")).toBeTruthy(); }); + + it("renders faction checkboxes for every side", () => { + renderEventFilters(); + openPanel(); + expect(screen.getByText("BLUFOR")).toBeTruthy(); + expect(screen.getByText("OPFOR")).toBeTruthy(); + expect(screen.getByText("Independent")).toBeTruthy(); + expect(screen.getByText("Civilian")).toBeTruthy(); + expect(screen.getByText("Virtual")).toBeTruthy(); + }); }); describe("EventFilters - event-type toggles", () => { @@ -137,6 +151,57 @@ describe("EventFilters - side filter", () => { }); }); +describe("EventFilters - faction filter", () => { + it("all factions are enabled by default", () => { + const { state } = renderEventFilters(); + expect(state().factions).toEqual(DEFAULT_FACTION_FILTER); + }); + + it("toggling a faction updates only that faction", () => { + const { state } = renderEventFilters(); + openPanel(); + + fireEvent.click(screen.getByText("BLUFOR")); + expect(state().factions.WEST).toBe(false); + expect(state().factions.EAST).toBe(true); + expect(state().factions.GUER).toBe(true); + expect(state().factions.VIRTUAL).toBe(true); + + fireEvent.click(screen.getByText("Independent")); + expect(state().factions.GUER).toBe(false); + expect(state().factions.WEST).toBe(false); + }); + + it("faction checkboxes are independent of the side-filter radio", () => { + const { state } = renderEventFilters(); + openPanel(); + + fireEvent.click(screen.getByText("Friendly fire only")); + fireEvent.click(screen.getByText("OPFOR")); + expect(state().sideFilter).toBe("friendlyFireOnly"); + expect(state().factions.EAST).toBe(false); + expect(state().factions.WEST).toBe(true); + }); + + it("Virtual and Civilian toggles flip those sides only", () => { + const { state } = renderEventFilters(); + openPanel(); + + fireEvent.click(screen.getByText("Virtual")); + fireEvent.click(screen.getByText("Civilian")); + expect(state().factions.VIRTUAL).toBe(false); + expect(state().factions.CIV).toBe(false); + expect(state().factions.WEST).toBe(true); + expect(state().factions.EAST).toBe(true); + expect(state().factions.GUER).toBe(true); + }); + + it("allFactionsEnabled is true only when every side is on", () => { + expect(allFactionsEnabled(DEFAULT_FACTION_FILTER)).toBe(true); + expect(allFactionsEnabled({ ...DEFAULT_FACTION_FILTER, GUER: false })).toBe(false); + }); +}); + describe("EventFilters - active indicator", () => { it("shows no active dot when state matches defaults", () => { const { container } = renderEventFilters(); @@ -157,6 +222,14 @@ describe("EventFilters - active indicator", () => { expect(dot).not.toBeNull(); }); + it("shows an active dot when a faction is unchecked", () => { + const { container } = renderEventFilters({ + factions: { ...DEFAULT_FACTION_FILTER, WEST: false }, + }); + const dot = container.querySelector("[aria-hidden='true']"); + expect(dot).not.toBeNull(); + }); + it("hides the active dot while the panel is open", () => { const { container } = renderEventFilters({ showHits: true }); // Dot visible while closed. diff --git a/ui/src/pages/recording-playback/__tests__/EventsTab.test.tsx b/ui/src/pages/recording-playback/__tests__/EventsTab.test.tsx index a246bea28..54b95b9fb 100644 --- a/ui/src/pages/recording-playback/__tests__/EventsTab.test.tsx +++ b/ui/src/pages/recording-playback/__tests__/EventsTab.test.tsx @@ -811,4 +811,242 @@ describe("EventsTab", () => { expect(screen.queryByText(/started hacking/)).toBeNull(); }); + + it("faction filter hides kills that do not involve the enabled sides", () => { + const { engine, renderer } = createTestEngine(); + const entities = [ + unitDef({ id: 1, name: "BlueVictim", side: "WEST" }), + unitDef({ id: 2, name: "BlueKiller", side: "WEST" }), + unitDef({ id: 3, name: "RedVictim", side: "EAST" }), + unitDef({ id: 4, name: "RedKiller", side: "EAST" }), + unitDef({ id: 5, name: "GreenVictim", side: "GUER" }), + unitDef({ id: 6, name: "GreenKiller", side: "GUER" }), + ]; + const events = [ + killedEvent(0, 1, 2, "M4A1", 100), + killedEvent(1, 3, 4, "AK-47", 100), + killedEvent(2, 5, 6, "AK-74", 100), + ]; + engine.loadRecording(makeManifest(entities, events)); + engine.seekTo(10); + + render(() => ( + + + + )); + + expect(screen.getByText("BlueVictim")).toBeTruthy(); + expect(screen.getByText("RedVictim")).toBeTruthy(); + expect(screen.getByText("GreenVictim")).toBeTruthy(); + + fireEvent.click(screen.getByLabelText("Event filters")); + fireEvent.click(screen.getByText("OPFOR")); + fireEvent.click(screen.getByText("Independent")); + fireEvent.click(screen.getByText("Civilian")); + fireEvent.click(screen.getByText("Virtual")); + + expect(screen.getByText("BlueVictim")).toBeTruthy(); + expect(screen.queryByText("RedVictim")).toBeNull(); + expect(screen.queryByText("GreenVictim")).toBeNull(); + }); + + it("faction filter keeps a cross-side kill if either side is enabled", () => { + const { engine, renderer } = createTestEngine(); + const entities = [ + unitDef({ id: 1, name: "BlueVictim", side: "WEST" }), + unitDef({ id: 2, name: "RedKiller", side: "EAST" }), + ]; + const events = [killedEvent(0, 1, 2, "AK-47", 100)]; + engine.loadRecording(makeManifest(entities, events)); + engine.seekTo(10); + + render(() => ( + + + + )); + + fireEvent.click(screen.getByLabelText("Event filters")); + fireEvent.click(screen.getByText("OPFOR")); + fireEvent.click(screen.getByText("Independent")); + fireEvent.click(screen.getByText("Civilian")); + fireEvent.click(screen.getByText("Virtual")); + + // Victim is WEST; BLUFOR is still enabled. + expect(screen.getByText("BlueVictim")).toBeTruthy(); + }); + + it("faction filter Virtual keeps Zeus modules and hides other sides", () => { + const { engine, renderer } = createTestEngine(); + const entities = [ + unitDef({ id: 1, name: "BlueVictim", side: "WEST" }), + unitDef({ id: 2, name: "BlueKiller", side: "WEST" }), + ]; + const events = [ + killedEvent(0, 1, 2, "M4A1", 100), + { + frameNum: 1, + type: "zeusModule", + payload: { + curatorId: 90, + curatorName: "Danny", + displayName: "Heal", + x: 100, + y: 200, + }, + }, + ]; + engine.loadRecording(makeManifest(entities, events)); + engine.seekTo(10); + + render(() => ( + + + + )); + + fireEvent.click(screen.getByLabelText("Event filters")); + fireEvent.click(screen.getByText("BLUFOR")); + fireEvent.click(screen.getByText("OPFOR")); + fireEvent.click(screen.getByText("Independent")); + fireEvent.click(screen.getByText("Civilian")); + + expect(screen.queryByText("BlueVictim")).toBeNull(); + expect(screen.getByText(/Heal/)).toBeTruthy(); + }); + + it("faction filter composes with hide-friendly-fire", () => { + const { engine, renderer } = createTestEngine(); + const entities = [ + unitDef({ id: 1, name: "BlueVictim", side: "WEST" }), + unitDef({ id: 2, name: "BlueKiller", side: "WEST" }), + unitDef({ id: 3, name: "RedVictim", side: "EAST" }), + ]; + const events = [ + killedEvent(0, 1, 2, "M4A1", 100), // WEST TK + killedEvent(1, 3, 2, "M4A1", 100), // WEST kills EAST + ]; + engine.loadRecording(makeManifest(entities, events)); + engine.seekTo(10); + + render(() => ( + + + + )); + + fireEvent.click(screen.getByLabelText("Event filters")); + fireEvent.click(screen.getByText("Hide friendly fire")); + fireEvent.click(screen.getByText("OPFOR")); + fireEvent.click(screen.getByText("Independent")); + fireEvent.click(screen.getByText("Civilian")); + fireEvent.click(screen.getByText("Virtual")); + + expect(screen.queryByText("BlueVictim")).toBeNull(); + expect(screen.getByText("RedVictim")).toBeTruthy(); + }); + + it("faction filter Independent keeps GUER kills only", () => { + const { engine, renderer } = createTestEngine(); + const entities = [ + unitDef({ id: 1, name: "BlueVictim", side: "WEST" }), + unitDef({ id: 2, name: "BlueKiller", side: "WEST" }), + unitDef({ id: 5, name: "GreenVictim", side: "GUER" }), + unitDef({ id: 6, name: "GreenKiller", side: "GUER" }), + ]; + const events = [ + killedEvent(0, 1, 2, "M4A1", 100), + killedEvent(1, 5, 6, "AK-74", 100), + ]; + engine.loadRecording(makeManifest(entities, events)); + engine.seekTo(10); + + render(() => ( + + + + )); + + fireEvent.click(screen.getByLabelText("Event filters")); + fireEvent.click(screen.getByText("BLUFOR")); + fireEvent.click(screen.getByText("OPFOR")); + fireEvent.click(screen.getByText("Civilian")); + fireEvent.click(screen.getByText("Virtual")); + + expect(screen.queryByText("BlueVictim")).toBeNull(); + expect(screen.getByText("GreenVictim")).toBeTruthy(); + }); + + it("faction filter leaves unsided mission events visible", () => { + const { engine, renderer } = createTestEngine(); + const entities = [ + unitDef({ id: 1, name: "BlueVictim", side: "WEST" }), + unitDef({ id: 2, name: "BlueKiller", side: "WEST" }), + ]; + const events = [ + killedEvent(0, 1, 2, "M4A1", 100), + generalEvent(1, "Objective completed"), + ]; + engine.loadRecording(makeManifest(entities, events)); + engine.seekTo(10); + + render(() => ( + + + + )); + + fireEvent.click(screen.getByLabelText("Event filters")); + fireEvent.click(screen.getByText("BLUFOR")); + fireEvent.click(screen.getByText("OPFOR")); + fireEvent.click(screen.getByText("Independent")); + fireEvent.click(screen.getByText("Civilian")); + fireEvent.click(screen.getByText("Virtual")); + + expect(screen.queryByText("BlueVictim")).toBeNull(); + expect(screen.getByText("Objective completed")).toBeTruthy(); + }); + + it("faction filter OPFOR keeps an explosion with side EAST", () => { + const { engine, renderer } = createTestEngine(); + const entities = [unitDef({ id: 1, name: "Rifleman", side: "WEST" })]; + const events = [ + { + frameNum: 0, + type: "explosion", + payload: { + x: 100, + y: 200, + ammo: "SatchelCharge_Remote_Ammo", + name: "Satchel Charge", + radius: 10, + power: 50, + firerId: 2, + vehicleId: -1, + side: "EAST", + source: "explosive", + }, + }, + ]; + engine.loadRecording(makeManifest(entities, events)); + engine.seekTo(10); + + render(() => ( + + + + )); + + fireEvent.click(screen.getByLabelText("Event filters")); + fireEvent.click(screen.getByText("Explosions")); + expect(screen.getByText(/Satchel Charge/)).toBeTruthy(); + + fireEvent.click(screen.getByText("OPFOR")); + expect(screen.queryByText(/Satchel Charge/)).toBeNull(); + + fireEvent.click(screen.getByText("OPFOR")); + fireEvent.click(screen.getByText("BLUFOR")); + expect(screen.getByText(/Satchel Charge/)).toBeTruthy(); + }); }); diff --git a/ui/src/pages/recording-playback/components/EventFilters.tsx b/ui/src/pages/recording-playback/components/EventFilters.tsx index bf59136b5..d6f75833e 100644 --- a/ui/src/pages/recording-playback/components/EventFilters.tsx +++ b/ui/src/pages/recording-playback/components/EventFilters.tsx @@ -1,5 +1,7 @@ import { createSignal, For, Show } from "solid-js"; import type { JSX, Accessor } from "solid-js"; +import type { Side } from "../../../data/types"; +import { SIDES, SIDE_LABELS } from "../../../config/sideColors"; import { FilterIcon } from "../../../components/Icons"; import { useI18n } from "../../../hooks/useLocale"; import { useClickOutside } from "../../../hooks/useClickOutside"; @@ -7,6 +9,24 @@ import styles from "./EventFilters.module.css"; export type SideFilter = "all" | "friendlyFireOnly" | "hideFriendlyFire"; +export type FactionFilter = Record; + +export const DEFAULT_FACTION_FILTER: FactionFilter = { + WEST: true, + EAST: true, + GUER: true, + CIV: true, + VIRTUAL: true, +}; + +/** Filter-panel names: Independent in full, the rest match the Units tab. */ +export const FACTION_FILTER_LABELS: Record = { + ...SIDE_LABELS, + GUER: "Independent", + CIV: "Civilian", + VIRTUAL: "Virtual", +}; + export interface EventFilterState { showKills: boolean; showHits: boolean; @@ -21,6 +41,7 @@ export interface EventFilterState { showConstructions: boolean; showTransmissions: boolean; sideFilter: SideFilter; + factions: FactionFilter; } export const DEFAULT_EVENT_FILTERS: EventFilterState = { @@ -41,9 +62,10 @@ export const DEFAULT_EVENT_FILTERS: EventFilterState = { // times, and the Comms tab is where transmissions are actually readable. showTransmissions: false, sideFilter: "all", + factions: { ...DEFAULT_FACTION_FILTER }, }; -type EventTypeKey = Exclude; +type EventTypeKey = Exclude; interface EventTypeItem { key: EventTypeKey; @@ -85,10 +107,15 @@ function isNonDefault(state: EventFilterState): boolean { state.showServicing !== DEFAULT_EVENT_FILTERS.showServicing || state.showConstructions !== DEFAULT_EVENT_FILTERS.showConstructions || state.showTransmissions !== DEFAULT_EVENT_FILTERS.showTransmissions || - state.sideFilter !== DEFAULT_EVENT_FILTERS.sideFilter + state.sideFilter !== DEFAULT_EVENT_FILTERS.sideFilter || + SIDES.some((side) => state.factions[side] !== DEFAULT_FACTION_FILTER[side]) ); } +export function allFactionsEnabled(factions: FactionFilter): boolean { + return SIDES.every((side) => factions[side]); +} + export interface EventFiltersProps { state: Accessor; setState: (updater: (prev: EventFilterState) => EventFilterState) => void; @@ -108,6 +135,13 @@ export function EventFilters(props: EventFiltersProps): JSX.Element { props.setState((prev) => ({ ...prev, sideFilter: side })); }; + const toggleFaction = (side: Side) => { + props.setState((prev) => ({ + ...prev, + factions: { ...prev.factions, [side]: !prev.factions[side] }, + })); + }; + return (
+ ); + }} +
diff --git a/ui/src/pages/recording-playback/components/EventsTab.tsx b/ui/src/pages/recording-playback/components/EventsTab.tsx index c1028e76e..53b532f46 100644 --- a/ui/src/pages/recording-playback/components/EventsTab.tsx +++ b/ui/src/pages/recording-playback/components/EventsTab.tsx @@ -21,8 +21,10 @@ import type { GameEvent } from "../../../playback/events/gameEvent"; import { SIDE_COLORS_UI } from "../../../config/sideColors"; import { formatElapsedTime } from "../../../playback/time"; import { SkullIcon, BulletIcon, LinkIcon, ClockIcon, DoorExitIcon, ActivityIcon, FlagIcon, AlertTriangleIcon, TerminalIcon, TargetIcon, ExplosionIcon, WrenchIcon, StaticWeaponIcon, RadioIcon, ZapIcon } from "../../../components/Icons"; -import { EventFilters, DEFAULT_EVENT_FILTERS } from "./EventFilters"; +import { EventFilters, DEFAULT_EVENT_FILTERS, DEFAULT_FACTION_FILTER, allFactionsEnabled } from "./EventFilters"; import type { EventFilterState } from "./EventFilters"; +import type { Side } from "../../../data/types"; +import type { PlaybackEngine } from "../../../playback/engine"; import styles from "./SidePanel.module.css"; function sideColor(side?: string): string { @@ -77,11 +79,33 @@ function eventStyle(event: GameEvent): { icon: JSX.Element; color: string } { return { icon: , color: "#888" }; } +/** Sides an event belongs to. Empty means the event has no faction and always passes. */ +export function eventInvolvedSides(event: GameEvent, engine: PlaybackEngine): string[] { + if (event instanceof HitKilledEvent) { + return [event.victimSide, event.causerSide].filter((s): s is string => !!s); + } + if (event instanceof EndMissionEvent) return event.side ? [event.side] : []; + if (event instanceof CapturedEvent) return event.side ? [event.side] : []; + if (event instanceof ZeusPingEvent) return event.payload.side ? [event.payload.side] : []; + if (event instanceof ZeusModuleEvent) return ["VIRTUAL"]; + if (event instanceof ExplosionEvent) return event.payload.side ? [event.payload.side] : []; + if (event instanceof ServiceEvent) return event.payload.side ? [event.payload.side] : []; + if (event instanceof StaticWeaponEvent) return event.payload.side ? [event.payload.side] : []; + if (event instanceof RadioTransmissionEvent) { + const side = engine.entitySnapshots().get(event.unitId)?.side; + return side ? [side] : []; + } + return []; +} + export function EventsTab(): JSX.Element { const engine = useEngine(); const { t } = useI18n(); const [filterText, setFilterText] = createSignal(""); - const [filters, setFilters] = createSignal(DEFAULT_EVENT_FILTERS); + const [filters, setFilters] = createSignal({ + ...DEFAULT_EVENT_FILTERS, + factions: { ...DEFAULT_FACTION_FILTER }, + }); const passesEventType = (event: GameEvent, f: EventFilterState): boolean => { // Detail snapshots drive the player profile and are intentionally hidden @@ -117,6 +141,13 @@ export function EventsTab(): JSX.Element { return true; }; + const passesFactionFilter = (event: GameEvent, f: EventFilterState): boolean => { + if (allFactionsEnabled(f.factions)) return true; + const sides = eventInvolvedSides(event, engine); + if (sides.length === 0) return true; + return sides.some((s) => f.factions[s as Side]); + }; + const filteredEvents = createMemo(() => { const all = engine.activeEvents(); const text = filterText().toLowerCase(); @@ -125,6 +156,7 @@ export function EventsTab(): JSX.Element { const filtered = all.filter((event) => { if (!passesEventType(event, f)) return false; if (!passesSideFilter(event, f)) return false; + if (!passesFactionFilter(event, f)) return false; // Text search if (text) { diff --git a/ui/src/renderers/leaflet/__tests__/leafletControls.test.ts b/ui/src/renderers/leaflet/__tests__/leafletControls.test.ts index 2c043b68f..f720702e0 100644 --- a/ui/src/renderers/leaflet/__tests__/leafletControls.test.ts +++ b/ui/src/renderers/leaflet/__tests__/leafletControls.test.ts @@ -1,15 +1,272 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import L from "leaflet"; import { createScaleControl } from "../leafletControls"; +import { LeafletRenderer, latLngToArmaMapLibre } from "../leafletRenderer"; +import { METERS_PER_DEGREE } from "../../../utils/coordinates"; +import { + armaMetersBetween, + formatScaleLabel, + niceScaleDistance, + scaleBarWidthPx, +} from "../scaleBar"; describe("createScaleControl", () => { it("returns a scale control object", () => { - const control = createScaleControl(); + const control = createScaleControl((ll) => [ll.lng, ll.lat]); expect(control).toBeDefined(); expect(typeof control.onAdd).toBe("function"); }); it("is positioned at bottomleft", () => { - const control = createScaleControl(); + const control = createScaleControl((ll) => [ll.lng, ll.lat]); expect(control.getPosition()).toBe("bottomleft"); }); }); + +describe("OcapScaleControl on a map", () => { + let container: HTMLDivElement; + let map: L.Map; + + beforeEach(() => { + container = document.createElement("div"); + Object.defineProperty(container, "clientWidth", { value: 400, configurable: true }); + Object.defineProperty(container, "clientHeight", { value: 400, configurable: true }); + document.body.appendChild(container); + map = L.map(container, { + crs: L.CRS.Simple, + center: [0, 0], + zoom: 0, + attributionControl: false, + zoomControl: false, + }); + map.invalidateSize(); + }); + + afterEach(() => { + map.remove(); + container.remove(); + }); + + function scaleLabel(): string { + return ( + container.querySelector(".leaflet-control-scale-line")?.textContent ?? "" + ); + } + + it("labels a sub-metre span in centimetres instead of sticking at 1 m", () => { + // 100 px of the scale bar equals 40 cm of world — Leaflet would say "1 m". + createScaleControl((ll) => [ll.lng * 0.004, ll.lat * 0.004]).addTo(map); + map.fire("move"); + expect(scaleLabel()).toMatch(/cm$/); + expect(scaleLabel()).not.toBe("1 m"); + }); + + it("labels a multi-kilometre span in km", () => { + createScaleControl((ll) => [ll.lng * 200, ll.lat * 200]).addTo(map); + map.fire("move"); + expect(scaleLabel()).toMatch(/km$/); + }); +}); + +describe("scale bar uses Arma metres in legacy CRS", () => { + let container: HTMLDivElement; + let renderer: LeafletRenderer; + + const WORLD = { + worldName: "test", + worldSize: 10000, + imageSize: 10000, + maxZoom: 4, + minZoom: 0, + multiplier: 1, + tileBaseUrl: "", + }; + + beforeEach(() => { + container = document.createElement("div"); + Object.defineProperty(container, "clientWidth", { value: 400, configurable: true }); + Object.defineProperty(container, "clientHeight", { value: 400, configurable: true }); + document.body.appendChild(container); + renderer = new LeafletRenderer(); + renderer.init(container, WORLD as any); + const map = (renderer as any).map as L.Map; + map.invalidateSize(); + map.fire("move"); + }); + + afterEach(() => { + renderer.dispose(); + container.remove(); + }); + + function scaleLabel(): string { + return ( + container.querySelector(".leaflet-control-scale-line")?.textContent ?? "" + ); + } + + it("shows metres at native tile zoom, not a 2^maxZoom-scaled fraction", () => { + // Native zoom = 4, 1 px = 1 m, 100 px ≈ 100 m → "100 m". + // Leaflet Control.Scale would report ~100 / 2^4 = 6 m. + const map = (renderer as any).map as L.Map; + map.setZoom(4, { animate: false }); + expect(scaleLabel()).toBe("100 m"); + }); + + it("still changes the label after zooming past 1 m of world", () => { + const map = (renderer as any).map as L.Map; + // maxZoom is native+4 = 8; 1 m = 16 px, 100 px ≈ 6.25 m → "5 m". + map.setZoom(8, { animate: false }); + expect(scaleLabel()).toBe("5 m"); + }); + + it("matches 1 px = 1 m at native zoom for every zoom 0..8", () => { + const map = (renderer as any).map as L.Map; + const expected: Record = { + 0: "1 km", + 1: "500 m", + 2: "200 m", + 3: "200 m", + 4: "100 m", + 5: "50 m", + 6: "20 m", + 7: "10 m", + 8: "5 m", + }; + for (const zoom of Object.keys(expected).map(Number)) { + map.setZoom(zoom, { animate: false }); + const y = map.getSize().y / 2; + const from = map.containerPointToLatLng([0, y]); + const to = map.containerPointToLatLng([100, y]); + const span = armaMetersBetween( + (renderer as any).latLngToArma(from), + (renderer as any).latLngToArma(to), + ); + // 100 px / 2^(zoom - 4) metres when multiplier = 1. + expect(span, `zoom ${zoom} span`).toBeCloseTo(100 / 2 ** (zoom - 4), 6); + expect(scaleLabel(), `zoom ${zoom} label`).toBe(expected[zoom]); + const line = container.querySelector(".leaflet-control-scale-line") as HTMLElement; + expect(line.style.width).toBe( + `${scaleBarWidthPx(niceScaleDistance(span), span)}px`, + ); + } + }); + + it("Leaflet CRS.distance is 2^maxNativeZoom too small at native zoom", () => { + const map = (renderer as any).map as L.Map; + map.setZoom(4, { animate: false }); + const y = map.getSize().y / 2; + const from = map.containerPointToLatLng([0, y]); + const to = map.containerPointToLatLng([100, y]); + const armaMeters = armaMetersBetween( + (renderer as any).latLngToArma(from), + (renderer as any).latLngToArma(to), + ); + const leafletUnits = map.distance(from, to); + expect(armaMeters).toBeCloseTo(100, 6); + expect(leafletUnits).toBeCloseTo(100 / 2 ** 4, 6); + expect(armaMeters / leafletUnits).toBeCloseTo(16, 6); + }); +}); + +describe("scale bar with a non-1 tile multiplier", () => { + let container: HTMLDivElement; + let renderer: LeafletRenderer; + + beforeEach(() => { + container = document.createElement("div"); + Object.defineProperty(container, "clientWidth", { value: 400, configurable: true }); + Object.defineProperty(container, "clientHeight", { value: 400, configurable: true }); + document.body.appendChild(container); + renderer = new LeafletRenderer(); + renderer.init(container, { + worldName: "test", + worldSize: 10000, + imageSize: 10000, + maxZoom: 4, + minZoom: 0, + multiplier: 2, + tileBaseUrl: "", + } as any); + const map = (renderer as any).map as L.Map; + map.invalidateSize(); + }); + + afterEach(() => { + renderer.dispose(); + container.remove(); + }); + + it("treats native zoom as 2 px per metre", () => { + const map = (renderer as any).map as L.Map; + map.setZoom(4, { animate: false }); + const y = map.getSize().y / 2; + const from = map.containerPointToLatLng([0, y]); + const to = map.containerPointToLatLng([100, y]); + const span = armaMetersBetween( + (renderer as any).latLngToArma(from), + (renderer as any).latLngToArma(to), + ); + expect(span).toBeCloseTo(50, 6); + expect( + container.querySelector(".leaflet-control-scale-line")?.textContent, + ).toBe("50 m"); + }); +}); + +describe("scale bar on EPSG:3857 (MapLibre mode conversion)", () => { + let container: HTMLDivElement; + let map: L.Map; + + beforeEach(() => { + container = document.createElement("div"); + Object.defineProperty(container, "clientWidth", { value: 400, configurable: true }); + Object.defineProperty(container, "clientHeight", { value: 400, configurable: true }); + document.body.appendChild(container); + map = L.map(container, { + crs: L.CRS.EPSG3857, + center: [0, 0], + zoom: 18, + minZoom: 10, + maxZoom: 22, + attributionControl: false, + zoomControl: false, + }); + map.invalidateSize(); + }); + + afterEach(() => { + map.remove(); + container.remove(); + }); + + it("labels 100 px at zoom 18 as 50 m of Arma world", () => { + createScaleControl((ll) => latLngToArmaMapLibre(ll)).addTo(map); + map.setView([0, 0], 18, { animate: false }); + const y = map.getSize().y / 2; + const from = map.containerPointToLatLng([0, y]); + const to = map.containerPointToLatLng([100, y]); + const span = armaMetersBetween(latLngToArmaMapLibre(from), latLngToArmaMapLibre(to)); + const expected = 100 * (360 / (256 * 2 ** 18)) * METERS_PER_DEGREE; + expect(span).toBeCloseTo(expected, 4); + expect(span).toBeCloseTo(59.72, 1); + expect( + container.querySelector(".leaflet-control-scale-line")?.textContent, + ).toBe(formatScaleLabel(niceScaleDistance(span))); + expect( + container.querySelector(".leaflet-control-scale-line")?.textContent, + ).toBe("50 m"); + }); + + it("drops from kilometres to metres as zoom increases, never sticking at 1 m", () => { + createScaleControl((ll) => latLngToArmaMapLibre(ll)).addTo(map); + const labels: string[] = []; + for (const zoom of [12, 14, 16, 18, 20, 22]) { + map.setView([0, 0], zoom, { animate: false }); + labels.push( + container.querySelector(".leaflet-control-scale-line")?.textContent ?? "", + ); + } + expect(labels).toEqual(["2 km", "500 m", "200 m", "50 m", "10 m", "2 m"]); + }); +}); diff --git a/ui/src/renderers/leaflet/__tests__/scaleBar.test.ts b/ui/src/renderers/leaflet/__tests__/scaleBar.test.ts new file mode 100644 index 000000000..7a40a99c8 --- /dev/null +++ b/ui/src/renderers/leaflet/__tests__/scaleBar.test.ts @@ -0,0 +1,275 @@ +import { describe, it, expect } from "vitest"; +import { + niceScaleDistance, + formatScaleLabel, + scaleBarWidthPx, + armaMetersBetween, + SCALE_MIN_METERS, + SCALE_MAX_WIDTH_PX, +} from "../scaleBar"; + +/** + * Leaflet Control.Scale's integer-only rounder. Copied so these tests keep + * documenting why we cannot use it: every span under 1 m collapses to 1. + */ +function leafletGetRoundNum(num: number): number { + const pow10 = Math.pow(10, (Math.floor(num) + "").length - 1); + let d = num / pow10; + d = d >= 10 ? 10 : d >= 5 ? 5 : d >= 3 ? 3 : d >= 2 ? 2 : 1; + return pow10 * d; +} + +/** + * Horizontal metres covered by `widthPx` in the legacy Simple CRS: + * 1 arma metre = multiplier * 2^(zoom - maxNativeZoom) pixels. + */ +function legacyHorizontalMeters( + widthPx: number, + zoom: number, + maxNativeZoom: number, + multiplier = 1, +): number { + return widthPx / (multiplier * 2 ** (zoom - maxNativeZoom)); +} + +/** + * Horizontal Arma metres for `widthPx` on EPSG:3857. Δlng per pixel is + * 360 / (256 * 2^zoom), independent of latitude; we multiply by the same + * METERS_PER_DEGREE the renderer uses. + */ +function mapLibreHorizontalMeters( + widthPx: number, + zoom: number, + metersPerDegree: number, +): number { + return widthPx * (360 / (256 * 2 ** zoom)) * metersPerDegree; +} + +describe("niceScaleDistance", () => { + it("picks 1-2-5 values in kilometres", () => { + expect(niceScaleDistance(15000)).toBe(10000); + expect(niceScaleDistance(3500)).toBe(2000); + expect(niceScaleDistance(5000)).toBe(5000); + }); + + it("picks 1-2-5 values in metres", () => { + expect(niceScaleDistance(100)).toBe(100); + expect(niceScaleDistance(99)).toBe(50); + expect(niceScaleDistance(3.1)).toBe(2); + expect(niceScaleDistance(1.5)).toBe(1); + }); + + it("keeps shrinking below 1 m instead of sticking at 1 m", () => { + expect(niceScaleDistance(0.8)).toBe(0.5); + expect(niceScaleDistance(0.4)).toBe(0.2); + expect(niceScaleDistance(0.03)).toBe(0.02); + expect(niceScaleDistance(0.015)).toBe(0.01); + }); + + it("floors at 1 cm", () => { + expect(niceScaleDistance(0.004)).toBe(SCALE_MIN_METERS); + expect(niceScaleDistance(0)).toBe(SCALE_MIN_METERS); + expect(niceScaleDistance(-1)).toBe(SCALE_MIN_METERS); + expect(niceScaleDistance(Number.NaN)).toBe(SCALE_MIN_METERS); + expect(niceScaleDistance(Number.POSITIVE_INFINITY)).toBe(SCALE_MIN_METERS); + }); + + it("lands on the 1-2-5 step just below each threshold", () => { + const cases: Array<[number, number]> = [ + [0.019, 0.01], + [0.02, 0.02], + [0.049, 0.02], + [0.05, 0.05], + [0.099, 0.05], + [0.1, 0.1], + [0.199, 0.1], + [0.2, 0.2], + [0.499, 0.2], + [0.5, 0.5], + [0.999, 0.5], + [1, 1], + [1.999, 1], + [2, 2], + [4.999, 2], + [5, 5], + [9.999, 5], + [10, 10], + [19.99, 10], + [20, 20], + [49.99, 20], + [50, 50], + [199, 100], + [200, 200], + [499, 200], + [999, 500], + [1000, 1000], + [1600, 1000], + [1999, 1000], + [4999, 2000], + [6250, 5000], + [9999, 5000], + [10000, 10000], + ]; + for (const [span, expected] of cases) { + expect(niceScaleDistance(span), `span ${span}`).toBe(expected); + } + }); + + it("never returns Leaflet's stuck-at-1-m answer for sub-metre spans", () => { + for (const span of [0.01, 0.03, 0.08, 0.2, 0.5, 0.8, 0.99]) { + expect(leafletGetRoundNum(span)).toBe(1); + expect(niceScaleDistance(span)).toBeLessThan(1); + } + }); +}); + +describe("formatScaleLabel", () => { + it("uses km at and above 1 km", () => { + expect(formatScaleLabel(1000)).toBe("1 km"); + expect(formatScaleLabel(2000)).toBe("2 km"); + expect(formatScaleLabel(5000)).toBe("5 km"); + expect(formatScaleLabel(10000)).toBe("10 km"); + expect(formatScaleLabel(50000)).toBe("50 km"); + }); + + it("uses metres from 1 m up to 1 km", () => { + expect(formatScaleLabel(1)).toBe("1 m"); + expect(formatScaleLabel(2)).toBe("2 m"); + expect(formatScaleLabel(20)).toBe("20 m"); + expect(formatScaleLabel(50)).toBe("50 m"); + expect(formatScaleLabel(100)).toBe("100 m"); + expect(formatScaleLabel(200)).toBe("200 m"); + expect(formatScaleLabel(500)).toBe("500 m"); + }); + + it("switches to centimetres below 1 m", () => { + expect(formatScaleLabel(0.5)).toBe("50 cm"); + expect(formatScaleLabel(0.2)).toBe("20 cm"); + expect(formatScaleLabel(0.1)).toBe("10 cm"); + expect(formatScaleLabel(0.05)).toBe("5 cm"); + expect(formatScaleLabel(0.02)).toBe("2 cm"); + expect(formatScaleLabel(0.01)).toBe("1 cm"); + }); + + it("labels every 1-2-5 step from 1 cm to 50 km", () => { + const expected: Array<[number, string]> = [ + [0.01, "1 cm"], + [0.02, "2 cm"], + [0.05, "5 cm"], + [0.1, "10 cm"], + [0.2, "20 cm"], + [0.5, "50 cm"], + [1, "1 m"], + [2, "2 m"], + [5, "5 m"], + [10, "10 m"], + [20, "20 m"], + [50, "50 m"], + [100, "100 m"], + [200, "200 m"], + [500, "500 m"], + [1000, "1 km"], + [2000, "2 km"], + [5000, "5 km"], + [10000, "10 km"], + [20000, "20 km"], + [50000, "50 km"], + ]; + for (const [meters, label] of expected) { + expect(formatScaleLabel(meters)).toBe(label); + } + }); +}); + +describe("scaleBarWidthPx", () => { + it("is the fraction of maxWidth that niceMeters occupies", () => { + expect(scaleBarWidthPx(50, 100, 100)).toBe(50); + expect(scaleBarWidthPx(100, 100, 100)).toBe(100); + expect(scaleBarWidthPx(5, 6.25, 100)).toBe(80); + expect(scaleBarWidthPx(20, 25, 100)).toBe(80); + expect(scaleBarWidthPx(10, 12.5, 100)).toBe(80); + expect(scaleBarWidthPx(200, 400, 100)).toBe(50); + expect(scaleBarWidthPx(1000, 1600, 100)).toBe(63); + }); + + it("never returns less than 1 px", () => { + expect(scaleBarWidthPx(0.01, 100, 100)).toBe(1); + }); + + it("defaults the max width to the Leaflet 100 px target", () => { + expect(scaleBarWidthPx(50, 100)).toBe(SCALE_MAX_WIDTH_PX / 2); + }); + + it("grows past maxWidth when the 1 cm floor exceeds the span", () => { + // 100 px = 4 mm, labelled 1 cm → bar is 2.5× the 100 px target. + expect(scaleBarWidthPx(0.01, 0.004, 100)).toBe(250); + }); +}); + +describe("armaMetersBetween", () => { + it("is planar hypot in world metres", () => { + expect(armaMetersBetween([0, 0], [3, 4])).toBe(5); + expect(armaMetersBetween([1000, 2000], [1000, 2100])).toBe(100); + expect(armaMetersBetween([50, 50], [50, 50])).toBe(0); + }); +}); + +describe("legacy CRS metres-per-pixel formula", () => { + // multiplier=1, maxNativeZoom=4: 100 px spans 100 / 2^(z-4) metres. + const expectedByZoom: Array<[number, number, string, number]> = [ + [0, 1600, "1 km", 63], + [1, 800, "500 m", 63], + [2, 400, "200 m", 50], + [3, 200, "200 m", 100], + [4, 100, "100 m", 100], + [5, 50, "50 m", 100], + [6, 25, "20 m", 80], + [7, 12.5, "10 m", 80], + [8, 6.25, "5 m", 80], + ]; + + it("matches 1-2-5 labels at every zoom 0..native+4", () => { + for (const [zoom, span, label, width] of expectedByZoom) { + const measured = legacyHorizontalMeters(100, zoom, 4, 1); + expect(measured, `zoom ${zoom} span`).toBe(span); + expect(formatScaleLabel(niceScaleDistance(measured)), `zoom ${zoom}`).toBe(label); + expect(scaleBarWidthPx(niceScaleDistance(measured), measured), `zoom ${zoom} width`).toBe(width); + } + }); + + it("scales with a non-1 tile multiplier", () => { + // multiplier=2: at native zoom, 1 m = 2 px, so 100 px = 50 m. + expect(legacyHorizontalMeters(100, 4, 4, 2)).toBe(50); + expect(formatScaleLabel(niceScaleDistance(50))).toBe("50 m"); + // multiplier=0.5: at native zoom, 1 m = 0.5 px, so 100 px = 200 m. + expect(legacyHorizontalMeters(100, 4, 4, 0.5)).toBe(200); + expect(formatScaleLabel(niceScaleDistance(200))).toBe("200 m"); + }); + + it("reaches centimetres once 100 px is under 1 m of world", () => { + // native+7: 1 m = 128 px, 100 px ≈ 0.781 m → 50 cm. + const span = legacyHorizontalMeters(100, 11, 4, 1); + expect(span).toBeCloseTo(100 / 128, 10); + expect(formatScaleLabel(niceScaleDistance(span))).toBe("50 cm"); + // native+10: 1 m = 1024 px, 100 px ≈ 0.0977 m → 5 cm. + const tight = legacyHorizontalMeters(100, 14, 4, 1); + expect(formatScaleLabel(niceScaleDistance(tight))).toBe("5 cm"); + }); +}); + +describe("MapLibre / EPSG:3857 100 px span", () => { + const METERS_PER_DEGREE = 111320; + + it("is Δlng * 111320, matching the renderer's Arma conversion", () => { + // 100 px * 360° / (256 * 2^z) * 111320 m/° + expect(mapLibreHorizontalMeters(100, 12, METERS_PER_DEGREE)).toBeCloseTo(3821.87, 1); + expect(formatScaleLabel(niceScaleDistance(3821.87))).toBe("2 km"); + + expect(mapLibreHorizontalMeters(100, 18, METERS_PER_DEGREE)).toBeCloseTo(59.72, 1); + expect(formatScaleLabel(niceScaleDistance(59.72))).toBe("50 m"); + + // MapLibre's own max zoom is 22: still metres, not yet centimetres. + expect(mapLibreHorizontalMeters(100, 22, METERS_PER_DEGREE)).toBeCloseTo(3.73, 2); + expect(formatScaleLabel(niceScaleDistance(3.73))).toBe("2 m"); + }); +}); diff --git a/ui/src/renderers/leaflet/leafletControls.ts b/ui/src/renderers/leaflet/leafletControls.ts index e4dc51082..64e9070bb 100644 --- a/ui/src/renderers/leaflet/leafletControls.ts +++ b/ui/src/renderers/leaflet/leafletControls.ts @@ -2,16 +2,73 @@ * Custom Leaflet controls for the OCAP2 map UI. */ import L from "leaflet"; +import type { ArmaCoord } from "../../utils/coordinates"; +import { + armaMetersBetween, + formatScaleLabel, + niceScaleDistance, + SCALE_MAX_WIDTH_PX, + scaleBarWidthPx, +} from "./scaleBar"; + +/** Convert a Leaflet LatLng back to Arma [x, y] metres. */ +export type LatLngToArmaFn = (latlng: L.LatLng) => ArmaCoord; /** - * Create a standard Leaflet scale control with metric units. + * Scale bar labelled in Arma metres (then cm past 1 m). + * + * Leaflet's Control.Scale is not used: its Simple-CRS distance is not metres, + * and its rounder cannot represent distances below 1 m. + * + * Built with `L.Control.extend` (not an ES6 subclass) so Leaflet's event + * `on`/`off` context matches the instance the way the built-in scale does. */ -export function createScaleControl(): L.Control.Scale { - return L.control.scale({ - metric: true, - imperial: false, +const OcapScaleControl = L.Control.extend({ + options: { position: "bottomleft", - }); + maxWidth: SCALE_MAX_WIDTH_PX, + }, + + initialize(this: any, latLngToArma: LatLngToArmaFn) { + L.Util.setOptions(this, { position: "bottomleft", maxWidth: SCALE_MAX_WIDTH_PX }); + this._latLngToArma = latLngToArma; + }, + + onAdd(this: any, map: L.Map): HTMLElement { + this._map = map; + const container = L.DomUtil.create("div", "leaflet-control-scale"); + this._line = L.DomUtil.create("div", "leaflet-control-scale-line", container); + map.on("move zoomend", this._update, this); + map.whenReady(this._update, this); + return container; + }, + + onRemove(this: any, map: L.Map): void { + map.off("move zoomend", this._update, this); + }, + + _update(this: any): void { + const map: L.Map | undefined = this._map; + const line: HTMLDivElement | undefined = this._line; + const latLngToArma: LatLngToArmaFn | undefined = this._latLngToArma; + if (!map || !line || !latLngToArma) return; + + const maxWidth: number = this.options.maxWidth ?? SCALE_MAX_WIDTH_PX; + const y = map.getSize().y / 2; + const from = map.containerPointToLatLng([0, y]); + const to = map.containerPointToLatLng([maxWidth, y]); + const maxMeters = armaMetersBetween(latLngToArma(from), latLngToArma(to)); + const nice = niceScaleDistance(maxMeters); + line.style.width = `${scaleBarWidthPx(nice, maxMeters, maxWidth)}px`; + line.textContent = formatScaleLabel(nice); + }, +}); + +/** + * Create a metric scale control that measures Arma world metres. + */ +export function createScaleControl(latLngToArma: LatLngToArmaFn): L.Control { + return new (OcapScaleControl as any)(latLngToArma); } /** diff --git a/ui/src/renderers/leaflet/leafletRenderer.ts b/ui/src/renderers/leaflet/leafletRenderer.ts index 196d3a8c4..d5ee28c62 100644 --- a/ui/src/renderers/leaflet/leafletRenderer.ts +++ b/ui/src/renderers/leaflet/leafletRenderer.ts @@ -234,8 +234,8 @@ export class LeafletRenderer implements MapRenderer { this.initLegacyMode(container, world, maxZoom); } - // Add standard controls - createScaleControl().addTo(this.map); + // Scale bar in Arma metres (Leaflet's own scale uses CRS units, not metres). + createScaleControl((ll) => this.latLngToArma(ll)).addTo(this.map); // SVG renderer for briefing marker shapes — avoids canvas bitmap scaling // during zoom animation (the old frontend does the same: window.svgRenderer = L.svg()) diff --git a/ui/src/renderers/leaflet/scaleBar.ts b/ui/src/renderers/leaflet/scaleBar.ts new file mode 100644 index 000000000..0535dcc41 --- /dev/null +++ b/ui/src/renderers/leaflet/scaleBar.ts @@ -0,0 +1,64 @@ +/** + * Scale-bar math in Arma metres. Leaflet's own Control.Scale cannot be used: + * + * - Its CRS.distance for the legacy Simple CRS is lat/lng units, not metres + * (off by 2^maxNativeZoom). That is why the bar looked short at every zoom. + * - `_getRoundNum` uses `String(Math.floor(num)).length`, so any span under + * 1 m collapses to "1 m" and never switches to centimetres. + */ + +/** Leaflet's default scale-bar target width. */ +export const SCALE_MAX_WIDTH_PX = 100; + +/** Smallest labelled distance: 1 cm. */ +export const SCALE_MIN_METERS = 0.01; + +/** + * Largest 1-2-5 value that still fits in `maxMeters`. + * Works below 1 m (unlike Leaflet's integer-only `_getRoundNum`). + */ +export function niceScaleDistance(maxMeters: number): number { + if (!Number.isFinite(maxMeters) || maxMeters <= 0) return SCALE_MIN_METERS; + const clamped = Math.max(maxMeters, SCALE_MIN_METERS); + const exp = Math.floor(Math.log10(clamped)); + const mantissa = clamped / 10 ** exp; + const nice = mantissa >= 5 ? 5 : mantissa >= 2 ? 2 : 1; + // Scientific notation keeps 1-2-5 values exact (2e-1 === 0.2). + return Number(`${nice}e${exp}`); +} + +/** Label for a 1-2-5 metre value: km, m, or cm. */ +export function formatScaleLabel(meters: number): string { + if (meters >= 1000) { + const km = meters / 1000; + return `${formatNiceNumber(km)} km`; + } + if (meters >= 1) { + return `${formatNiceNumber(meters)} m`; + } + return `${Math.round(meters * 100)} cm`; +} + +function formatNiceNumber(value: number): string { + // 1-2-5 values are exact in decimal; strip IEEE noise (0.1 * 5 → 0.5000…). + const rounded = Math.round(value * 1e6) / 1e6; + return String(rounded); +} + +/** Pixel width of a bar representing `niceMeters` inside a `maxMeters` span. */ +export function scaleBarWidthPx( + niceMeters: number, + maxMeters: number, + maxWidth = SCALE_MAX_WIDTH_PX, +): number { + if (!(maxMeters > 0) || !(niceMeters > 0)) return maxWidth; + return Math.max(1, Math.round(maxWidth * (niceMeters / maxMeters))); +} + +/** Planar distance in Arma metres between two world positions. */ +export function armaMetersBetween( + a: [number, number], + b: [number, number], +): number { + return Math.hypot(b[0] - a[0], b[1] - a[1]); +} From 6b0e9a3fac06612388102d5ccb4eb657e73fd89a Mon Sep 17 00:00:00 2001 From: Yethe Samartaka Date: Wed, 14 Oct 2026 07:08:35 +0200 Subject: [PATCH 26/39] zeus improvements + fps --- ui/src/components/Icons.tsx | 7 + ui/src/i18n/locales.ts | 27 + .../__tests__/FpsTab.test.tsx | 181 ++++ .../__tests__/SidePanel.test.tsx | 40 +- .../__tests__/StatsTab.test.tsx | 79 +- .../__tests__/deepLink.test.ts | 2 +- .../__tests__/fpsDiagnostics.test.ts | 211 +++++ .../components/FpsTab.module.css | 576 ++++++++++++ .../recording-playback/components/FpsTab.tsx | 850 ++++++++++++++++++ .../components/PlayerProfileCard.module.css | 4 +- .../components/SidePanel.module.css | 51 +- .../components/SidePanel.tsx | 26 +- .../components/StatsTab.tsx | 40 - ui/src/pages/recording-playback/deepLink.ts | 2 +- .../recording-playback/fpsDiagnostics.ts | 217 +++++ .../playback/__tests__/serverFpsStats.test.ts | 24 + ui/src/playback/engine.ts | 45 + ui/src/playback/eventManager.ts | 16 +- ui/src/styles/variables.css | 2 +- 19 files changed, 2236 insertions(+), 164 deletions(-) create mode 100644 ui/src/pages/recording-playback/__tests__/FpsTab.test.tsx create mode 100644 ui/src/pages/recording-playback/__tests__/fpsDiagnostics.test.ts create mode 100644 ui/src/pages/recording-playback/components/FpsTab.module.css create mode 100644 ui/src/pages/recording-playback/components/FpsTab.tsx create mode 100644 ui/src/pages/recording-playback/fpsDiagnostics.ts diff --git a/ui/src/components/Icons.tsx b/ui/src/components/Icons.tsx index d72b315f4..f19880425 100644 --- a/ui/src/components/Icons.tsx +++ b/ui/src/components/Icons.tsx @@ -60,6 +60,13 @@ export const BarChartIcon = (p: IconProps): JSX.Element => ( ); +export const GaugeIcon = (p: IconProps): JSX.Element => ( + + + + +); + export const MessageSquareIcon = (p: IconProps): JSX.Element => ( diff --git a/ui/src/i18n/locales.ts b/ui/src/i18n/locales.ts index 5ffd88274..02ef76072 100644 --- a/ui/src/i18n/locales.ts +++ b/ui/src/i18n/locales.ts @@ -3570,4 +3570,31 @@ export const translations: Record> = { comms_no_matches: { en: "No nets match this search", ru: "Нет сетей по этому запросу", de: "Keine Netze passen zur Suche", cs: "Hledání neodpovídají žádné sítě", it: "Nessuna rete corrisponde alla ricerca", fr: "Aucun réseau ne correspond à cette recherche", fi: "Haku ei vastaa yhtään verkkoa", uk: "Немає мереж за цим запитом" }, comms_no_transmission_matches: { en: "No transmissions match this search", ru: "Нет передач по этому запросу", de: "Keine Funksprüche passen zur Suche", cs: "Hledání neodpovídají žádné přenosy", it: "Nessuna trasmissione corrisponde alla ricerca", fr: "Aucune transmission ne correspond à cette recherche", fi: "Haku ei vastaa yhtään lähetystä", uk: "Немає передач за цим запитом" }, profile_diff_new_log: { en: "New since then", ru: "Новое с тех пор", de: "Neu seitdem", cs: "Nové od té doby", it: "Nuovo da allora", fr: "Nouveau depuis", fi: "Uutta sen jälkeen", uk: "Нове відтоді" }, + fps: { en: "FPS", ru: "FPS", de: "FPS", cs: "FPS", it: "FPS", fr: "FPS", fi: "FPS", uk: "FPS" }, + minimum: { en: "Minimum", ru: "Минимум", de: "Minimum", cs: "Minimum", it: "Minimo", fr: "Minimum", fi: "Minimi", uk: "Мінімум" }, + fps_units: { en: "Units", ru: "Юниты", de: "Einheiten", cs: "Jednotky", it: "Unità", fr: "Unités", fi: "Yksiköt", uk: "Юніти" }, + fps_vehicles: { en: "Vehicles", ru: "Техника", de: "Fahrzeuge", cs: "Vozidla", it: "Veicoli", fr: "Véhicules", fi: "Ajoneuvot", uk: "Техніка" }, + fps_players: { en: "Players", ru: "Игроки", de: "Spieler", cs: "Hráči", it: "Giocatori", fr: "Joueurs", fi: "Pelaajat", uk: "Гравці" }, + fps_ai: { en: "AI", ru: "ИИ", de: "KI", cs: "AI", it: "IA", fr: "IA", fi: "Tekoäly", uk: "ШІ" }, + fps_population: { en: "Alive units & vehicles", ru: "Живые юниты и техника", de: "Lebende Einheiten & Fahrzeuge", cs: "Živé jednotky a vozidla", it: "Unità e veicoli attivi", fr: "Unités et véhicules en vie", fi: "Elossa olevat yksiköt ja ajoneuvot", uk: "Живі юніти й техніка" }, + fps_zone_critical: { en: "Below 20", ru: "Ниже 20", de: "Unter 20", cs: "Pod 20", it: "Sotto 20", fr: "Sous 20", fi: "Alle 20", uk: "Нижче 20" }, + fps_zone_low: { en: "20–30", ru: "20–30", de: "20–30", cs: "20–30", it: "20–30", fr: "20–30", fi: "20–30", uk: "20–30" }, + fps_correlation: { en: "Correlation with FPS", ru: "Корреляция с FPS", de: "Korrelation mit FPS", cs: "Korelace s FPS", it: "Correlazione con FPS", fr: "Corrélation avec les FPS", fi: "Korrelaatio FPS:n kanssa", uk: "Кореляція з FPS" }, + fps_correlation_hint: { en: "Negative means FPS falls as the count rises", ru: "Отрицательная — FPS падает, когда число растёт", de: "Negativ heißt: FPS sinken, wenn die Anzahl steigt", cs: "Záporná znamená, že FPS klesá, když počet roste", it: "Negativa: gli FPS calano quando il numero cresce", fr: "Négative : les FPS baissent quand le nombre augmente", fi: "Negatiivinen: FPS laskee, kun määrä kasvaa", uk: "Від'ємна — FPS падає, коли кількість зростає" }, + fps_corr_none: { en: "none", ru: "нет", de: "keine", cs: "žádná", it: "nessuna", fr: "aucune", fi: "ei", uk: "немає" }, + fps_corr_weak: { en: "weak", ru: "слабая", de: "schwach", cs: "slabá", it: "debole", fr: "faible", fi: "heikko", uk: "слабка" }, + fps_corr_moderate: { en: "moderate", ru: "средняя", de: "mittel", cs: "střední", it: "moderata", fr: "modérée", fi: "kohtalainen", uk: "середня" }, + fps_corr_strong: { en: "strong", ru: "сильная", de: "stark", cs: "silná", it: "forte", fr: "forte", fi: "vahva", uk: "сильна" }, + fps_lowest: { en: "Lowest moments", ru: "Худшие моменты", de: "Tiefpunkte", cs: "Nejhorší okamžiky", it: "Momenti peggiori", fr: "Pires moments", fi: "Heikoimmat hetket", uk: "Найгірші моменти" }, + fps_time: { en: "Time", ru: "Время", de: "Zeit", cs: "Čas", it: "Tempo", fr: "Temps", fi: "Aika", uk: "Час" }, + fps_jump_min: { en: "Jump to the lowest server FPS", ru: "Перейти к минимальному FPS сервера", de: "Zum niedrigsten Server-FPS springen", cs: "Přejít na nejnižší FPS serveru", it: "Vai al punto con gli FPS server più bassi", fr: "Aller au FPS serveur le plus bas", fi: "Siirry palvelimen alimpaan FPS-arvoon", uk: "Перейти до мінімального FPS сервера" }, + fps_at: { en: "at {time}", ru: "в {time}", de: "bei {time}", cs: "v {time}", it: "a {time}", fr: "à {time}", fi: "kohdassa {time}", uk: "о {time}" }, + fps_samples: { en: "{count} samples", ru: "замеров: {count}", de: "{count} Messwerte", cs: "{count} vzorků", it: "{count} campioni", fr: "{count} relevés", fi: "{count} näytettä", uk: "вимірів: {count}" }, + fps_every: { en: "every {seconds} s", ru: "каждые {seconds} с", de: "alle {seconds} s", cs: "každých {seconds} s", it: "ogni {seconds} s", fr: "toutes les {seconds} s", fi: "{seconds} s välein", uk: "кожні {seconds} с" }, + fps_click_hint: { en: "Click or drag the graph to move the timeline", ru: "Нажмите или проведите по графику, чтобы перемотать", de: "Klicke oder ziehe im Graphen, um die Timeline zu bewegen", cs: "Kliknutím nebo tažením v grafu posunete časovou osu", it: "Clicca o trascina sul grafico per spostare la timeline", fr: "Cliquez ou glissez sur le graphique pour déplacer la timeline", fi: "Napsauta tai vedä kaaviota siirtääksesi aikajanaa", uk: "Натисніть або проведіть по графіку, щоб перемотати" }, + fps_no_samples: { en: "This recording has no server FPS samples. Turn on “Track Server FPS” in the OCAP server settings to record them.", ru: "В этой записи нет замеров FPS сервера. Включите «Track Server FPS» в настройках OCAP на сервере.", de: "Diese Aufnahme enthält keine Server-FPS-Messwerte. Aktiviere „Track Server FPS“ in den OCAP-Servereinstellungen, um sie aufzuzeichnen.", cs: "Tento záznam neobsahuje vzorky FPS serveru. Zapněte „Track Server FPS“ v nastavení OCAP na serveru.", it: "Questa registrazione non contiene campioni FPS del server. Attiva “Track Server FPS” nelle impostazioni OCAP del server.", fr: "Cet enregistrement ne contient aucun relevé de FPS serveur. Activez « Track Server FPS » dans les paramètres OCAP du serveur.", fi: "Tässä tallenteessa ei ole palvelimen FPS-näytteitä. Ota ”Track Server FPS” käyttöön OCAPin palvelinasetuksista.", uk: "У цьому записі немає вимірів FPS сервера. Увімкніть «Track Server FPS» у налаштуваннях OCAP на сервері." }, + fps_counting: { en: "Counting units and vehicles…", ru: "Подсчёт юнитов и техники…", de: "Einheiten und Fahrzeuge werden gezählt…", cs: "Počítám jednotky a vozidla…", it: "Conteggio di unità e veicoli…", fr: "Comptage des unités et des véhicules…", fi: "Lasketaan yksiköitä ja ajoneuvoja…", uk: "Підрахунок юнітів і техніки…" }, + fps_counts_failed: { en: "Unit and vehicle counts could not be loaded.", ru: "Не удалось подсчитать юнитов и технику.", de: "Einheiten und Fahrzeuge konnten nicht gezählt werden.", cs: "Počty jednotek a vozidel se nepodařilo načíst.", it: "Impossibile caricare il conteggio di unità e veicoli.", fr: "Impossible de charger le nombre d'unités et de véhicules.", fi: "Yksiköiden ja ajoneuvojen määrää ei voitu ladata.", uk: "Не вдалося підрахувати юнітів і техніку." }, + fps_chart_fps: { en: "Server FPS over the mission", ru: "FPS сервера за миссию", de: "Server-FPS im Missionsverlauf", cs: "FPS serveru během mise", it: "FPS server durante la missione", fr: "FPS serveur au fil de la mission", fi: "Palvelimen FPS tehtävän aikana", uk: "FPS сервера протягом місії" }, + fps_chart_population: { en: "Alive units and vehicles over the mission", ru: "Живые юниты и техника за миссию", de: "Lebende Einheiten und Fahrzeuge im Missionsverlauf", cs: "Živé jednotky a vozidla během mise", it: "Unità e veicoli attivi durante la missione", fr: "Unités et véhicules en vie au fil de la mission", fi: "Elossa olevat yksiköt ja ajoneuvot tehtävän aikana", uk: "Живі юніти й техніка протягом місії" }, }; diff --git a/ui/src/pages/recording-playback/__tests__/FpsTab.test.tsx b/ui/src/pages/recording-playback/__tests__/FpsTab.test.tsx new file mode 100644 index 000000000..e61f9295e --- /dev/null +++ b/ui/src/pages/recording-playback/__tests__/FpsTab.test.tsx @@ -0,0 +1,181 @@ +import { describe, it, expect, afterEach, vi } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@solidjs/testing-library"; +import { FpsTab } from "../components/FpsTab"; +import type { EntityState, EventDef } from "../../../data/types"; +import { createTestEngine, TestProviders, unitDef, makeManifest } from "./testHelpers"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const SAMPLES: EventDef[] = [ + { frameNum: 0, type: "serverFps", fps: 40 }, + { frameNum: 60, type: "serverFps", fps: 20 }, + { frameNum: 120, type: "serverFps", fps: 30 }, + { frameNum: 180, type: "serverFps", fps: 50 }, +]; + +function alive(startFrame: number, endFrame: number): EntityState[] { + return Array.from({ length: endFrame - startFrame + 1 }, () => ({ + position: [0, 0], + direction: 0, + alive: 1, + })); +} + +function renderTab(events: EventDef[] = SAMPLES, units = [unitDef({ id: 1, endFrame: 180, positions: alive(0, 180) })]) { + const { engine, renderer } = createTestEngine(); + engine.loadRecording(makeManifest(units, events, 180)); + render(() => ( + + + + )); + return engine; +} + +describe("FpsTab", () => { + // Current, average and median keep the Stats contract: everything up to the + // playhead. The minimum is the worst moment of the whole recording, because + // its job is to take the viewer there. + it("shows playhead-scoped stats beside the recording's minimum", () => { + const { engine, renderer } = createTestEngine(); + engine.loadRecording(makeManifest([unitDef({ id: 1 })], SAMPLES, 180)); + engine.seekTo(120); + + render(() => ( + + + + )); + + for (const label of ["Current", "Average", "Median"]) { + expect(screen.getByText(label).previousElementSibling?.textContent).toBe("30.0"); + } + const minimum = screen.getByTitle("Jump to the lowest server FPS"); + expect(minimum.textContent).toContain("20.0"); + expect(minimum.textContent).toContain("at 0:01:00"); + }); + + it("keeps the threshold colours on the stat tiles", () => { + const { engine, renderer } = createTestEngine(); + engine.loadRecording(makeManifest( + [unitDef({ id: 1 })], + [ + { frameNum: 0, type: "serverFps", fps: 10 }, + { frameNum: 10, type: "serverFps", fps: 25 }, + { frameNum: 20, type: "serverFps", fps: 80 }, + ], + 20, + )); + engine.seekTo(20); + + render(() => ( + + + + )); + + expect(screen.getByText("Current").previousElementSibling?.getAttribute("style")) + .toContain("var(--accent-success)"); + expect(screen.getByText("Average").previousElementSibling?.getAttribute("style")) + .toContain("var(--accent-warning)"); + expect(screen.getByText("Median").previousElementSibling?.getAttribute("style")) + .toContain("rgb(255, 138, 61)"); + expect(screen.getByText("Minimum").previousElementSibling?.getAttribute("style")) + .toContain("var(--accent-danger)"); + }); + + it("moves the timeline to the minimum when it is clicked", () => { + const engine = renderTab(); + expect(engine.currentFrame()).toBe(0); + + fireEvent.click(screen.getByTitle("Jump to the lowest server FPS")); + + expect(engine.currentFrame()).toBe(60); + }); + + it("lists the lowest moments and jumps to one when it is clicked", () => { + const engine = renderTab(); + + const rows = screen.getByText("Lowest moments").parentElement!.querySelectorAll("button"); + expect([...rows].map((row) => row.textContent)).toEqual([ + expect.stringContaining("20.0"), + expect.stringContaining("30.0"), + expect.stringContaining("40.0"), + expect.stringContaining("50.0"), + ]); + + fireEvent.click(rows[1]); + expect(engine.currentFrame()).toBe(120); + }); + + // The graph is the timeline's twin: a click lands on the sample under the + // pointer, which is the moment the crosshair and tooltip were describing. + it("moves the timeline to the sample clicked on the graph", () => { + const engine = renderTab(); + const graph = screen.getByRole("img", { name: "Server FPS over the mission" }); + // The plot runs between a 40px left and a 14px right gutter of the pane. + const width = Number(graph.getAttribute("width")); + const xFor = (frame: number) => 40 + ((width - 54) * frame) / 180; + + // Just past frame 120 still lands on the 120 sample. + fireEvent.pointerDown(graph, { clientX: xFor(125), pointerId: 1 }); + expect(engine.currentFrame()).toBe(120); + + // Dragging keeps scrubbing. + fireEvent.pointerMove(graph, { clientX: xFor(10), pointerId: 1 }); + expect(engine.currentFrame()).toBe(0); + fireEvent.pointerUp(graph, { clientX: xFor(10), pointerId: 1 }); + + // A plain hover afterwards only moves the crosshair. + fireEvent.pointerMove(graph, { clientX: xFor(170), pointerId: 1 }); + expect(engine.currentFrame()).toBe(0); + // The axis carries the same time label, so find the readout by its FPS row. + const readout = screen.getAllByText("0:03:00") + .map((time) => time.parentElement!) + .find((parent) => parent.textContent?.includes("FPS")); + expect(readout?.textContent).toContain("50.0"); + }); + + it("explains a recording without samples instead of showing tiles", () => { + renderTab([]); + + expect(screen.getByText(/no server FPS samples/)).toBeTruthy(); + expect(screen.queryByText("Current")).toBeNull(); + expect(screen.queryByTitle("Jump to the lowest server FPS")).toBeNull(); + // The population graph still works without FPS. + expect(screen.getByRole("img", { name: "Alive units and vehicles over the mission" })).toBeTruthy(); + }); + + it("correlates FPS with the population once the units are counted", async () => { + // Four units drop out one by one while FPS climbs: a perfectly inverse + // relationship. + renderTab([ + { frameNum: 0, type: "serverFps", fps: 20 }, + { frameNum: 60, type: "serverFps", fps: 30 }, + { frameNum: 120, type: "serverFps", fps: 40 }, + { frameNum: 180, type: "serverFps", fps: 50 }, + ], [ + unitDef({ id: 1, endFrame: 180, positions: alive(0, 180) }), + unitDef({ id: 2, endFrame: 30, positions: alive(0, 30) }), + unitDef({ id: 3, endFrame: 90, positions: alive(0, 90) }), + unitDef({ id: 4, endFrame: 150, positions: alive(0, 150) }), + ]); + + // Every unit here is AI, so the AI row matches the Units row. + const [units] = await screen.findAllByText("−1.00"); + expect(units.parentElement?.textContent).toContain("Units"); + expect(units.parentElement?.textContent).toContain("strong"); + }); + + it("toggles a population series from its legend chip", () => { + renderTab(); + + const ai = screen.getAllByRole("button").find((button) => button.textContent?.startsWith("AI"))!; + expect(ai.getAttribute("aria-pressed")).toBe("false"); + fireEvent.click(ai); + expect(ai.getAttribute("aria-pressed")).toBe("true"); + }); +}); diff --git a/ui/src/pages/recording-playback/__tests__/SidePanel.test.tsx b/ui/src/pages/recording-playback/__tests__/SidePanel.test.tsx index e9e172b73..ec12fcef7 100644 --- a/ui/src/pages/recording-playback/__tests__/SidePanel.test.tsx +++ b/ui/src/pages/recording-playback/__tests__/SidePanel.test.tsx @@ -27,12 +27,12 @@ describe("SidePanel", () => { )); - for (const label of ["Units", "Events", "ORBAT", "Comms", "Stats"]) { + for (const label of ["Units", "Events", "ORBAT", "Comms", "Stats", "FPS"]) { expect(screen.getByText(label)).toBeTruthy(); } }); - // Five tabs share a panel fixed at 370px, so an inactive label can be + // Six tabs share a panel fixed at 420px, so a long translation can be // truncated to fit. The tooltip is the only place the full name survives, and // it is what stops a clipped tab from becoming an unidentifiable icon. it("gives every tab a title carrying its full label", () => { @@ -47,7 +47,7 @@ describe("SidePanel", () => { )); - for (const label of ["Units", "Events", "ORBAT", "Comms", "Stats"]) { + for (const label of ["Units", "Events", "ORBAT", "Comms", "Stats", "FPS"]) { expect(screen.getByTitle(label)).toBeTruthy(); } }); @@ -71,10 +71,42 @@ describe("SidePanel", () => { fireEvent.click(screen.getByText("Stats")); expect(onTabChange).toHaveBeenCalledWith("stats"); - fireEvent.click(screen.getByText("Units")); + fireEvent.click(screen.getByText("FPS")); + expect(onTabChange).toHaveBeenCalledWith("fps"); + + // The FPS tab's own legend also says "Units", so go by the tab's title. + fireEvent.click(screen.getByTitle("Units")); expect(onTabChange).toHaveBeenCalledWith("units"); }); + // The FPS graph needs room, so that tab widens the panel. The width lives on + // the root, where the map controls and the profile card read it too, and it + // has to go back the moment the viewer leaves the tab or closes the panel. + it("widens the panel only while the FPS tab is open", () => { + const { engine, renderer } = createTestEngine(); + engine.loadRecording(makeManifest([unitDef()])); + + const [activeTab, setActiveTab] = createSignal("units"); + const width = () => document.documentElement.style.getPropertyValue("--pb-panel-width"); + + const { unmount } = render(() => ( + + + + )); + + expect(width()).toBe(""); + fireEvent.click(screen.getByText("FPS")); + expect(width()).toContain("clamp("); + fireEvent.click(screen.getByText("Stats")); + expect(width()).toBe(""); + + setActiveTab("fps"); + expect(width()).toContain("clamp("); + unmount(); + expect(width()).toBe(""); + }); + it("shows UnitsTab content when activeTab is 'units'", () => { const { engine, renderer } = createTestEngine(); engine.loadRecording( diff --git a/ui/src/pages/recording-playback/__tests__/StatsTab.test.tsx b/ui/src/pages/recording-playback/__tests__/StatsTab.test.tsx index c1b93102d..10525fbbd 100644 --- a/ui/src/pages/recording-playback/__tests__/StatsTab.test.tsx +++ b/ui/src/pages/recording-playback/__tests__/StatsTab.test.tsx @@ -16,40 +16,19 @@ afterEach(() => { }); describe("StatsTab", () => { - it("shows playhead-scoped server FPS stats above Force Summary when samples exist", () => { + // Server FPS moved to its own tab with the graph; Stats must not show a + // second, partial copy of it. + it("leaves server FPS to the FPS tab even when samples exist", () => { const { engine, renderer } = createTestEngine(); engine.loadRecording(makeManifest( [unitDef({ id: 1, side: "WEST" })], [ { frameNum: 0, type: "serverFps", fps: 40 }, { frameNum: 60, type: "serverFps", fps: 20 }, - { frameNum: 120, type: "serverFps", fps: 30 }, - { frameNum: 180, type: "serverFps", fps: 50 }, ], - 180, + 60, )); - engine.seekTo(120); - - render(() => ( - - - - )); - - expect(screen.getByText("Server FPS")).toBeTruthy(); - expect(screen.getByText("Current")).toBeTruthy(); - expect(screen.getByText("Average")).toBeTruthy(); - expect(screen.getByText("Median")).toBeTruthy(); - expect(screen.getAllByText("30.0")).toHaveLength(3); - - const fpsHeading = screen.getByText("Server FPS"); - const forceHeading = screen.getByText("Force Summary"); - expect(fpsHeading.compareDocumentPosition(forceHeading) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - }); - - it("hides server FPS stats for backwards-compatible recordings without samples", () => { - const { engine, renderer } = createTestEngine(); - engine.loadRecording(makeManifest([unitDef({ id: 1, side: "WEST" })])); + engine.seekTo(60); render(() => ( @@ -58,56 +37,10 @@ describe("StatsTab", () => { )); expect(screen.queryByText("Server FPS")).toBeNull(); - expect(screen.queryByText("Weather")).toBeNull(); + expect(screen.queryByText("Median")).toBeNull(); expect(screen.getByText("Force Summary")).toBeTruthy(); }); - it("colors each FPS number independently by threshold", () => { - const { engine, renderer } = createTestEngine(); - engine.loadRecording(makeManifest( - [unitDef({ id: 1, side: "WEST" })], - [ - { frameNum: 0, type: "serverFps", fps: 10 }, - { frameNum: 10, type: "serverFps", fps: 25 }, - { frameNum: 20, type: "serverFps", fps: 80 }, - ], - 20, - )); - engine.seekTo(20); - - render(() => ( - - - - )); - - expect(screen.getByText("Current").previousElementSibling?.getAttribute("style")) - .toContain("var(--accent-success)"); - expect(screen.getByText("Average").previousElementSibling?.getAttribute("style")) - .toContain("var(--accent-warning)"); - expect(screen.getByText("Median").previousElementSibling?.getAttribute("style")) - .toContain("rgb(255, 138, 61)"); - }); - - it("colors FPS values below 20 red", () => { - const { engine, renderer } = createTestEngine(); - engine.loadRecording(makeManifest( - [unitDef({ id: 1, side: "WEST" })], - [{ frameNum: 0, type: "serverFps", fps: 19.9 }], - )); - - render(() => ( - - - - )); - - for (const label of ["Current", "Average", "Median"]) { - expect(screen.getByText(label).previousElementSibling?.getAttribute("style")) - .toContain("var(--accent-danger)"); - } - }); - it("shows playhead-scoped weather after the leaderboard when samples exist", () => { const { engine, renderer } = createTestEngine(); engine.loadRecording(makeManifest( diff --git a/ui/src/pages/recording-playback/__tests__/deepLink.test.ts b/ui/src/pages/recording-playback/__tests__/deepLink.test.ts index 6f87a92ae..8f09e1a66 100644 --- a/ui/src/pages/recording-playback/__tests__/deepLink.test.ts +++ b/ui/src/pages/recording-playback/__tests__/deepLink.test.ts @@ -50,7 +50,7 @@ describe("readDeepLink", () => { }); it("keeps every tab the side panel actually has", () => { - for (const tab of ["units", "events", "orbat", "comms", "stats"]) { + for (const tab of ["units", "events", "orbat", "comms", "stats", "fps"]) { expect(readDeepLink(`?tab=${tab}`).tab).toBe(tab); } }); diff --git a/ui/src/pages/recording-playback/__tests__/fpsDiagnostics.test.ts b/ui/src/pages/recording-playback/__tests__/fpsDiagnostics.test.ts new file mode 100644 index 000000000..4fd73a5e1 --- /dev/null +++ b/ui/src/pages/recording-playback/__tests__/fpsDiagnostics.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, vi } from "vitest"; +import type { ChunkManager } from "../../../data/chunkManager"; +import type { AliveState, ChunkData, EntityDef, EntityState } from "../../../data/types"; +import { + buildPopulationSamples, + correlationStrength, + findLowestDips, + nearestIndex, + niceScale, + pearson, + populationFrames, + timeTicks, +} from "../fpsDiagnostics"; +import { createTestEngine, makeManifest, unitDef, vehicleDef } from "./testHelpers"; + +/** Dense per-frame states for an entity living `startFrame..endFrame`. */ +function states( + startFrame: number, + endFrame: number, + at: (frame: number) => Partial = () => ({}), +): EntityState[] { + const result: EntityState[] = []; + for (let frame = startFrame; frame <= endFrame; frame++) { + result.push({ position: [0, 0], direction: 0, alive: 1, ...at(frame) }); + } + return result; +} + +function living(def: Partial & { id: number }, at?: (frame: number) => Partial): EntityDef { + const startFrame = def.startFrame ?? 0; + const endFrame = def.endFrame ?? 10; + const base = def.type && def.type !== "man" && def.type !== "zeus" ? vehicleDef : unitDef; + return base({ ...def, startFrame, endFrame, positions: states(startFrame, endFrame, at) }); +} + +describe("populationFrames", () => { + // Counting at the sample frames is what pairs each FPS value with the load + // at that same moment. + it("counts at the FPS sample frames when there are samples", () => { + expect(populationFrames( + [{ frame: 0, fps: 40 }, { frame: 10, fps: 30 }, { frame: 10, fps: 31 }, { frame: 20, fps: 20 }], + 100, + 1000, + )).toEqual([0, 10, 20]); + }); + + it("falls back to a ten-second grid that reaches the last frame", () => { + expect(populationFrames([], 35, 1000)).toEqual([0, 10, 20, 30, 35]); + expect(populationFrames([{ frame: 0, fps: 40 }], 20, 500)).toEqual([0, 20]); + }); + + it("caps the grid on a very long recording", () => { + const frames = populationFrames([], 1_000_000, 1000); + expect(frames.length).toBeLessThanOrEqual(1501); + expect(frames[frames.length - 1]).toBe(1_000_000); + }); +}); + +describe("buildPopulationSamples", () => { + it("counts alive units, players, AI and vehicles at each frame", async () => { + const { engine } = createTestEngine(); + engine.loadRecording(makeManifest( + [ + living({ id: 1 }, () => ({ isPlayer: false })), + // A player who dies at frame 6: a body is not load the way a man is. + living({ id: 2 }, (frame) => ({ isPlayer: true, alive: (frame >= 6 ? 0 : 1) as AliveState })), + // Unconscious is still simulated. + living({ id: 3 }, () => ({ alive: 2 as AliveState, isPlayer: false })), + // Takes over a slot at frame 5 and is a player from then on. + living({ id: 4 }, (frame) => ({ isPlayer: frame >= 5 })), + // Spawns late. + living({ id: 5, startFrame: 8 }, () => ({ isPlayer: false })), + living({ id: 50, type: "car" }), + living({ id: 51, type: "tank" }, (frame) => ({ alive: (frame >= 3 ? 0 : 1) as AliveState })), + // Zeus is a camera, not a simulated unit. + living({ id: 90, type: "zeus" }, () => ({ isPlayer: true })), + ], + [], + 10, + )); + + const samples = await buildPopulationSamples(engine, [10, 0, 5, 5]); + + expect(samples).toEqual([ + { frame: 0, units: 4, players: 1, ai: 3, vehicles: 2 }, + { frame: 5, units: 4, players: 2, ai: 2, vehicles: 1 }, + { frame: 10, units: 4, players: 1, ai: 3, vehicles: 1 }, + ]); + }); + + // The chunk cache keeps three chunks. Loading every chunk and then reading + // would see only the last three, so the counts must be taken chunk by chunk. + it("reads every chunk of a chunked recording, in order", async () => { + const chunkSize = 10; + const chunkCount = 5; + const endFrame = chunkSize * chunkCount - 1; + const chunks: ChunkData[] = []; + for (let index = 0; index < chunkCount; index++) { + const first = index * chunkSize; + chunks.push({ + entities: new Map([ + [1, states(first, first + chunkSize - 1)], + // A second unit only alive in the odd chunks. + [2, states(first, first + chunkSize - 1, () => ({ alive: (index % 2) as AliveState }))], + ]), + }); + } + const loadChunk = vi.fn((index: number) => Promise.resolve(chunks[index])); + const chunkManager = { + setCallbacks: () => {}, + getChunkForFrame: () => null, + ensureLoaded: () => Promise.resolve(), + loadChunk, + } as unknown as ChunkManager; + + const { engine } = createTestEngine(); + const manifest = makeManifest( + [ + unitDef({ id: 1, startFrame: 0, endFrame, positions: undefined }), + unitDef({ id: 2, startFrame: 0, endFrame, positions: undefined }), + ], + [], + endFrame, + ); + engine.loadRecording({ ...manifest, chunkSize, chunkCount }, chunkManager); + + const samples = await buildPopulationSamples(engine, [5, 15, 25, 35, 45]); + + expect(samples.map((sample) => sample.units)).toEqual([1, 2, 1, 2, 1]); + expect(loadChunk.mock.calls.map(([index]) => index)).toEqual([0, 1, 2, 3, 4]); + }); +}); + +describe("findLowestDips", () => { + const samples = [ + { frame: 0, fps: 45 }, + { frame: 10, fps: 12 }, + { frame: 20, fps: 14 }, + { frame: 100, fps: 25 }, + { frame: 200, fps: 12 }, + { frame: 300, fps: 50 }, + ]; + + it("lists the lowest samples first, the earliest on a tie", () => { + expect(findLowestDips(samples, 3, 1)).toEqual([ + { frame: 10, fps: 12 }, + { frame: 200, fps: 12 }, + { frame: 20, fps: 14 }, + ]); + }); + + // Frame 20 is the same bad minute as frame 10, not a second dip. + it("keeps the picks at least the gap apart", () => { + expect(findLowestDips(samples, 3, 60)).toEqual([ + { frame: 10, fps: 12 }, + { frame: 200, fps: 12 }, + { frame: 100, fps: 25 }, + ]); + }); + + it("returns nothing for a recording without samples", () => { + expect(findLowestDips([], 5, 60)).toEqual([]); + }); +}); + +describe("pearson", () => { + it("is -1 for a perfectly inverse relationship and +1 for a direct one", () => { + expect(pearson([20, 30, 40, 50], [4, 3, 2, 1])).toBeCloseTo(-1); + expect(pearson([1, 2, 3], [10, 20, 30])).toBeCloseTo(1); + }); + + it("is null without enough data or without variation", () => { + expect(pearson([1, 2], [2, 1])).toBeNull(); + expect(pearson([40, 40, 40], [1, 2, 3])).toBeNull(); + expect(pearson([1, 2, 3], [5, 5, 5])).toBeNull(); + }); + + it("grades the strength by magnitude, whatever the sign", () => { + expect(correlationStrength(-0.85)).toBe("strong"); + expect(correlationStrength(0.5)).toBe("moderate"); + expect(correlationStrength(-0.25)).toBe("weak"); + expect(correlationStrength(0.05)).toBe("none"); + }); +}); + +describe("nearestIndex", () => { + it("finds the closest frame, the earlier one on a tie", () => { + const frames = [0, 10, 20, 30]; + expect(nearestIndex(frames, -5)).toBe(0); + expect(nearestIndex(frames, 14)).toBe(1); + expect(nearestIndex(frames, 15)).toBe(1); + expect(nearestIndex(frames, 16)).toBe(2); + expect(nearestIndex(frames, 99)).toBe(3); + expect(nearestIndex([], 3)).toBe(-1); + }); +}); + +describe("axis helpers", () => { + it("rounds the value axis up with headroom and ticks from zero", () => { + const { max, ticks } = niceScale(50, 5); + expect(max).toBeGreaterThanOrEqual(54); + expect(ticks[0]).toBe(0); + expect(ticks[ticks.length - 1]).toBe(max); + expect(ticks.length).toBeLessThanOrEqual(6); + }); + + it("puts time ticks on round marks", () => { + // One hour at one frame per second, room for eight labels: every ten minutes. + expect(timeTicks(3600, 1000, 8)).toEqual([0, 600, 1200, 1800, 2400, 3000, 3600]); + }); +}); diff --git a/ui/src/pages/recording-playback/components/FpsTab.module.css b/ui/src/pages/recording-playback/components/FpsTab.module.css new file mode 100644 index 000000000..1c7a88792 --- /dev/null +++ b/ui/src/pages/recording-playback/components/FpsTab.module.css @@ -0,0 +1,576 @@ +/* ── FPS tab ── + The side panel widens while this tab is open (see SidePanel.tsx), so the + layout is written for a wide panel and folds down through a container query + when the viewport is too narrow to give it one. */ + +.fpsTab { + box-sizing: border-box; + min-height: 100%; + padding: 14px; + display: flex; + flex-direction: column; + gap: 12px; + container-type: inline-size; +} + +.header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.headerMeta { + display: flex; + gap: 14px; + flex-wrap: wrap; + font-family: var(--font-mono); + font-size: var(--font-size-sm); + color: var(--text-dimmer); + letter-spacing: 0.03em; +} + +.headerHint { + color: var(--text-dim); +} + +.note { + padding: 12px 14px; + border-radius: 8px; + border: 1px dashed rgba(255, 255, 255, 0.12); + color: var(--text-muted); + font-family: var(--font-mono); + font-size: var(--font-size-md); + line-height: 1.5; +} + +/* ── Summary tiles ── */ + +.tiles { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; +} + +.tile { + padding: 11px 6px 10px; + text-align: center; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.025); + font: inherit; + color: inherit; +} + +.tilesPending { + grid-column: span 3; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-dimmest); + font-family: var(--font-mono); + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.tileValue { + font-family: var(--font-mono); + font-size: var(--font-size-4xl); + font-weight: 700; + line-height: 1; +} + +.tileLabel { + margin-top: 6px; + color: var(--text-on-accent); + font-family: var(--font-mono); + font-size: var(--font-size-xs); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.tileSub { + margin-top: 4px; + display: inline-flex; + align-items: center; + gap: 3px; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: var(--font-size-xs); + font-variant-numeric: tabular-nums; +} + +.tileButton { + cursor: pointer; + border-color: color-mix(in srgb, var(--accent-danger) 28%, transparent); + background: color-mix(in srgb, var(--accent-danger) 6%, transparent); + transition: background 0.15s, border-color 0.15s, transform 0.15s; +} + +.tileButton:hover { + background: color-mix(in srgb, var(--accent-danger) 12%, transparent); + border-color: color-mix(in srgb, var(--accent-danger) 50%, transparent); +} + +.tileButton:hover .tileSub { + color: var(--text-primary); +} + +.tileButton:active { + transform: translateY(1px); +} + +.tileButton:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 1px; +} + +.tileButtonActive { + border-color: color-mix(in srgb, var(--accent-danger) 70%, transparent); +} + +/* ── Chart card ── */ + +.chartCard { + flex: 1 0 auto; + min-height: 430px; + display: flex; + flex-direction: column; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + background: rgba(0, 0, 0, 0.22); + padding: 8px 0 2px; +} + +.chartBody { + position: relative; + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.paneHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + flex-wrap: wrap; + padding: 2px 14px 4px 12px; +} + +.paneHeader + .fpsPane, +.paneHeader + .populationPane { + margin-bottom: 4px; +} + +.paneTitle { + font-family: var(--font-mono); + font-size: var(--font-size-sm); + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-muted); +} + +.fpsPane { + position: relative; + flex: 3 1 0; + min-height: 210px; +} + +.populationPane { + position: relative; + flex: 2 1 0; + min-height: 160px; +} + +.pane { + position: absolute; + inset: 0; +} + +.paneSvg { + display: block; + cursor: crosshair; + touch-action: none; + user-select: none; + -webkit-user-select: none; +} + +.paneOverlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-dimmer); + font-family: var(--font-mono); + font-size: var(--font-size-md); + pointer-events: none; +} + +/* ── SVG marks ── */ + +.gridLine { + stroke: rgba(255, 255, 255, 0.055); + stroke-width: 1; + shape-rendering: crispEdges; +} + +.baseLine { + stroke: rgba(255, 255, 255, 0.16); + stroke-width: 1; + shape-rendering: crispEdges; +} + +.tickLabel { + fill: var(--text-dimmer); + font-family: var(--font-mono); + font-size: 10px; + font-variant-numeric: tabular-nums; +} + +.playhead { + stroke: var(--text-primary); + stroke-opacity: 0.75; + stroke-width: 1.5; + pointer-events: none; +} + +.crosshair { + stroke: var(--text-muted); + stroke-opacity: 0.6; + stroke-width: 1; + pointer-events: none; +} + +.marker { + pointer-events: none; +} + +.markerLabel { + fill: var(--text-primary); + font-family: var(--font-mono); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.03em; + paint-order: stroke; + stroke: #0b1016; + stroke-width: 3px; + stroke-linejoin: round; +} + +/* ── Keys and legend ── */ + +.zoneKeys { + display: flex; + gap: 12px; +} + +.zoneKey { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--text-dimmer); + font-family: var(--font-mono); + font-size: var(--font-size-sm); +} + +.zoneSwatch { + width: 10px; + height: 8px; + border-radius: 2px; + opacity: 0.55; +} + +.legend { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.legendChip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 9px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.03); + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: var(--font-size-sm); + cursor: pointer; + transition: background 0.15s, opacity 0.15s; +} + +.legendChip:hover { + background: rgba(255, 255, 255, 0.07); +} + +.legendChip:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 1px; +} + +.legendChipOff { + opacity: 0.42; +} + +.legendLabel { + white-space: nowrap; +} + +.legendValue { + min-width: 2ch; + text-align: right; + color: var(--text-primary); + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.lineKey { + display: inline-block; + width: 12px; + height: 2px; + border-radius: 1px; + flex-shrink: 0; +} + +/* ── Tooltip ── */ + +.tooltip { + position: absolute; + top: 30px; + z-index: 2; + pointer-events: none; + min-width: 128px; + padding: 8px 10px; + border-radius: 8px; + background: rgba(12, 17, 24, 0.94); + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45); + font-family: var(--font-mono); +} + +.tooltipFlip { + transform: translateX(-100%); +} + +.tooltipTime { + margin-bottom: 5px; + color: var(--text-muted); + font-size: var(--font-size-sm); + letter-spacing: 0.04em; + font-variant-numeric: tabular-nums; +} + +.tooltipRow { + display: flex; + align-items: center; + gap: 8px; + padding: 1px 0; +} + +.tooltipRowOff { + opacity: 0.45; +} + +.tooltipValue { + min-width: 4ch; + text-align: right; + color: var(--text-primary); + font-size: var(--font-size-base); + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.tooltipLabel { + color: var(--text-muted); + font-size: var(--font-size-sm); +} + +/* ── Correlation and lowest moments ── */ + +.insights { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 10px; +} + +.insightCard { + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 12px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + background: rgba(0, 0, 0, 0.18); + min-width: 0; +} + +.insightTitle { + margin-bottom: 6px; + font-family: var(--font-mono); + font-size: var(--font-size-sm); + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-muted); +} + +.insightNote { + margin-top: 6px; + color: var(--text-dimmer); + font-family: var(--font-mono); + font-size: var(--font-size-sm); + line-height: 1.4; +} + +.corrRow { + display: grid; + grid-template-columns: minmax(78px, 1fr) 46px 72px minmax(70px, 1.3fr); + align-items: center; + gap: 8px; + padding: 4px 0; + font-family: var(--font-mono); + font-size: var(--font-size-md); +} + +.corrName { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-secondary); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.corrValue { + text-align: right; + color: var(--text-primary); + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.corrStrength { + color: var(--text-dimmer); + font-size: var(--font-size-xs); + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.corrTrack { + position: relative; + height: 6px; + border-radius: 3px; + background: rgba(255, 255, 255, 0.06); +} + +.corrCenter { + position: absolute; + left: 50%; + top: -3px; + bottom: -3px; + width: 1px; + background: rgba(255, 255, 255, 0.28); +} + +.corrFill { + position: absolute; + top: 0; + bottom: 0; + border-radius: 3px; + opacity: 0.9; +} + +.dipRow { + display: grid; + grid-template-columns: 16px minmax(62px, 1fr) 48px 48px 58px 54px; + align-items: center; + gap: 6px; + width: 100%; + padding: 5px 6px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: var(--font-size-md); + text-align: left; + cursor: pointer; + transition: background 0.15s; +} + +button.dipRow:hover { + background: var(--bg-interactive-hover); +} + +button.dipRow:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: -2px; +} + +.dipHead { + cursor: default; + color: var(--text-dimmer); + font-size: var(--font-size-xs); + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.dipRowActive { + background: color-mix(in srgb, var(--accent-primary) 12%, transparent); +} + +.dipRank { + color: var(--text-dimmest); + text-align: right; +} + +.dipTime { + font-variant-numeric: tabular-nums; +} + +.dipNum { + text-align: right; + font-variant-numeric: tabular-nums; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dipFps { + font-weight: 700; +} + +/* ── Narrow panel ── */ + +@container (max-width: 560px) { + .tiles { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .tilesPending { + grid-column: span 2; + min-height: 56px; + } + + .headerHint { + display: none; + } + + .insights { + grid-template-columns: minmax(0, 1fr); + } + + .dipRow { + grid-template-columns: 14px minmax(56px, 1fr) 42px 42px 50px 48px; + gap: 4px; + } +} diff --git a/ui/src/pages/recording-playback/components/FpsTab.tsx b/ui/src/pages/recording-playback/components/FpsTab.tsx new file mode 100644 index 000000000..bfbdeb473 --- /dev/null +++ b/ui/src/pages/recording-playback/components/FpsTab.tsx @@ -0,0 +1,850 @@ +import { + createEffect, + createMemo, + createSignal, + createUniqueId, + For, + onCleanup, + onMount, + Show, + untrack, +} from "solid-js"; +import type { Accessor, JSX } from "solid-js"; +import { useEngine } from "../../../hooks/useEngine"; +import { useI18n } from "../../../hooks/useLocale"; +import { formatElapsedTime } from "../../../playback/time"; +import { + buildPopulationSamples, + correlationStrength, + findLowestDips, + formatFps, + fpsColor, + FPS_CRITICAL, + FPS_LOW, + nearestIndex, + niceScale, + pearson, + populationFrames, + timeTicks, +} from "../fpsDiagnostics"; +import type { PopulationKey, PopulationSample } from "../fpsDiagnostics"; +import styles from "./FpsTab.module.css"; +import sidePanelStyles from "./SidePanel.module.css"; + +// Categorical slots validated together against the panel surface (dark, adjacent +// pairs). Each entity keeps its colour whichever series are toggled off. +const FPS_SERIES_COLOR = "#3987e5"; +const POPULATION_SERIES: { key: PopulationKey; labelKey: string; color: string }[] = [ + { key: "units", labelKey: "fps_units", color: "#d95926" }, + { key: "vehicles", labelKey: "fps_vehicles", color: "#199e70" }, + { key: "players", labelKey: "fps_players", color: "#c98500" }, + { key: "ai", labelKey: "fps_ai", color: "#d55181" }, +]; +const CRITICAL_COLOR = "#ff4a4a"; +const LOW_COLOR = "#ff8a3d"; +/** Ring around markers, so a dot stays legible where it crosses a line. */ +const SURFACE_COLOR = "#0b1016"; + +/** Lowest moments closer together than this are one dip, not two. */ +const DIP_GAP_SECONDS = 60; +const DIP_COUNT = 5; + +// Both panes share these gutters, which is what keeps their time axes aligned. +const GUTTER_LEFT = 40; +const GUTTER_RIGHT = 14; +const PAD_TOP = 10; +const PAD_BOTTOM = 6; +const TIME_AXIS_HEIGHT = 22; +/** Fallback before the first measurement, and in test DOMs that never lay out. */ +const DEFAULT_SIZE = { width: 640, height: 220 }; + +interface Size { + width: number; + height: number; +} + +function createElementSize(element: Accessor): Accessor { + const [size, setSize] = createSignal(DEFAULT_SIZE); + onMount(() => { + const el = element(); + if (!el) return; + const measure = () => { + const rect = el.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) setSize({ width: rect.width, height: rect.height }); + }; + measure(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(measure); + observer.observe(el); + onCleanup(() => observer.disconnect()); + }); + return size; +} + +// ─── Chart pane ─── + +interface PanePoint { + frame: number; + value: number; +} + +interface PaneSeries { + key: string; + color: string; + points: PanePoint[]; + area?: boolean; +} + +interface ChartPaneProps { + label: string; + endFrame: number; + series: PaneSeries[]; + yMax: number; + yTicks: number[]; + xTicks: number[]; + showTimeAxis: boolean; + formatTime: (frame: number) => string; + bands?: { from: number; to: number; color: string }[]; + marker?: { frame: number; value: number; label: string } | null; + hoverFrame: number | null; + playheadFrame: number; + snap: (frame: number) => number; + onHover: (frame: number | null, x: number) => void; + onSeek: (frame: number) => void; +} + +function ChartPane(props: ChartPaneProps): JSX.Element { + let container: HTMLDivElement | undefined; + const size = createElementSize(() => container); + const gradientId = createUniqueId(); + + const plotLeft = GUTTER_LEFT; + const plotRight = () => Math.max(plotLeft + 10, size().width - GUTTER_RIGHT); + const plotTop = PAD_TOP; + const plotBottom = () => + Math.max(plotTop + 10, size().height - (props.showTimeAxis ? TIME_AXIS_HEIGHT : PAD_BOTTOM)); + + const x = (frame: number) => + plotLeft + (frame / Math.max(1, props.endFrame)) * (plotRight() - plotLeft); + const y = (value: number) => + plotBottom() - (Math.min(value, props.yMax) / Math.max(1e-9, props.yMax)) * (plotBottom() - plotTop); + + const linePath = (points: PanePoint[]) => { + let path = ""; + for (let i = 0; i < points.length; i++) { + path += `${i === 0 ? "M" : "L"}${x(points[i].frame).toFixed(1)},${y(points[i].value).toFixed(1)}`; + } + return path; + }; + + const areaPath = (points: PanePoint[]) => { + if (points.length < 2) return ""; + const base = plotBottom().toFixed(1); + return `${linePath(points)}L${x(points[points.length - 1].frame).toFixed(1)},${base}L${x(points[0].frame).toFixed(1)},${base}Z`; + }; + + const seriesFrames = createMemo(() => + props.series.map((series) => series.points.map((point) => point.frame)), + ); + + const hoverDots = createMemo(() => { + const frame = props.hoverFrame; + if (frame === null) return []; + const frames = seriesFrames(); + return props.series.flatMap((series, i) => { + const index = nearestIndex(frames[i], frame); + if (index < 0) return []; + const point = series.points[index]; + return [{ key: series.key, color: series.color, cx: x(point.frame), cy: y(point.value) }]; + }); + }); + + const frameAtPointer = (event: PointerEvent): number => { + const rect = (event.currentTarget as Element).getBoundingClientRect(); + const px = event.clientX - rect.left; + const ratio = (px - plotLeft) / Math.max(1, plotRight() - plotLeft); + const raw = Math.max(0, Math.min(1, ratio)) * props.endFrame; + return props.snap(raw); + }; + + let dragging = false; + + const handleDown = (event: PointerEvent) => { + if (event.button !== 0) return; + dragging = true; + (event.currentTarget as Element).setPointerCapture?.(event.pointerId); + const frame = frameAtPointer(event); + props.onHover(frame, x(frame)); + props.onSeek(frame); + }; + + const handleMove = (event: PointerEvent) => { + const frame = frameAtPointer(event); + props.onHover(frame, x(frame)); + if (dragging) props.onSeek(frame); + }; + + const handleUp = (event: PointerEvent) => { + dragging = false; + (event.currentTarget as Element).releasePointerCapture?.(event.pointerId); + }; + + const markerLabel = createMemo(() => { + const marker = props.marker; + if (!marker) return null; + const cx = x(marker.frame); + const cy = y(marker.value); + const above = cy - 14 > plotTop + 4; + let anchor: "start" | "middle" | "end" = "middle"; + if (cx < plotLeft + 36) anchor = "start"; + else if (cx > plotRight() - 36) anchor = "end"; + return { cx, cy, ty: above ? cy - 10 : cy + 18, anchor, text: marker.label }; + }); + + return ( +
+ { + if (!dragging) props.onHover(null, 0); + }} + > + + + + + + + + + {(band) => ( + <> + + + + )} + + + + {(frame) => ( + + )} + + + {(value) => ( + <> + + + {value} + + + )} + + + + {(frame) => { + const anchor = () => { + if (x(frame) - plotLeft < 24) return "start"; + if (plotRight() - x(frame) < 24) return "end"; + return "middle"; + }; + return ( + + {props.formatTime(frame)} + + ); + }} + + + + + {(series) => ( + <> + + + + 1} + fallback={ + + {(point) => ( + + )} + + } + > + (plotRight() - plotLeft) * 0.75 ? 1.25 : 2} + stroke-linejoin="round" + stroke-linecap="round" + /> + + + )} + + + + + + {(marker) => ( + + + + {marker().text} + + + )} + + + + + + {(dot) => ( + + )} + + + +
+ ); +} + +// ─── Tab ─── + +export function FpsTab(): JSX.Element { + const engine = useEngine(); + const { t } = useI18n(); + + const captureDelayMs = () => engine.captureDelayMs() || 1000; + const endFrame = () => engine.endFrame(); + const formatTime = (frame: number) => formatElapsedTime(frame, captureDelayMs()); + + // Events are in place before the end frame is set, so reading it here is + // what re-runs this when a recording loads. + const fpsSamples = createMemo(() => { + endFrame(); + return engine.eventManager.getServerFpsSamples(); + }); + const fpsFrames = createMemo(() => fpsSamples().map((sample) => sample.frame)); + const hasFps = () => fpsSamples().length > 0; + + const stats = createMemo(() => engine.eventManager.getServerFpsStats(engine.currentFrame())); + + const dips = createMemo(() => + findLowestDips( + fpsSamples(), + DIP_COUNT, + Math.max(1, Math.round((DIP_GAP_SECONDS * 1000) / captureDelayMs())), + ), + ); + const minimum = () => dips()[0]; + + const sampleIntervalSeconds = createMemo(() => { + const frames = fpsFrames(); + if (frames.length < 2) return null; + const gaps = frames.slice(1).map((frame, i) => frame - frames[i]).sort((a, b) => a - b); + const median = gaps[Math.floor(gaps.length / 2)]; + return Math.max(1, Math.round((median * captureDelayMs()) / 1000)); + }); + + // ─── Population, counted once per recording ─── + + const [population, setPopulation] = createSignal(null); + const [populationFailed, setPopulationFailed] = createSignal(false); + + createEffect(() => { + const frames = populationFrames(fpsSamples(), endFrame(), captureDelayMs()); + setPopulation(null); + setPopulationFailed(false); + let cancelled = false; + onCleanup(() => { + cancelled = true; + }); + untrack(() => buildPopulationSamples(engine, frames)).then( + (samples) => { + if (!cancelled) setPopulation(samples); + }, + (error: unknown) => { + if (cancelled) return; + console.error("Failed to count units for the FPS graph", error); + setPopulationFailed(true); + }, + ); + }); + + const populationFramesList = createMemo(() => population()?.map((sample) => sample.frame) ?? []); + const populationAt = (frame: number): PopulationSample | undefined => { + const samples = population(); + if (!samples) return undefined; + const index = nearestIndex(populationFramesList(), frame); + return index < 0 ? undefined : samples[index]; + }; + const populationByFrame = createMemo( + () => new Map((population() ?? []).map((sample) => [sample.frame, sample])), + ); + + const [hidden, setHidden] = createSignal>(new Set(["ai"])); + const toggleSeries = (key: PopulationKey) => { + const next = new Set(hidden()); + if (next.has(key)) next.delete(key); + else next.add(key); + setHidden(next); + }; + + // ─── Shared hover and seek ─── + + const [hoverFrame, setHoverFrame] = createSignal(null); + const [hoverX, setHoverX] = createSignal(0); + let chartBody: HTMLDivElement | undefined; + const bodySize = createElementSize(() => chartBody); + + const snap = (frame: number): number => { + const frames = hasFps() ? fpsFrames() : populationFramesList(); + const index = nearestIndex(frames, frame); + return index < 0 ? Math.round(frame) : frames[index]; + }; + const onHover = (frame: number | null, x: number) => { + setHoverFrame(frame); + setHoverX(x); + }; + const seek = (frame: number) => engine.seekTo(frame); + + const fpsAt = (frame: number): number | undefined => { + const index = nearestIndex(fpsFrames(), frame); + return index < 0 ? undefined : fpsSamples()[index].fps; + }; + + /** Readout frame for the legend values: the hovered sample, else the playhead. */ + const readoutFrame = () => hoverFrame() ?? engine.currentFrame(); + + // ─── Scales ─── + + const xTicks = createMemo(() => + timeTicks(endFrame(), captureDelayMs(), Math.floor(bodySize().width / 92)), + ); + + const fpsScale = createMemo(() => { + const max = fpsSamples().reduce((top, sample) => Math.max(top, sample.fps), 0); + return niceScale(Math.max(max, FPS_LOW + 5), 5); + }); + + const fpsSeries = createMemo((): PaneSeries[] => [ + { + key: "fps", + color: FPS_SERIES_COLOR, + area: true, + points: fpsSamples().map((sample) => ({ frame: sample.frame, value: sample.fps })), + }, + ]); + + const populationSeries = createMemo((): PaneSeries[] => { + const samples = population() ?? []; + return POPULATION_SERIES.filter((series) => !hidden().has(series.key)).map((series) => ({ + key: series.key, + color: series.color, + points: samples.map((sample) => ({ frame: sample.frame, value: sample[series.key] })), + })); + }); + + const populationScale = createMemo(() => { + let max = 0; + for (const series of populationSeries()) { + for (const point of series.points) max = Math.max(max, point.value); + } + return niceScale(Math.max(max, 4), 4); + }); + + // ─── Correlation ─── + + const correlations = createMemo(() => { + const byFrame = populationByFrame(); + if (!population() || !hasFps()) return null; + const paired = fpsSamples().filter((sample) => byFrame.has(sample.frame)); + const fpsValues = paired.map((sample) => sample.fps); + return { + count: paired.length, + rows: POPULATION_SERIES.map((series) => ({ + ...series, + r: pearson(fpsValues, paired.map((sample) => byFrame.get(sample.frame)![series.key])), + })), + }; + }); + + const formatR = (r: number) => `${r < 0 ? "−" : r > 0 ? "+" : ""}${Math.abs(r).toFixed(2)}`; + + // ─── Tooltip ─── + + const tooltip = createMemo(() => { + const frame = hoverFrame(); + if (frame === null) return null; + const flip = hoverX() > bodySize().width * 0.6; + return { + frame, + left: flip ? hoverX() - 12 : hoverX() + 12, + flip, + fps: fpsAt(frame), + population: populationAt(frame), + }; + }); + + return ( +
+
+
+
+ {t("server_fps")} +
+
+ + + {t("fps_samples", { count: fpsSamples().length })} + + {(seconds) => <> · {t("fps_every", { seconds: seconds() })}} + + + {t("fps_click_hint")} + +
+
+ + {t("fps_no_samples")}
} + > +
+ —
}> + {(fps) => ( + <> +
+
+ {formatFps(fps.current)} +
+
{t("current")}
+
+
+
+ {formatFps(fps.average)} +
+
{t("average")}
+
+
+
+ {formatFps(fps.median)} +
+
{t("median")}
+
+ + )} + + + {(low) => ( + + )} + +
+ + +
+
onHover(null, 0)}> + +
+ {t("server_fps")} + + + + {t("fps_zone_low")} + + + + {t("fps_zone_critical")} + + +
+
+ +
+
+ +
+ {t("fps_population")} + + + {(series) => ( + + )} + + +
+
+ + +
+ {populationFailed() ? t("fps_counts_failed") : t("fps_counting")} +
+
+
+ + + {(tip) => ( +
+
{formatTime(tip().frame)}
+ +
+ + {formatFps(tip().fps!)} + FPS +
+
+ + {(series) => ( +
+ + + {tip().population?.[series.key] ?? "—"} + + {t(series.labelKey)} +
+ )} +
+
+ )} +
+
+
+ + +
+
+
{t("fps_correlation")}
+ + {populationFailed() ? t("fps_counts_failed") : t("fps_counting")} +
+ } + > + {(corr) => ( + <> + + {(row) => ( +
+ + + {t(row.labelKey)} + + {row.r === null ? "—" : formatR(row.r)} + + {row.r === null ? "" : t(`fps_corr_${correlationStrength(row.r)}`)} + + + + + + + +
+ )} +
+
+ {t("fps_correlation_hint")} · n = {corr().count} +
+ + )} + +
+ +
+
{t("fps_lowest")}
+
+ # + {t("fps_time")} + FPS + {t("fps_units")} + {t("fps_vehicles")} + {t("fps_players")} +
+ + {(dip, i) => { + const counts = () => populationByFrame().get(dip.frame); + return ( + + ); + }} + +
+
+
+
+
+ ); +} diff --git a/ui/src/pages/recording-playback/components/PlayerProfileCard.module.css b/ui/src/pages/recording-playback/components/PlayerProfileCard.module.css index 49efd6be1..e56ed4c98 100644 --- a/ui/src/pages/recording-playback/components/PlayerProfileCard.module.css +++ b/ui/src/pages/recording-playback/components/PlayerProfileCard.module.css @@ -1,9 +1,9 @@ .card { position: fixed; top: 56px; - left: calc(var(--pb-panel-width, 370px) + 18px); + left: calc(var(--pb-panel-width, 420px) + 18px); bottom: calc(var(--pb-bottom-height, 90px) + 8px); - width: min(640px, calc(100vw - var(--pb-panel-width, 370px) - 36px)); + width: min(640px, calc(100vw - var(--pb-panel-width, 420px) - 36px)); z-index: 40; display: flex; flex-direction: column; diff --git a/ui/src/pages/recording-playback/components/SidePanel.module.css b/ui/src/pages/recording-playback/components/SidePanel.module.css index e4adb6479..9dc48bbad 100644 --- a/ui/src/pages/recording-playback/components/SidePanel.module.css +++ b/ui/src/pages/recording-playback/components/SidePanel.module.css @@ -16,8 +16,9 @@ top: 56px; left: 8px; bottom: calc(var(--pb-bottom-height, 90px) + 8px); - transition: bottom 0.15s ease; - width: var(--pb-panel-width, 370px); + /* Width moves when the FPS tab widens the panel. */ + transition: bottom 0.15s ease, width 0.2s ease; + width: var(--pb-panel-width, 420px); display: flex; flex-direction: column; background: var(--bg-panel); @@ -38,9 +39,11 @@ border-bottom: var(--border-subtle); padding: 0 4px; flex-shrink: 0; - /* The panel is a fixed 370px and the row has to hold every tab inside it. - Without this the row simply overflows and the last tab is clipped at the - panel's rounded edge rather than the row adapting to fit. */ + /* The panel is 420px (wider only on the FPS tab) and the row has to hold + every tab inside it. 420px fits every English label in full; a longer + translation can still run out of room, and without this the row would + overflow and clip the last tab at the panel's rounded edge rather than + adapting to fit. */ overflow: hidden; } @@ -49,10 +52,11 @@ align-items: center; justify-content: center; gap: 5px; - /* Equal shares of the panel, and allowed to shrink below their content width. - `min-width: 0` is the part that actually lets the label ellipsis instead of - forcing the row wider than its container. */ - flex: 1 1 0; + /* Each tab starts at its own label's width and the spare room is shared + out, so a short label such as FPS does not hold space a longer one needs. + When a translation does not fit, `min-width: 0` lets the label ellipsis + instead of forcing the row wider than its container. */ + flex: 1 1 auto; min-width: 0; padding: 8px 6px; border: none; @@ -609,42 +613,13 @@ text-transform: uppercase; } -.serverFpsCard { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 4px; - margin-top: 8px; - padding: 8px; - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 8px; -} .serverFpsTitle { color: var(--text-on-accent); } -.serverFpsMetric { - padding: 7px 3px; - text-align: center; - border-radius: 5px; -} -.serverFpsValue { - font-family: var(--font-mono); - font-size: var(--font-size-xl); - font-weight: 700; - line-height: 1; -} -.serverFpsLabel { - margin-top: 4px; - color: var(--text-on-accent); - font-family: var(--font-mono); - font-size: var(--font-size-xs); - font-weight: 600; - letter-spacing: 0.06em; - text-transform: uppercase; -} .weatherHeader { display: flex; diff --git a/ui/src/pages/recording-playback/components/SidePanel.tsx b/ui/src/pages/recording-playback/components/SidePanel.tsx index 79ad33651..0a6900619 100644 --- a/ui/src/pages/recording-playback/components/SidePanel.tsx +++ b/ui/src/pages/recording-playback/components/SidePanel.tsx @@ -1,14 +1,23 @@ -import { Switch, Match, For } from "solid-js"; +import { Switch, Match, For, createEffect, onCleanup } from "solid-js"; import type { Accessor, JSX } from "solid-js"; -import { UsersIcon, ActivityIcon, BarChartIcon, RadioIcon, SitemapIcon } from "../../../components/Icons"; +import { UsersIcon, ActivityIcon, BarChartIcon, RadioIcon, SitemapIcon, GaugeIcon } from "../../../components/Icons"; import { useI18n } from "../../../hooks/useLocale"; import { UnitsTab } from "./UnitsTab"; import { EventsTab } from "./EventsTab"; import { StatsTab } from "./StatsTab"; +import { FpsTab } from "./FpsTab"; import { CommsTab } from "./CommsTab"; import { OrbatTab } from "./OrbatTab"; import styles from "./SidePanel.module.css"; +/** + * Panel width while the FPS tab is open. The graph is the point of that tab, + * so it takes most of the screen, while leaving a strip of map beside it. + * Set on the root so everything laid out against the panel (map controls, the + * profile card) moves with it. + */ +const FPS_PANEL_WIDTH = "clamp(420px, 62vw, 1120px)"; + export interface SidePanelProps { activeTab: Accessor; onTabChange: (tab: string) => void; @@ -27,8 +36,16 @@ export function SidePanel(props: SidePanelProps): JSX.Element { { id: "orbat" as const, labelKey: "orbat", Icon: SitemapIcon }, { id: "comms" as const, labelKey: "comms", Icon: RadioIcon }, { id: "stats" as const, labelKey: "stats", Icon: BarChartIcon }, + { id: "fps" as const, labelKey: "fps", Icon: GaugeIcon }, ]; + createEffect(() => { + const root = document.documentElement.style; + if (props.activeTab() === "fps") root.setProperty("--pb-panel-width", FPS_PANEL_WIDTH); + else root.removeProperty("--pb-panel-width"); + }); + onCleanup(() => document.documentElement.style.removeProperty("--pb-panel-width")); + return (
@@ -38,7 +55,7 @@ export function SidePanel(props: SidePanelProps): JSX.Element { class={styles.tab} classList={{ [styles.tabActive]: props.activeTab() === tab.id }} onClick={() => props.onTabChange(tab.id)} - // Five tabs share a fixed-width panel, so an inactive label can be + // Six tabs share a fixed-width panel, so an inactive label can be // truncated. The tooltip is where the full name stays reachable. title={t(tab.labelKey)} > @@ -70,6 +87,9 @@ export function SidePanel(props: SidePanelProps): JSX.Element { + + +
diff --git a/ui/src/pages/recording-playback/components/StatsTab.tsx b/ui/src/pages/recording-playback/components/StatsTab.tsx index 22e4a4cd2..271eef0e3 100644 --- a/ui/src/pages/recording-playback/components/StatsTab.tsx +++ b/ui/src/pages/recording-playback/components/StatsTab.tsx @@ -50,10 +50,6 @@ export function StatsTab(): JSX.Element { const { t } = useI18n(); const showPlayerKillCount = (): boolean => !customize().disableKillCount; - const serverFps = createMemo(() => - engine.eventManager.getServerFpsStats(engine.currentFrame()), - ); - const weather = createMemo(() => engine.eventManager.getWeatherAt(engine.currentFrame()), ); @@ -124,14 +120,6 @@ export function StatsTab(): JSX.Element { return buildMovementStats(engine); }); - const formatFps = (value: number): string => value.toFixed(1); - const fpsColor = (value: number): string => { - if (value >= 60) return "var(--accent-success)"; - if (value >= 30) return "var(--accent-warning)"; - if (value >= 20) return "#ff8a3d"; - return "var(--accent-danger)"; - }; - // Frame-aware kill/death counts const killDeathCounts = createMemo(() => engine.eventManager.getKillDeathCounts(engine.currentFrame()), @@ -183,34 +171,6 @@ export function StatsTab(): JSX.Element { return (
- - {(fps) => ( -
-
{t("server_fps")}
-
-
-
- {formatFps(fps.current)} -
-
{t("current")}
-
-
-
- {formatFps(fps.average)} -
-
{t("average")}
-
-
-
- {formatFps(fps.median)} -
-
{t("median")}
-
-
-
- )} -
- {/* Force summary */}
{t("force_summary")}
diff --git a/ui/src/pages/recording-playback/deepLink.ts b/ui/src/pages/recording-playback/deepLink.ts index a3c6f91d1..1acd9b462 100644 --- a/ui/src/pages/recording-playback/deepLink.ts +++ b/ui/src/pages/recording-playback/deepLink.ts @@ -14,7 +14,7 @@ import type { Side } from "../../data/types"; /** Panel tabs a link may select. Mirrors the tab ids in . */ -export const DEEP_LINK_TABS = ["units", "events", "orbat", "comms", "stats"] as const; +export const DEEP_LINK_TABS = ["units", "events", "orbat", "comms", "stats", "fps"] as const; export type DeepLinkTab = (typeof DEEP_LINK_TABS)[number]; diff --git a/ui/src/pages/recording-playback/fpsDiagnostics.ts b/ui/src/pages/recording-playback/fpsDiagnostics.ts new file mode 100644 index 000000000..0b5dc8d55 --- /dev/null +++ b/ui/src/pages/recording-playback/fpsDiagnostics.ts @@ -0,0 +1,217 @@ +import type { PlaybackEngine } from "../../playback/engine"; +import { Unit } from "../../playback/entities/unit"; + +/** + * Server FPS diagnostics: every FPS sample beside how much the server was + * simulating at that moment, so a dip can be read against load. + * + * Counts are taken at the FPS sample frames themselves, which is what lets a + * dip and the population at that dip be compared one to one (the tooltip, the + * lowest-moments list and the correlation all rely on that pairing). A + * recording without FPS samples still gets counts, on a regular grid. + */ + +export interface FpsSample { + frame: number; + fps: number; +} + +export interface PopulationSample { + frame: number; + /** Men that are alive or unconscious, players and AI together. */ + units: number; + players: number; + ai: number; + /** Vehicles that are not destroyed, static weapons included. */ + vehicles: number; +} + +export type PopulationKey = "units" | "vehicles" | "players" | "ai"; + +/** The tile and chart thresholds. The bands below 30 are the ones worth a look. */ +export const FPS_CRITICAL = 20; +export const FPS_LOW = 30; +export const FPS_GOOD = 60; + +export function fpsColor(value: number): string { + if (value >= FPS_GOOD) return "var(--accent-success)"; + if (value >= FPS_LOW) return "var(--accent-warning)"; + if (value >= FPS_CRITICAL) return "#ff8a3d"; + return "var(--accent-danger)"; +} + +export function formatFps(value: number): string { + return value.toFixed(1); +} + +/** Seconds between grid points when there are no FPS samples to count at. */ +const GRID_SECONDS = 10; +/** Upper bound on grid points, so a very long recording stays cheap to count. */ +const MAX_GRID_POINTS = 1500; + +/** + * Frames to count the population at: the FPS sample frames when there are + * enough of them to draw a line, otherwise a regular grid across the mission. + */ +export function populationFrames( + fps: readonly FpsSample[], + endFrame: number, + captureDelayMs: number, +): number[] { + if (fps.length >= 2) return [...new Set(fps.map((sample) => sample.frame))]; + if (endFrame <= 0) return [0]; + + const perGrid = Math.max(1, Math.round((GRID_SECONDS * 1000) / (captureDelayMs || 1000))); + const step = Math.max(perGrid, Math.ceil(endFrame / MAX_GRID_POINTS)); + const frames: number[] = []; + for (let frame = 0; frame < endFrame; frame += step) frames.push(frame); + frames.push(endFrame); + return frames; +} + +/** + * Count alive units, players, AI and vehicles at each requested frame. + * + * Zeus is a camera, not a simulated unit, and is left out. A destroyed vehicle + * or a dead body is left out too: the point is what the server is actively + * simulating, and an unconscious man still is. + */ +export async function buildPopulationSamples( + engine: PlaybackEngine, + frames: readonly number[], +): Promise { + const sorted = [...new Set(frames)].sort((a, b) => a - b); + const counted = new Map(); + const entities = engine.entityManager.getAll().filter((entity) => entity.type !== "zeus"); + + let cursor = 0; + await engine.visitAllFrames((firstFrame, lastFrame, stateAt) => { + while (cursor < sorted.length && sorted[cursor] < firstFrame) cursor++; + for (; cursor < sorted.length && sorted[cursor] <= lastFrame; cursor++) { + const frame = sorted[cursor]; + const sample: PopulationSample = { frame, units: 0, players: 0, ai: 0, vehicles: 0 }; + for (const entity of entities) { + if (frame < entity.startFrame || frame > entity.endFrame) continue; + const state = stateAt(entity.id, frame); + if (!state || state.alive === 0) continue; + if (entity instanceof Unit) { + sample.units += 1; + if (state.isPlayer === true) sample.players += 1; + else sample.ai += 1; + } else { + sample.vehicles += 1; + } + } + counted.set(frame, sample); + } + }); + + return sorted.map( + (frame) => counted.get(frame) ?? { frame, units: 0, players: 0, ai: 0, vehicles: 0 }, + ); +} + +/** + * The lowest samples, at most `count`, no two closer than `minGapFrames`. + * + * One bad minute is a run of low samples; without the gap the list would be + * five neighbours of the same dip. The first entry is the recording's minimum, + * the earliest one on a tie. + */ +export function findLowestDips( + samples: readonly FpsSample[], + count: number, + minGapFrames: number, +): FpsSample[] { + const ranked = [...samples].sort((a, b) => a.fps - b.fps || a.frame - b.frame); + const picked: FpsSample[] = []; + for (const sample of ranked) { + if (picked.length >= count) break; + if (picked.some((other) => Math.abs(other.frame - sample.frame) < minGapFrames)) continue; + picked.push(sample); + } + return picked; +} + +/** Pearson correlation, or null when there is too little data or no variation. */ +export function pearson(xs: readonly number[], ys: readonly number[]): number | null { + const n = Math.min(xs.length, ys.length); + if (n < 3) return null; + + let meanX = 0; + let meanY = 0; + for (let i = 0; i < n; i++) { + meanX += xs[i]; + meanY += ys[i]; + } + meanX /= n; + meanY /= n; + + let covariance = 0; + let varianceX = 0; + let varianceY = 0; + for (let i = 0; i < n; i++) { + const dx = xs[i] - meanX; + const dy = ys[i] - meanY; + covariance += dx * dy; + varianceX += dx * dx; + varianceY += dy * dy; + } + if (varianceX === 0 || varianceY === 0) return null; + return covariance / Math.sqrt(varianceX * varianceY); +} + +export type CorrelationStrength = "none" | "weak" | "moderate" | "strong"; + +export function correlationStrength(r: number): CorrelationStrength { + const magnitude = Math.abs(r); + if (magnitude >= 0.7) return "strong"; + if (magnitude >= 0.4) return "moderate"; + if (magnitude >= 0.2) return "weak"; + return "none"; +} + +/** Index of the frame closest to `frame` in an ascending list, or -1 when empty. */ +export function nearestIndex(frames: readonly number[], frame: number): number { + if (frames.length === 0) return -1; + let low = 0; + let high = frames.length - 1; + while (low < high) { + const mid = (low + high) >> 1; + if (frames[mid] < frame) low = mid + 1; + else high = mid; + } + if (low > 0 && frame - frames[low - 1] <= frames[low] - frame) return low - 1; + return low; +} + +/** + * A rounded axis top and its ticks: steps of 1, 2 or 5 times a power of ten, + * with a little headroom so a line never runs along the top edge. + */ +export function niceScale(maxValue: number, targetTicks = 5): { max: number; ticks: number[] } { + const top = Math.max(1, maxValue * 1.08); + const rough = top / Math.max(1, targetTicks); + const magnitude = 10 ** Math.floor(Math.log10(rough)); + const step = [1, 2, 5, 10].map((m) => m * magnitude).find((s) => s >= rough) ?? 10 * magnitude; + const max = Math.ceil(top / step) * step; + const ticks: number[] = []; + for (let value = 0; value <= max + step / 2; value += step) ticks.push(value); + return { max, ticks }; +} + +const TIME_TICK_SECONDS = [10, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200, 10800, 21600]; + +/** Frames for time-axis ticks on round elapsed-time marks, at most `maxTicks` of them. */ +export function timeTicks(endFrame: number, captureDelayMs: number, maxTicks: number): number[] { + if (endFrame <= 0) return [0]; + const msPerFrame = captureDelayMs || 1000; + const limit = Math.max(2, maxTicks); + const seconds = + TIME_TICK_SECONDS.find((s) => (endFrame * msPerFrame) / (s * 1000) <= limit) ?? + TIME_TICK_SECONDS[TIME_TICK_SECONDS.length - 1]; + const stepFrames = (seconds * 1000) / msPerFrame; + const ticks: number[] = []; + for (let frame = 0; frame <= endFrame; frame += stepFrames) ticks.push(Math.round(frame)); + return ticks; +} diff --git a/ui/src/playback/__tests__/serverFpsStats.test.ts b/ui/src/playback/__tests__/serverFpsStats.test.ts index b65b7ee8f..1ecacdfce 100644 --- a/ui/src/playback/__tests__/serverFpsStats.test.ts +++ b/ui/src/playback/__tests__/serverFpsStats.test.ts @@ -81,6 +81,30 @@ describe("server FPS samples", () => { expect(mgr.getServerFpsStats(30)!.current).toBe(50); }); + // The recorder samples before its first frame and again at frame 0. Older + // builds stamp the early sample -1, newer ones 0; both are the start. + it("keeps one start sample whether the early one is stamped -1 or 0", () => { + for (const early of [-1, 0]) { + mgr.clear(); + addSamples([ + [early, 26], + [0, 27], + [10, 80], + ]); + + expect(mgr.getServerFpsSamples()).toEqual([ + { frame: 0, fps: 26 }, + { frame: 10, fps: 80 }, + ]); + expect(mgr.getServerFpsStats(10)).toEqual({ current: 80, average: 53, median: 53 }); + } + }); + + it("keeps a lone pre-start sample, at frame 0", () => { + addSamples([[-1, 40]]); + expect(mgr.getServerFpsStats(0)).toEqual({ current: 40, average: 40, median: 40 }); + }); + it("drops the samples on clear", () => { addSamples([[0, 40]]); mgr.clear(); diff --git a/ui/src/playback/engine.ts b/ui/src/playback/engine.ts index 4693120bf..0f966267d 100644 --- a/ui/src/playback/engine.ts +++ b/ui/src/playback/engine.ts @@ -549,6 +549,51 @@ export class PlaybackEngine { return Promise.all(loads).then(() => {}); } + /** + * Walk the whole recording once, handing `visit` each stretch of frames + * together with a state reader that is valid for that stretch. + * + * The chunk cache keeps only a few chunks, so loading them all and then + * reading through getStateAt sees just the last ones. A reader that must see + * every frame comes through here instead: each chunk is read while this call + * still holds it, one chunk at a time, in frame order. A JSON recording is a + * single pass over the dense position arrays. + */ + async visitAllFrames( + visit: ( + firstFrame: number, + lastFrame: number, + stateAt: (entityId: number, frame: number) => EntityState | null, + ) => void, + ): Promise { + const endFrame = this._endFrame(); + const chunkManager = this.chunkManager; + const count = this.manifest?.chunkCount ?? 0; + if (!chunkManager || count <= 0) { + visit(0, endFrame, (entityId, frame) => this.getStateAt(entityId, frame)); + return; + } + + const chunkSize = this.manifest?.chunkSize || 300; + for (let index = 0; index < count; index++) { + const firstFrame = index * chunkSize; + if (firstFrame > endFrame) break; + const chunk = await chunkManager.loadChunk(index); + // A different recording was loaded while this chunk was in flight. + if (this.chunkManager !== chunkManager) return; + const lastFrame = Math.min(firstFrame + chunkSize - 1, endFrame); + visit(firstFrame, lastFrame, (entityId, frame) => { + const entity = this.entityManager.getEntity(entityId); + if (!entity || frame < entity.startFrame || frame > entity.endFrame) return null; + return ( + chunk.entities.get(entityId)?.[frame - firstFrame] ?? + entity.positions?.[entity.getRelativeFrameIndex(frame)] ?? + null + ); + }); + } + } + /** Whether a player has snapshots at all, before any sidecar is fetched. */ hasPlayerSnapshots(unitId: number): boolean { return ( diff --git a/ui/src/playback/eventManager.ts b/ui/src/playback/eventManager.ts index cf8c2697b..627fa7c12 100644 --- a/ui/src/playback/eventManager.ts +++ b/ui/src/playback/eventManager.ts @@ -28,6 +28,7 @@ export class EventManager { private frameIndex: Map = new Map(); private playerSnapshots = new Map>(); private serverFpsEvents: ServerFpsEvent[] = []; + private serverFpsFrames = new Set(); private weatherEvents: WeatherEvent[] = []; private zeusEntities = new Map(); private zeusRemoteControl: ZeusRemoteControlEvent[] = []; @@ -59,7 +60,14 @@ export class EventManager { return; } if (event instanceof ServerFpsEvent) { - this.serverFpsEvents.push(event); + // The recorder samples once before its first frame and again at frame 0. + // Older builds stamp the early one -1, newer ones clamp it to 0; either + // way it is the start of the recording, and one sample per frame is kept + // so the start does not count twice in the average and median. + const sample = event.frameNum < 0 ? new ServerFpsEvent(0, event.id, event.fps) : event; + if (this.serverFpsFrames.has(sample.frameNum)) return; + this.serverFpsFrames.add(sample.frameNum); + this.serverFpsEvents.push(sample); this.serverFpsEvents.sort((a, b) => a.frameNum - b.frameNum); return; } @@ -226,6 +234,11 @@ export class EventManager { return { current: samples[samples.length - 1].fps, average, median }; } + /** Every server FPS sample in the recording, in frame order. */ + getServerFpsSamples(): { frame: number; fps: number }[] { + return this.serverFpsEvents.map((event) => ({ frame: event.frameNum, fps: event.fps })); + } + /** Latest weather sample at or before the requested playback frame. */ getWeatherAt(frame: number): WeatherPayload | undefined { for (let i = this.weatherEvents.length - 1; i >= 0; i--) { @@ -528,6 +541,7 @@ export class EventManager { this.frameIndex = new Map(); this.playerSnapshots = new Map(); this.serverFpsEvents = []; + this.serverFpsFrames.clear(); this.weatherEvents = []; this.zeusEntities = new Map(); this.zeusRemoteControl = []; diff --git a/ui/src/styles/variables.css b/ui/src/styles/variables.css index ca43e09c9..b3180f5a7 100644 --- a/ui/src/styles/variables.css +++ b/ui/src/styles/variables.css @@ -82,5 +82,5 @@ /* ── Playback layout ── */ --pb-top-height: 48px; --pb-bottom-height: 94px; - --pb-panel-width: 370px; + --pb-panel-width: 420px; } From 3b332270af64e37f1cd73619d72e663356e469d2 Mon Sep 17 00:00:00 2001 From: Yethe Samartaka Date: Sat, 17 Oct 2026 18:27:58 +0200 Subject: [PATCH 27/39] Fix Go CI frontend build and positional explosion parsing --- .github/workflows/go.yml | 14 +++++++++ internal/storage/parser_test.go | 4 +++ internal/storage/parser_v1.go | 9 +++++- internal/storage/player_snapshot_test.go | 37 ++++++++++++------------ 4 files changed, 45 insertions(+), 19 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index b88bae243..de2ad86c2 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -35,6 +35,20 @@ jobs: key: ${{ runner.os }}-gomod-${{ hashFiles('**/go.sum') }} restore-keys: ${{ runner.os }}-gomod- + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + cache-dependency-path: ui/package-lock.json + + - name: Install frontend dependencies + run: npm ci + working-directory: ui + + - name: Build embedded frontend + run: npm run build + working-directory: ui + - name: Install maptool pipeline tools run: | sudo apt-get update -qq diff --git a/internal/storage/parser_test.go b/internal/storage/parser_test.go index d87289153..6e6967dd0 100644 --- a/internal/storage/parser_test.go +++ b/internal/storage/parser_test.go @@ -715,7 +715,11 @@ func TestParserV1_parseEvent_EdgeCases(t *testing.T) { 50.5, // distance as float }) require.NotNil(t, evt) + assert.Equal(t, "explosion", evt.Type) + assert.Equal(t, uint32(5), evt.SourceID) + assert.Equal(t, uint32(10), evt.TargetID) assert.Equal(t, float32(50.5), evt.Distance) + assert.Empty(t, evt.Message) }) t.Run("event with only source (no target)", func(t *testing.T) { diff --git a/internal/storage/parser_v1.go b/internal/storage/parser_v1.go index 31a6d7fc8..3b5ae8910 100644 --- a/internal/storage/parser_v1.go +++ b/internal/storage/parser_v1.go @@ -167,6 +167,13 @@ func parseEventArray(evtArr []interface{}) *Event { return event } + // Positional explosion events use the generic fields below; only object + // payloads carry the additive blast details in the message field. + isStructuredExplosion := false + if event.Type == "explosion" && len(evtArr) > 2 { + _, isStructuredExplosion = evtArr[2].(map[string]interface{}) + } + // Additive player-detail, weather, and server FPS events keep their structured payload as JSON in // the generic protobuf message field. Older readers safely ignore these // unknown event names, while newer readers can reconstruct the object. @@ -175,7 +182,7 @@ func parseEventArray(evtArr []interface{}) *Event { event.Type == "tfarSettings" || event.Type == "acreSettings" || event.Type == "serverFps" || event.Type == "weather" || event.Type == "zeusEntity" || event.Type == "zeusRemoteControl" || event.Type == "zeusCamera" || - event.Type == "zeusPing" || event.Type == "zeusModule" || event.Type == "explosion" || + event.Type == "zeusPing" || event.Type == "zeusModule" || isStructuredExplosion || event.Type == "serviceEvent" || event.Type == "staticWeapon" || event.Type == "radioTransmission" { if len(evtArr) > 2 { diff --git a/internal/storage/player_snapshot_test.go b/internal/storage/player_snapshot_test.go index 1c495c34b..948c1de2c 100644 --- a/internal/storage/player_snapshot_test.go +++ b/internal/storage/player_snapshot_test.go @@ -246,33 +246,34 @@ func TestZeusModuleIsNotAPlayerSnapshotEvent(t *testing.T) { } func TestParseExplosionEventPreservesPayload(t *testing.T) { + payload := map[string]interface{}{ + "x": float64(3411), + "y": float64(9002), + "ammo": "Sh_155mm_AMOS", + "name": "155mm HE", + "radius": float64(28), + "power": float64(180), + "firerId": float64(17), + "vehicleId": float64(4), + "side": "WEST", + "source": "projectile", + } event := parseEventArray([]interface{}{ float64(310), "explosion", - map[string]interface{}{ - "x": float64(3411), - "y": float64(9002), - "ammo": "Sh_155mm_AMOS", - "name": "155mm HE", - "radius": float64(28), - "power": float64(180), - "firerId": float64(17), - "vehicleId": float64(4), - "side": "WEST", - "source": "projectile", - }, + payload, }) require.NotNil(t, event) require.Equal(t, "explosion", event.Type) require.Equal(t, uint32(310), event.FrameNum) - var payload map[string]interface{} - require.NoError(t, json.Unmarshal([]byte(event.Message), &payload)) - require.Equal(t, "Sh_155mm_AMOS", payload["ammo"]) - require.Equal(t, float64(28), payload["radius"]) - require.Equal(t, float64(17), payload["firerId"]) - require.Equal(t, "projectile", payload["source"]) + var decoded map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(event.Message), &decoded)) + require.Equal(t, payload, decoded) + require.Zero(t, event.SourceID) + require.Zero(t, event.TargetID) + require.Zero(t, event.Distance) } func TestParseServiceEventPreservesPayload(t *testing.T) { From 6216114bf3475c029d2005c2feea29cee857132d Mon Sep 17 00:00:00 2001 From: Yethe Samartaka Date: Sat, 17 Oct 2026 19:34:52 +0200 Subject: [PATCH 28/39] Fix web review regressions and stream whole-mission analysis --- internal/frontend/dist/.gitkeep | 0 ui/public/.gitkeep | 0 .../convertedRecording.integration.test.ts | 10 +- .../__tests__/EventsTab.test.tsx | 4 +- .../__tests__/orbat.test.ts | 54 ++++---- .../pages/recording-playback/arsenalExport.ts | 2 +- .../components/OrbatTab.tsx | 40 ++++-- .../components/StatsTab.tsx | 22 +-- .../medical/__tests__/bodyImage.test.ts | 2 +- ui/src/pages/recording-playback/movement.ts | 106 ++++++++------- ui/src/pages/recording-playback/orbat.ts | 78 ++++++----- .../recording-playback/useRenderBridge.ts | 51 +++---- ui/src/playback/__tests__/engine.test.ts | 25 ---- .../playback/__tests__/entityManager.test.ts | 2 +- .../playback/__tests__/eventManager.test.ts | 4 + .../__tests__/wholeMissionAnalysis.test.ts | 127 ++++++++++++++++++ ui/src/playback/__tests__/zeus.test.ts | 4 +- ui/src/playback/deathHeatmap.ts | 22 +++ ui/src/playback/engine.ts | 61 ++++----- ui/src/playback/radioRange/demGrid.ts | 2 +- ui/src/playback/zeus.ts | 2 +- ui/src/renderers/leaflet/scaleBar.ts | 6 +- 22 files changed, 390 insertions(+), 234 deletions(-) create mode 100644 internal/frontend/dist/.gitkeep create mode 100644 ui/public/.gitkeep create mode 100644 ui/src/playback/__tests__/wholeMissionAnalysis.test.ts diff --git a/internal/frontend/dist/.gitkeep b/internal/frontend/dist/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/ui/public/.gitkeep b/ui/public/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/ui/src/data/__tests__/convertedRecording.integration.test.ts b/ui/src/data/__tests__/convertedRecording.integration.test.ts index 5bdf92235..314ce0789 100644 --- a/ui/src/data/__tests__/convertedRecording.integration.test.ts +++ b/ui/src/data/__tests__/convertedRecording.integration.test.ts @@ -11,7 +11,7 @@ * Both files are large local artifacts, so the suite skips itself when they are * not present. Point it somewhere else with OCAP_TEST_RECORDING / _V2. */ -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { gunzipSync } from "node:zlib"; import { resolve } from "node:path"; @@ -133,8 +133,12 @@ function gearFingerprint(inventory: InventorySnapshot) { } describe.skipIf(!available)("a converted recording replays through the real UI path", () => { - const original = decode(ORIGINAL); - const converted = decode(CONVERTED); + let original: ReturnType; + let converted: ReturnType; + beforeAll(() => { + original = decode(ORIGINAL); + converted = decode(CONVERTED); + }); it("decodes to the same mission, entities, markers and non-snapshot events", () => { expect(converted.missionName).toBe(original.missionName); diff --git a/ui/src/pages/recording-playback/__tests__/EventsTab.test.tsx b/ui/src/pages/recording-playback/__tests__/EventsTab.test.tsx index 54b95b9fb..52146fda2 100644 --- a/ui/src/pages/recording-playback/__tests__/EventsTab.test.tsx +++ b/ui/src/pages/recording-playback/__tests__/EventsTab.test.tsx @@ -883,7 +883,7 @@ describe("EventsTab", () => { unitDef({ id: 1, name: "BlueVictim", side: "WEST" }), unitDef({ id: 2, name: "BlueKiller", side: "WEST" }), ]; - const events = [ + const events: EventDef[] = [ killedEvent(0, 1, 2, "M4A1", 100), { frameNum: 1, @@ -1011,7 +1011,7 @@ describe("EventsTab", () => { it("faction filter OPFOR keeps an explosion with side EAST", () => { const { engine, renderer } = createTestEngine(); const entities = [unitDef({ id: 1, name: "Rifleman", side: "WEST" })]; - const events = [ + const events: EventDef[] = [ { frameNum: 0, type: "explosion", diff --git a/ui/src/pages/recording-playback/__tests__/orbat.test.ts b/ui/src/pages/recording-playback/__tests__/orbat.test.ts index 1dd4f83ac..8c7c493ef 100644 --- a/ui/src/pages/recording-playback/__tests__/orbat.test.ts +++ b/ui/src/pages/recording-playback/__tests__/orbat.test.ts @@ -17,9 +17,8 @@ function state(over: Partial = {}): EntityState { } /** - * The two builders only ever touch `entityManager`, `getStateAt`, `endFrame` and - * `captureDelayMs`, so a stub with those is enough and keeps the tests away from - * chunk loading and the renderer. + * Dense JSON state for focused calculation tests. The real bounded chunk cache + * is covered separately by wholeMissionAnalysis.test.ts. */ function fakeEngine(entities: Array, endFrame: number, captureDelayMs = 1000) { const byId = new Map(entities.map((e) => [e.id, e])); @@ -32,6 +31,9 @@ function fakeEngine(entities: Array, endFrame: number, captureDe entityManager: manager, endFrame: () => endFrame, captureDelayMs: () => captureDelayMs, + async visitAllFrames(this: PlaybackEngine, visit: Parameters[0]) { + visit(0, endFrame, (id, frame) => this.getStateAt(id, frame)); + }, getStateAt(entityId: number, frame: number): EntityState | null { const entity = manager.getEntity(entityId); if (!entity) return null; @@ -262,31 +264,31 @@ describe("buildOrbat", () => { }); describe("buildVehicleOccupancy", () => { - it("drops vehicles nobody ever boarded", () => { + it("drops vehicles nobody ever boarded", async () => { const parked = new Vehicle(9, "Parked", "car", 0, 3, "car", [ state({ crewIds: [] }), state({ crewIds: [] }), state({ crewIds: [] }), state({ crewIds: [] }), ]); - expect(buildVehicleOccupancy(fakeEngine([parked], 3, 1000), 0, 4)).toEqual([]); + expect(await buildVehicleOccupancy(fakeEngine([parked], 3, 1000), 0, 4)).toEqual([]); }); - it("records crew over time and the peak that sizes the strip", () => { + it("records crew over time and the peak that sizes the strip", async () => { const truck = new Vehicle(9, "Truck", "truck", 0, 3, "truck", [ state({ crewIds: [] }), state({ crewIds: [1, 2] }), state({ crewIds: [1, 2, 3] }), state({ crewIds: [] }), ]); - const [occupancy] = buildVehicleOccupancy(fakeEngine([truck], 3, 1000), 0, 4); + const [occupancy] = await buildVehicleOccupancy(fakeEngine([truck], 3, 1000), 0, 4); expect(occupancy.name).toBe("Truck"); expect(occupancy.peak).toBe(3); expect(occupancy.samples.map((s) => s.crewIds.length)).toEqual([0, 2, 3, 0]); }); - it("keeps only what a player of that side crewed when asked", () => { + it("keeps only what a player of that side crewed when asked", async () => { const truck = new Vehicle(9, "Player Truck", "truck", 0, 1, "truck", [ state({ crewIds: [1] }), state({ crewIds: [1] }), @@ -304,14 +306,14 @@ describe("buildVehicleOccupancy", () => { const enemy = unit(3, "Enemy", "EAST", "Bravo", [state(), state()]); const engine = fakeEngine([truck, aiTruck, enemyTruck, driver, ai, enemy], 1); - const names = buildVehicleOccupancy(engine, 0, 2, { + const names = (await buildVehicleOccupancy(engine, 0, 2, { side: "WEST", playersOnly: true, - }).map((v) => v.name); + })).map((v) => v.name); expect(names).toEqual(["Player Truck"]); }); - it("reports whether it is occupied at the playhead", () => { + it("reports whether it is occupied at the playhead", async () => { const truck = new Vehicle(9, "Truck", "truck", 0, 3, "truck", [ state({ crewIds: [] }), state({ crewIds: [1] }), @@ -320,19 +322,19 @@ describe("buildVehicleOccupancy", () => { ]); const engine = fakeEngine([truck], 3, 1000); - expect(buildVehicleOccupancy(engine, 1, 4)[0].occupiedNow).toBe(true); - expect(buildVehicleOccupancy(engine, 3, 4)[0].occupiedNow).toBe(false); + expect((await buildVehicleOccupancy(engine, 1, 4))[0].occupiedNow).toBe(true); + expect((await buildVehicleOccupancy(engine, 3, 4))[0].occupiedNow).toBe(false); }); }); describe("buildMovementStats", () => { - it("splits distance between on foot and mounted", () => { + it("splits distance between on foot and mounted", async () => { const walker = unit(1, "Walker", "WEST", "Alpha", [ state({ position: [0, 0, 0] }), state({ position: [100, 0, 0] }), state({ position: [200, 0, 0], isInVehicle: true }), ]); - const [stats] = buildMovementStats(fakeEngine([walker], 2, 1000), { step: 1 }); + const [stats] = await buildMovementStats(fakeEngine([walker], 2, 1000), { step: 1 }); expect(Math.round(stats.onFoot)).toBe(100); expect(Math.round(stats.mounted)).toBe(100); @@ -340,18 +342,18 @@ describe("buildMovementStats", () => { expect(stats.mountedFrames).toBe(1); }); - it("ignores a teleport, which is Zeus or a respawn rather than travel", () => { + it("ignores a teleport, which is Zeus or a respawn rather than travel", async () => { const teleported = unit(1, "Zeus bait", "WEST", "Alpha", [ state({ position: [0, 0, 0] }), state({ position: [5000, 0, 0] }), state({ position: [5050, 0, 0] }), ]); - const [stats] = buildMovementStats(fakeEngine([teleported], 2, 1000), { step: 1 }); + const [stats] = await buildMovementStats(fakeEngine([teleported], 2, 1000), { step: 1 }); expect(Math.round(stats.total)).toBe(50); }); - it("breaks the chain over a gap instead of bridging it", () => { + it("breaks the chain over a gap instead of bridging it", async () => { // Frame 1 is missing, which is what an unloaded chunk looks like; bridging // it would invent the whole distance across the gap. const gapped = unit(1, "Gapped", "WEST", "Alpha", [ @@ -360,20 +362,20 @@ describe("buildMovementStats", () => { state({ position: [300, 0, 0] }), state({ position: [310, 0, 0] }), ]); - const [stats] = buildMovementStats(fakeEngine([gapped], 3, 1000), { step: 1 }); + const [stats] = await buildMovementStats(fakeEngine([gapped], 3, 1000), { step: 1 }); expect(Math.round(stats.total)).toBe(10); }); - it("skips units that never moved", () => { + it("skips units that never moved", async () => { const still = unit(1, "Statue", "WEST", "Alpha", [ state({ position: [0, 0, 0] }), state({ position: [0, 0, 0] }), ]); - expect(buildMovementStats(fakeEngine([still], 1, 1000), { step: 1 })).toEqual([]); + expect(await buildMovementStats(fakeEngine([still], 1, 1000), { step: 1 })).toEqual([]); }); - it("ranks by total distance, furthest first", () => { + it("ranks by total distance, furthest first", async () => { const near = unit(1, "Near", "WEST", "Alpha", [ state({ position: [0, 0, 0] }), state({ position: [50, 0, 0] }), @@ -382,20 +384,20 @@ describe("buildMovementStats", () => { state({ position: [0, 0, 0] }), state({ position: [300, 0, 0] }), ]); - const ranked = buildMovementStats(fakeEngine([near, far], 1, 1000), { step: 1 }); + const ranked = await buildMovementStats(fakeEngine([near, far], 1, 1000), { step: 1 }); expect(ranked.map((s) => s.name)).toEqual(["Far", "Near"]); }); - it("leaves AI out unless asked for", () => { + it("leaves AI out unless asked for", async () => { const ai = unit(1, "AI", "WEST", "Alpha", [ state({ position: [0, 0, 0] }), state({ position: [200, 0, 0] }), ], { isPlayer: false }); const engine = fakeEngine([ai], 1, 1000); - expect(buildMovementStats(engine, { step: 1 })).toEqual([]); - expect(buildMovementStats(engine, { step: 1, playersOnly: false })).toHaveLength(1); + expect(await buildMovementStats(engine, { step: 1 })).toEqual([]); + expect(await buildMovementStats(engine, { step: 1, playersOnly: false })).toHaveLength(1); }); }); diff --git a/ui/src/pages/recording-playback/arsenalExport.ts b/ui/src/pages/recording-playback/arsenalExport.ts index e8e3aad5f..3587be36a 100644 --- a/ui/src/pages/recording-playback/arsenalExport.ts +++ b/ui/src/pages/recording-playback/arsenalExport.ts @@ -5,7 +5,7 @@ type Weapon = InventorySnapshot["weapons"][number]; type Magazine = InventorySnapshot["magazines"][number]; const itemCount = (item: GearItem): number => Math.max(1, Math.floor(item.count ?? 1)); -const sqfString = (value: string): string => `"${value.replaceAll('"', '""')}"`; +const sqfString = (value: string): string => `"${value.replace(/"/g, '""')}"`; /** * TFAR clones every handheld radio into a per-instance class that carries the diff --git a/ui/src/pages/recording-playback/components/OrbatTab.tsx b/ui/src/pages/recording-playback/components/OrbatTab.tsx index b70c89e94..dd24b126a 100644 --- a/ui/src/pages/recording-playback/components/OrbatTab.tsx +++ b/ui/src/pages/recording-playback/components/OrbatTab.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; +import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"; import type { JSX } from "solid-js"; import type { Side } from "../../../data/types"; @@ -11,7 +11,13 @@ import { SIDE_LABELS, } from "../../../config/sideColors"; import { formatElapsedTime } from "../../../playback/time"; -import { buildOrbat, buildVehicleOccupancy, isLeader } from "../orbat"; +import { + buildOrbat, + buildVehicleOccupancy, + isLeader, + selectVehicleOccupancy, + type VehicleOccupancy, +} from "../orbat"; import { activeSide, setActiveSide } from "../shortcuts"; import styles from "./OrbatTab.module.css"; import panel from "./SidePanel.module.css"; @@ -20,7 +26,8 @@ export function OrbatTab(): JSX.Element { const engine = useEngine(); const { t } = useI18n(); - const [chunksReady, setChunksReady] = createSignal(false); + const [vehicleHistory, setVehicleHistory] = createSignal([]); + const [vehiclesReady, setVehiclesReady] = createSignal(false); const [search, setSearch] = createSignal(""); const query = createMemo(() => search().trim().toLowerCase()); /** Set once the faction was chosen here, which stops the auto-pick below. */ @@ -62,14 +69,24 @@ export function OrbatTab(): JSX.Element { } }); - // The vehicle strips span the whole mission, so a chunked recording has to - // have every chunk in hand. Playback itself only ever loads the chunk under - // the playhead, which is why this is asked for explicitly rather than assumed. + // Crew samples survive chunk eviction and can be reused as the playhead moves. createEffect(() => { - void engine.ensureAllChunks().then(() => setChunksReady(true)); + engine.endFrame(); + let active = true; + onCleanup(() => { active = false; }); + setVehicleHistory([]); + setVehiclesReady(false); + void buildVehicleOccupancy(engine, 0).then((history) => { + if (active) setVehicleHistory(history); + }).catch((error) => console.error("Failed to analyze vehicle occupancy", error)) + .finally(() => { if (active) setVehiclesReady(true); }); }); - const allGroups = createMemo(() => buildOrbat(engine, activeSide(), engine.currentFrame())); + const allGroups = createMemo(() => { + // Re-read after a seek finishes loading the playhead's chunk. + engine.entitySnapshots(); + return buildOrbat(engine, activeSide(), engine.currentFrame()); + }); /** * Groups narrowed by the search box. A hit on the group name keeps the whole @@ -92,9 +109,7 @@ export function OrbatTab(): JSX.Element { /** Only what the players of this faction rode; the AI motor pool is noise here. */ const allVehicles = createMemo(() => { - chunksReady(); - engine.endFrame(); - return buildVehicleOccupancy(engine, engine.currentFrame(), 160, { + return selectVehicleOccupancy(engine, vehicleHistory(), engine.currentFrame(), { side: activeSide(), playersOnly: true, }); @@ -129,6 +144,7 @@ export function OrbatTab(): JSX.Element { /** Crew aboard at the sample nearest the playhead. */ const currentCrew = (vehicleId: number): number[] => { + engine.entitySnapshots(); const state = engine.getStateAt(vehicleId, engine.currentFrame()); return state?.crewIds ?? []; }; @@ -240,7 +256,7 @@ export function OrbatTab(): JSX.Element {
{t("orbat_vehicles")}
{t("orbat_loading_vehicles")}
} > ([]); const [movementReady, setMovementReady] = createSignal(false); createEffect(() => { - void engine.ensureAllChunks().then(() => setMovementReady(true)); - }); - - const movement = createMemo(() => { - movementReady(); engine.endFrame(); - return buildMovementStats(engine); + let active = true; + onCleanup(() => { active = false; }); + setMovement([]); + setMovementReady(false); + void buildMovementStats(engine).then((stats) => { + if (active) setMovement(stats); + }).catch((error) => console.error("Failed to analyze movement", error)) + .finally(() => { if (active) setMovementReady(true); }); }); // Frame-aware kill/death counts diff --git a/ui/src/pages/recording-playback/medical/__tests__/bodyImage.test.ts b/ui/src/pages/recording-playback/medical/__tests__/bodyImage.test.ts index 1311ef215..fefaaa7ef 100644 --- a/ui/src/pages/recording-playback/medical/__tests__/bodyImage.test.ts +++ b/ui/src/pages/recording-playback/medical/__tests__/bodyImage.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { MedicalBodyPart } from "../../../data/types"; +import type { MedicalBodyPart } from "../../../../data/types"; import { BLOOD_LOSS_COLORS, DAMAGE_COLORS, diff --git a/ui/src/pages/recording-playback/movement.ts b/ui/src/pages/recording-playback/movement.ts index 9f7a1bc3b..f55aee803 100644 --- a/ui/src/pages/recording-playback/movement.ts +++ b/ui/src/pages/recording-playback/movement.ts @@ -34,10 +34,10 @@ export interface MovementStats { */ const MAX_STEP_METRES = 400; -export function buildMovementStats( +export async function buildMovementStats( engine: PlaybackEngine, options: { step?: number; playersOnly?: boolean } = {}, -): MovementStats[] { +): Promise { const endFrame = engine.endFrame(); if (endFrame <= 0) return []; @@ -47,61 +47,63 @@ export function buildMovementStats( const secondsPerStep = (step * captureDelayMs) / 1000; const playersOnly = options.playersOnly ?? true; - const result: MovementStats[] = []; - - for (const entity of engine.entityManager.getAll()) { - if (!(entity instanceof Unit)) continue; - if (playersOnly && !entity.isPlayer) continue; - - let onFoot = 0; - let mounted = 0; - let mountedFrames = 0; - let trackedFrames = 0; - let topSpeed = 0; - - let previous: { x: number; y: number } | null = null; - const last = Math.min(entity.endFrame, endFrame); - - for (let frame = entity.startFrame; frame <= last; frame += step) { - const state = engine.getStateAt(entity.id, frame); - if (!state) { - // A gap in loaded data breaks the chain rather than bridging it, so an - // unloaded stretch cannot masquerade as a long march. - previous = null; - continue; - } - - trackedFrames += 1; - if (state.isInVehicle) mountedFrames += 1; + const units = engine.entityManager.getAll().filter( + (entity): entity is Unit => entity instanceof Unit && (!playersOnly || entity.isPlayer), + ); + if (units.length === 0) return []; + const totals = units.map((entity) => ({ + unitId: entity.id, + name: entity.name, + side: entity.side, + onFoot: 0, + mounted: 0, + total: 0, + mountedFrames: 0, + trackedFrames: 0, + topSpeed: 0, + })); + const previous = new Map(); + + // Keep only totals and the previous sample across chunks. In particular, a + // sample on either side of a chunk boundary must still count as one step. + await engine.visitAllFrames((firstFrame, lastFrame, stateAt) => { + for (let i = 0; i < units.length; i++) { + const entity = units[i]; + const stats = totals[i]; + const offset = Math.max(0, Math.ceil((firstFrame - entity.startFrame) / step)); + const first = entity.startFrame + offset * step; + const last = Math.min(entity.endFrame, endFrame, lastFrame); + for (let frame = first; frame <= last; frame += step) { + const state = stateAt(entity.id, frame); + if (!state) { + // A gap in loaded data breaks the chain rather than bridging it, so an + // unloaded stretch cannot masquerade as a long march. + previous.delete(entity.id); + continue; + } - const position = { x: state.position[0], y: state.position[1] }; - if (previous) { - const distance = Math.hypot(position.x - previous.x, position.y - previous.y); - if (distance <= MAX_STEP_METRES) { - if (state.isInVehicle) mounted += distance; - else onFoot += distance; - topSpeed = Math.max(topSpeed, distance / secondsPerStep); + stats.trackedFrames += 1; + if (state.isInVehicle) stats.mountedFrames += 1; + + const position = { x: state.position[0], y: state.position[1] }; + const prior = previous.get(entity.id); + if (prior) { + const distance = Math.hypot(position.x - prior.x, position.y - prior.y); + if (distance <= MAX_STEP_METRES) { + if (state.isInVehicle) stats.mounted += distance; + else stats.onFoot += distance; + stats.topSpeed = Math.max(stats.topSpeed, distance / secondsPerStep); + } } + previous.set(entity.id, position); } - previous = position; } + }); - if (onFoot + mounted < 1) continue; - - result.push({ - unitId: entity.id, - name: entity.name, - side: entity.side, - onFoot, - mounted, - total: onFoot + mounted, - mountedFrames, - trackedFrames, - topSpeed, - }); - } - - return result.sort((a, b) => b.total - a.total); + return totals + .map((stats) => ({ ...stats, total: stats.onFoot + stats.mounted })) + .filter((stats) => stats.total >= 1) + .sort((a, b) => b.total - a.total); } /** Metres as a compact "1.2 km" / "840 m". */ diff --git a/ui/src/pages/recording-playback/orbat.ts b/ui/src/pages/recording-playback/orbat.ts index 3a40040c3..0de085936 100644 --- a/ui/src/pages/recording-playback/orbat.ts +++ b/ui/src/pages/recording-playback/orbat.ts @@ -186,47 +186,63 @@ function playerUnitIds(engine: PlaybackEngine, side?: Side): Set { * story. The crew lists themselves stay whole — who a player rode with is * part of it. */ -export function buildVehicleOccupancy( +export async function buildVehicleOccupancy( engine: PlaybackEngine, frame: number, sampleCount = 160, options: { side?: Side; playersOnly?: boolean } = {}, -): VehicleOccupancy[] { +): Promise { const endFrame = engine.endFrame(); if (endFrame <= 0) return []; - const players = options.playersOnly ? playerUnitIds(engine, options.side) : null; - const step = Math.max(1, Math.floor(endFrame / sampleCount)); - const result: VehicleOccupancy[] = []; - - for (const entity of engine.entityManager.getAll()) { - if (!(entity instanceof Vehicle)) continue; - - const samples: CrewSample[] = []; - let peak = 0; - let occupiedNow = false; - let carriedPlayer = false; - let nearestDistance = Number.POSITIVE_INFINITY; - - for (let f = entity.startFrame; f <= Math.min(entity.endFrame, endFrame); f += step) { - const state = engine.getStateAt(entity.id, f); - const crewIds = state?.crewIds ?? []; - samples.push({ frame: f, crewIds }); - if (crewIds.length > peak) peak = crewIds.length; - if (players && !carriedPlayer) carriedPlayer = crewIds.some((id) => players.has(id)); - - const distance = Math.abs(f - frame); - if (distance < nearestDistance) { - nearestDistance = distance; - occupiedNow = crewIds.length > 0; + const vehicles = engine.entityManager.getAll().filter( + (entity): entity is Vehicle => entity instanceof Vehicle, + ); + if (vehicles.length === 0) return []; + const result: VehicleOccupancy[] = vehicles.map((entity) => ({ + vehicleId: entity.id, name: entity.name, samples: [], peak: 0, occupiedNow: false, + })); + + await engine.visitAllFrames((firstFrame, lastFrame, stateAt) => { + for (let i = 0; i < vehicles.length; i++) { + const entity = vehicles[i]; + const occupancy = result[i]; + const offset = Math.max(0, Math.ceil((firstFrame - entity.startFrame) / step)); + const first = entity.startFrame + offset * step; + const last = Math.min(entity.endFrame, endFrame, lastFrame); + for (let f = first; f <= last; f += step) { + const crewIds = [...(stateAt(entity.id, f)?.crewIds ?? [])]; + occupancy.samples.push({ frame: f, crewIds }); + occupancy.peak = Math.max(occupancy.peak, crewIds.length); } } + }); - if (peak === 0) continue; - if (players && !carriedPlayer) continue; - result.push({ vehicleId: entity.id, name: entity.name, samples, peak, occupiedNow }); - } + return selectVehicleOccupancy(engine, result, frame, options); +} - return result.sort((a, b) => b.peak - a.peak || a.name.localeCompare(b.name)); +/** Filter durable crew samples and update the playhead indicator without loading chunks again. */ +export function selectVehicleOccupancy( + engine: PlaybackEngine, + history: readonly VehicleOccupancy[], + frame: number, + options: { side?: Side; playersOnly?: boolean } = {}, +): VehicleOccupancy[] { + const players = options.playersOnly ? playerUnitIds(engine, options.side) : null; + return history + .filter((vehicle) => { + if (vehicle.peak === 0) return false; + return !players || vehicle.samples.some((sample) => sample.crewIds.some((id) => players.has(id))); + }) + .map((vehicle) => { + let nearest: CrewSample | undefined; + for (const sample of vehicle.samples) { + if (!nearest || Math.abs(sample.frame - frame) < Math.abs(nearest.frame - frame)) { + nearest = sample; + } + } + return { ...vehicle, occupiedNow: (nearest?.crewIds.length ?? 0) > 0 }; + }) + .sort((a, b) => b.peak - a.peak || a.name.localeCompare(b.name)); } diff --git a/ui/src/pages/recording-playback/useRenderBridge.ts b/ui/src/pages/recording-playback/useRenderBridge.ts index 356a0d571..c0bc2fed9 100644 --- a/ui/src/pages/recording-playback/useRenderBridge.ts +++ b/ui/src/pages/recording-playback/useRenderBridge.ts @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal } from "solid-js"; +import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"; import type { Accessor } from "solid-js"; import type { MarkerHandle, @@ -30,6 +30,7 @@ import { } from "../../playback/explosions"; import { binDeathSites, + buildDeathSites, busiestCell, deathBlobAlpha, deathBlobRadius, @@ -665,41 +666,29 @@ export function useRenderBridge( // as more deaths land in its cell has to be recreated rather than updated. const deathBlobs = new Map(); - // A death's position is read from the chunk covering the frame it happened on, - // and playback keeps only the chunk under the playhead and its neighbour - // resident. Reading them straight therefore silently dropped every death - // outside that window, and blobs blinked in and out as the viewer scrubbed. - // The whole recording is pulled in once, the first time the overlay is turned - // on; a JSON recording already holds everything and resolves immediately, and - // anyone who leaves the overlay off never pays for it. - const [deathChunksReady, setDeathChunksReady] = createSignal(false); + // Resolve positions during traversal, then keep only the small site list. + const [deathSites, setDeathSites] = createSignal([]); + let deathSitesLoad: Promise | undefined; createEffect(() => { - if (!deathHeatmapVisible()) return; - if (deathChunksReady()) return; - void engine.ensureAllChunks().then(() => setDeathChunksReady(true)); + engine.endFrame(); + deathSitesLoad = undefined; + setDeathSites([]); }); - /** - * Every death in the recording with a resolved position, oldest first. - * - * Depends on the recording rather than the playhead, so it is resolved once - * instead of rescanning every event and re-reading every victim's position on - * each frame. Sorted so the binning below can stop at the playhead. - */ - const deathSites = createMemo(() => { - if (!deathHeatmapVisible() || !deathChunksReady()) return []; + createEffect(() => { + if (!deathHeatmapVisible()) return; engine.endFrame(); - - const sites: DeathSite[] = []; - for (const event of engine.eventManager.getAll()) { - if (!(event instanceof HitKilledEvent) || event.type !== "killed") continue; - const position = engine.getPositionAt(event.victimId, event.frameNum); - if (!position) continue; - sites.push({ frameNum: event.frameNum, x: position[0], y: position[1] }); - } - sites.sort((a, b) => a.frameNum - b.frameNum); - return sites; + let active = true; + onCleanup(() => { active = false; }); + deathSitesLoad ??= buildDeathSites(engine).catch((error) => { + deathSitesLoad = undefined; + console.error("Failed to resolve death sites", error); + return []; + }); + void deathSitesLoad.then((sites) => { + if (active) setDeathSites(sites); + }); }); createEffect(() => { diff --git a/ui/src/playback/__tests__/engine.test.ts b/ui/src/playback/__tests__/engine.test.ts index 6f7d4dc16..88f03915e 100644 --- a/ui/src/playback/__tests__/engine.test.ts +++ b/ui/src/playback/__tests__/engine.test.ts @@ -191,31 +191,6 @@ describe("PlaybackEngine", () => { }); }); - describe("ensureAllChunks", () => { - it("loads every chunk of a chunked recording exactly once each", async () => { - const cm = makeMockChunkManager(); - (cm.loadChunk as ReturnType).mockResolvedValue(undefined); - engine.loadRecording(makeManifest({ chunkCount: 4 }), cm); - - await engine.ensureAllChunks(); - - expect(cm.loadChunk).toHaveBeenCalledTimes(4); - expect((cm.loadChunk as ReturnType).mock.calls.map((c) => c[0])).toEqual([ - 0, 1, 2, 3, - ]); - }); - - it("resolves immediately for a JSON recording, fetching nothing", async () => { - engine.loadRecording(makeManifest({ chunkCount: 0 })); - await expect(engine.ensureAllChunks()).resolves.toBeUndefined(); - }); - - it("resolves when a recording has no chunk manager even if the manifest claims chunks", async () => { - engine.loadRecording(makeManifest({ chunkCount: 5 })); - await expect(engine.ensureAllChunks()).resolves.toBeUndefined(); - }); - }); - // ─── Player snapshot sidecars ─── describe("ensurePlayerSnapshots", () => { diff --git a/ui/src/playback/__tests__/entityManager.test.ts b/ui/src/playback/__tests__/entityManager.test.ts index c47989e61..844e137e6 100644 --- a/ui/src/playback/__tests__/entityManager.test.ts +++ b/ui/src/playback/__tests__/entityManager.test.ts @@ -548,7 +548,7 @@ describe("EntityManager", () => { })); expect(entity).toBeInstanceOf(Unit); expect(entity.iconType).toBe("zeus"); - expect(entity.side).toBe("VIRTUAL"); + expect((entity as Unit).side).toBe("VIRTUAL"); }); it("creates a Vehicle for type 'car'", () => { diff --git a/ui/src/playback/__tests__/eventManager.test.ts b/ui/src/playback/__tests__/eventManager.test.ts index 18c22d6d6..11cfa334f 100644 --- a/ui/src/playback/__tests__/eventManager.test.ts +++ b/ui/src/playback/__tests__/eventManager.test.ts @@ -403,12 +403,14 @@ describe("EventManager", () => { it("rebuilds one unit's series when its snapshots arrive later", () => { mgr.addEvent(new PlayerSnapshotEvent(10, "medicalSnapshot", 1, { unitId: 3, + vanilla: {}, ace: { heartRate: 70 }, })); mgr.reconstructPlayerSnapshots(); mgr.addEvent(new PlayerSnapshotEvent(10, "medicalSnapshot", 2, { unitId: 7, + vanilla: {}, ace: { heartRate: 80, pain: 0 }, })); mgr.addEvent(new PlayerSnapshotEvent(20, "medicalSnapshot", 3, { @@ -421,10 +423,12 @@ describe("EventManager", () => { expect(mgr.getPlayerSnapshots(7, 20).get("medicalSnapshot")!.payload).toEqual({ unitId: 7, + vanilla: {}, ace: { heartRate: 130, pain: 0 }, }); expect(mgr.getPlayerSnapshots(3, 10).get("medicalSnapshot")!.payload).toEqual({ unitId: 3, + vanilla: {}, ace: { heartRate: 70 }, }); }); diff --git a/ui/src/playback/__tests__/wholeMissionAnalysis.test.ts b/ui/src/playback/__tests__/wholeMissionAnalysis.test.ts new file mode 100644 index 000000000..554403a8d --- /dev/null +++ b/ui/src/playback/__tests__/wholeMissionAnalysis.test.ts @@ -0,0 +1,127 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ChunkManager } from "../../data/chunkManager"; +import type { ApiClient } from "../../data/apiClient"; +import type { DecoderStrategy } from "../../data/decoders/decoder.interface"; +import type { ChunkData, EntityState, Manifest } from "../../data/types"; +import { MockRenderer } from "../../renderers/mockRenderer"; +import { buildMovementStats } from "../../pages/recording-playback/movement"; +import { buildOrbat, buildVehicleOccupancy, selectVehicleOccupancy } from "../../pages/recording-playback/orbat"; +import { buildDeathSites } from "../deathHeatmap"; +import { PlaybackEngine } from "../engine"; + +const engines: PlaybackEngine[] = []; +afterEach(() => { + for (const engine of engines) engine.dispose(); + engines.length = 0; +}); + +async function recording(chunked = true) { + const manifest: Manifest = { + version: 1, worldName: "altis", missionName: "Four chunks", endFrame: 1199, + chunkSize: 300, chunkCount: chunked ? 4 : 0, captureDelayMs: 1000, + entities: [ + { id: 1, type: "man", name: "Walker", side: "WEST", groupName: "Alpha", + isPlayer: true, startFrame: 0, endFrame: 1199 }, + { id: 2, type: "man", name: "Late joiner", side: "WEST", groupName: "Bravo", + isPlayer: true, startFrame: 3, endFrame: 307 }, + { id: 9, type: "truck", name: "Truck", side: "WEST", groupName: "", + isPlayer: false, startFrame: 0, endFrame: 1199 }, + ], + events: [920, 20, 620, 320].map((frameNum) => ({ + frameNum, type: "killed", victimId: 1, causedById: 2, weapon: "Test", distance: 10, + })), + markers: [], times: [], + }; + const state = (id: number, frame: number): EntityState => ({ + position: [frame, 10], direction: 0, alive: 1, + groupName: id === 1 ? "Alpha" : "Bravo", isPlayer: id !== 9, + // The player only used the truck in the first chunk. + crewIds: id === 9 && frame < 100 ? [1] : [], + }); + const getChunk = vi.fn(async (_filename: string, index: number) => new Uint32Array([index]).buffer); + const api = { + getManifest: vi.fn(async () => new ArrayBuffer(0)), getChunk, + } as unknown as ApiClient; + const decoder: DecoderStrategy = { + decodeManifest: () => manifest, + decodeChunk: (buffer): ChunkData => { + const index = new Uint32Array(buffer)[0]; + return { entities: new Map(manifest.entities.map((entity) => [entity.id, + Array.from({ length: 300 }, (_, offset) => state(entity.id, index * 300 + offset)), + ])) }; + }, + decodePlayerSnapshots: () => [], + }; + const cache = new ChunkManager(decoder, api); + if (chunked) { + await cache.loadManifest("four-chunks"); + await cache.ensureLoaded(0); + } else { + for (const entity of manifest.entities) { + entity.positions = Array.from({ length: entity.endFrame - entity.startFrame + 1 }, + (_, offset) => state(entity.id, entity.startFrame + offset)); + } + } + const engine = new PlaybackEngine(new MockRenderer()); + engines.push(engine); + engine.loadRecording(manifest, chunked ? cache : undefined); + return { engine, cache, getChunk }; +} + +describe("whole-mission analysis with the real three-chunk cache", () => { + it("counts movement from all four chunks, including steps across their boundaries", async () => { + const { engine, cache } = await recording(); + const stats = await buildMovementStats(engine); + expect(stats.find((row) => row.unitId === 1)?.total).toBe(1195); + // A delayed start and a step that does not divide 300 preserve sample alignment. + const uneven = await buildMovementStats(engine, { step: 7 }); + expect(uneven.find((row) => row.unitId === 2)?.total).toBe(301); + expect([0, 300, 600, 900].filter((frame) => cache.getChunkForFrame(frame))).toHaveLength(3); + expect(engine.getStateAt(1, 300)).toBeNull(); + expect(engine.getStateAt(1, 0)?.position).toEqual([0, 10]); + expect(buildOrbat(engine, "WEST", 0)[0].members[0].name).toBe("Walker"); + }); + + it("retains early vehicle crew history and filters it without fetching again", async () => { + const { engine, cache, getChunk } = await recording(); + const history = await buildVehicleOccupancy(engine, 0); + // Evict the early data again: the displayed strip must survive independently. + for (let index = 1; index < 4; index++) await cache.loadChunk(index); + expect(engine.getStateAt(9, 0)).toBeNull(); + const requests = getChunk.mock.calls.length; + const [early] = selectVehicleOccupancy(engine, history, 0, { side: "WEST", playersOnly: true }); + expect(early.peak).toBe(1); + expect(early.samples[0]).toEqual({ frame: 0, crewIds: [1] }); + expect(early.occupiedNow).toBe(true); + expect(selectVehicleOccupancy(engine, history, 1199)[0].occupiedNow).toBe(false); + expect(selectVehicleOccupancy(engine, history, 0, { side: "EAST", playersOnly: true })).toEqual([]); + expect(getChunk).toHaveBeenCalledTimes(requests); + }); + + it("resolves deaths in every chunk into durable, chronological positions", async () => { + const { engine, cache } = await recording(); + const sites = await buildDeathSites(engine); + for (let index = 1; index < 4; index++) await cache.loadChunk(index); + expect(engine.getPositionAt(1, 20)).toBeNull(); + expect(sites).toEqual([20, 320, 620, 920].map((frameNum) => ({ frameNum, x: frameNum, y: 10 }))); + }); + + it("produces the same results for dense JSON and bounded chunk playback", async () => { + const { engine: chunked } = await recording(); + const { engine: json } = await recording(false); + expect(await buildMovementStats(chunked)).toEqual(await buildMovementStats(json)); + expect(await buildVehicleOccupancy(chunked, 700)).toEqual(await buildVehicleOccupancy(json, 700)); + expect(await buildDeathSites(chunked)).toEqual(await buildDeathSites(json)); + }); + + it("propagates a failed chunk load and restores the paused playhead", async () => { + const { engine, getChunk } = await recording(); + getChunk.mockImplementation(async (_filename, index) => { + if (index === 3) throw new Error("unavailable chunk"); + return new Uint32Array([index]).buffer; + }); + await expect(buildMovementStats(engine)).rejects.toThrow("unavailable chunk"); + expect(engine.getStateAt(1, 0)?.position).toEqual([0, 10]); + expect(engine.entitySnapshots().get(1)?.position).toEqual([0, 10]); + }); +}); diff --git a/ui/src/playback/__tests__/zeus.test.ts b/ui/src/playback/__tests__/zeus.test.ts index 3051675fb..977b949a6 100644 --- a/ui/src/playback/__tests__/zeus.test.ts +++ b/ui/src/playback/__tests__/zeus.test.ts @@ -326,7 +326,7 @@ describe("PlaybackEngine Zeus synthesis", () => { const zeus = engine.entityManager.getEntity(90); expect(zeus).toBeInstanceOf(Unit); - expect(zeus?.side).toBe("VIRTUAL"); + expect((zeus as Unit).side).toBe("VIRTUAL"); expect(engine.entityManager.getBySide("VIRTUAL")).toHaveLength(1); engine.seekTo(0); @@ -377,7 +377,7 @@ describe("PlaybackEngine Zeus synthesis", () => { const zeus = engine.entityManager.getEntity(0); expect(zeus).toBeInstanceOf(Unit); expect(zeus?.type).toBe("zeus"); - expect(zeus?.side).toBe("VIRTUAL"); + expect((zeus as Unit).side).toBe("VIRTUAL"); expect(zeus?.name).toBe("YetheSamartaka"); expect(engine.entityManager.getBySide("VIRTUAL")).toHaveLength(1); diff --git a/ui/src/playback/deathHeatmap.ts b/ui/src/playback/deathHeatmap.ts index a94c240f1..09281ea40 100644 --- a/ui/src/playback/deathHeatmap.ts +++ b/ui/src/playback/deathHeatmap.ts @@ -12,6 +12,9 @@ * frame. */ +import type { PlaybackEngine } from "./engine"; +import { HitKilledEvent } from "./events/hitKilledEvent"; + /** One death, already resolved to a map position. */ export interface DeathSite { frameNum: number; @@ -19,6 +22,25 @@ export interface DeathSite { y: number; } +/** Resolve each victim while the chunk containing their death is still available. */ +export async function buildDeathSites(engine: PlaybackEngine): Promise { + const deaths = engine.eventManager.getAll().filter( + (event): event is HitKilledEvent => event instanceof HitKilledEvent && event.type === "killed", + ).sort((a, b) => a.frameNum - b.frameNum); + if (deaths.length === 0) return []; + const sites: DeathSite[] = []; + let cursor = 0; + await engine.visitAllFrames((firstFrame, lastFrame, stateAt) => { + while (cursor < deaths.length && deaths[cursor].frameNum < firstFrame) cursor++; + for (; cursor < deaths.length && deaths[cursor].frameNum <= lastFrame; cursor++) { + const event = deaths[cursor]; + const position = stateAt(event.victimId, event.frameNum)?.position; + if (position) sites.push({ frameNum: event.frameNum, x: position[0], y: position[1] }); + } + }); + return sites; +} + /** Deaths sharing one grid cell, collapsed to a single blob. */ export interface DeathCell { x: number; diff --git a/ui/src/playback/engine.ts b/ui/src/playback/engine.ts index 0f966267d..c9a60f60b 100644 --- a/ui/src/playback/engine.ts +++ b/ui/src/playback/engine.ts @@ -532,23 +532,6 @@ export class PlaybackEngine { return this.getStateAt(entityId, frame)?.position ?? null; } - /** - * Every chunk of a chunked recording, loaded. - * - * Playback only ever needs the chunk under the playhead and its neighbour, so - * chunks are lazy. Whole-mission analysis is the opposite shape and has to pay - * for the lot once; a JSON recording already holds everything and resolves - * immediately. - */ - ensureAllChunks(): Promise { - const chunkManager = this.chunkManager; - const count = this.manifest?.chunkCount ?? 0; - if (!chunkManager || count <= 0) return Promise.resolve(); - const loads: Promise[] = []; - for (let i = 0; i < count; i++) loads.push(chunkManager.loadChunk(i)); - return Promise.all(loads).then(() => {}); - } - /** * Walk the whole recording once, handing `visit` each stretch of frames * together with a state reader that is valid for that stretch. @@ -575,22 +558,34 @@ export class PlaybackEngine { } const chunkSize = this.manifest?.chunkSize || 300; - for (let index = 0; index < count; index++) { - const firstFrame = index * chunkSize; - if (firstFrame > endFrame) break; - const chunk = await chunkManager.loadChunk(index); - // A different recording was loaded while this chunk was in flight. - if (this.chunkManager !== chunkManager) return; - const lastFrame = Math.min(firstFrame + chunkSize - 1, endFrame); - visit(firstFrame, lastFrame, (entityId, frame) => { - const entity = this.entityManager.getEntity(entityId); - if (!entity || frame < entity.startFrame || frame > entity.endFrame) return null; - return ( - chunk.entities.get(entityId)?.[frame - firstFrame] ?? - entity.positions?.[entity.getRelativeFrameIndex(frame)] ?? - null - ); - }); + try { + for (let index = 0; index < count; index++) { + const firstFrame = index * chunkSize; + if (firstFrame > endFrame) break; + const chunk = await chunkManager.loadChunk(index); + // A different recording was loaded while this chunk was in flight. + if (this.chunkManager !== chunkManager) return; + const lastFrame = Math.min(firstFrame + chunkSize - 1, endFrame); + visit(firstFrame, lastFrame, (entityId, frame) => { + const entity = this.entityManager.getEntity(entityId); + if (!entity || frame < entity.startFrame || frame > entity.endFrame) return null; + return ( + chunk.entities.get(entityId)?.[frame - firstFrame] ?? + entity.positions?.[entity.getRelativeFrameIndex(frame)] ?? + null + ); + }); + } + } finally { + // Analysis may evict the playhead's chunk. Restore it even when a later + // chunk fails, so a paused recording and its roster remain usable. + if (this.chunkManager === chunkManager) { + const frame = this._currentFrame(); + await chunkManager.ensureLoaded(frame); + if (this.chunkManager === chunkManager && this._currentFrame() === frame) { + this.computeSnapshots(frame); + } + } } } diff --git a/ui/src/playback/radioRange/demGrid.ts b/ui/src/playback/radioRange/demGrid.ts index 50bf251b8..3889cd92d 100644 --- a/ui/src/playback/radioRange/demGrid.ts +++ b/ui/src/playback/radioRange/demGrid.ts @@ -197,7 +197,7 @@ async function fetchDemCandidate(url: string): Promise { try { const res = await fetch(url, { cache: "force-cache" }); if (!res.ok) return undefined; - let bytes = new Uint8Array(await res.arrayBuffer()); + let bytes: Uint8Array = new Uint8Array(await res.arrayBuffer()); if (!looksDemBinary(bytes)) return undefined; if (looksGzip(bytes)) bytes = await inflateGzip(bytes); return decodeDemBinary(bytes); diff --git a/ui/src/playback/zeus.ts b/ui/src/playback/zeus.ts index 31c0f01e5..3988bf0ec 100644 --- a/ui/src/playback/zeus.ts +++ b/ui/src/playback/zeus.ts @@ -193,7 +193,7 @@ export function groupZeusModules(events: ZeusModuleLike[]): ZeusModuleGroup[] { groups.push({ key: `${curatorId}:${displayName}:${attached}:${event.frameNum}`, curatorId, - curatorName, + curatorName: curatorName ?? "", displayName, attachedName: attached, position: [x, y], diff --git a/ui/src/renderers/leaflet/scaleBar.ts b/ui/src/renderers/leaflet/scaleBar.ts index 0535dcc41..216550088 100644 --- a/ui/src/renderers/leaflet/scaleBar.ts +++ b/ui/src/renderers/leaflet/scaleBar.ts @@ -7,6 +7,8 @@ * 1 m collapses to "1 m" and never switches to centimetres. */ +import type { ArmaCoord } from "../../utils/coordinates"; + /** Leaflet's default scale-bar target width. */ export const SCALE_MAX_WIDTH_PX = 100; @@ -57,8 +59,8 @@ export function scaleBarWidthPx( /** Planar distance in Arma metres between two world positions. */ export function armaMetersBetween( - a: [number, number], - b: [number, number], + a: ArmaCoord, + b: ArmaCoord, ): number { return Math.hypot(b[0] - a[0], b[1] - a[1]); } From 67434590bc1dc0e80dd2a256bfea3a54d4a9265d Mon Sep 17 00:00:00 2001 From: Yethe Samartaka Date: Sat, 17 Oct 2026 20:02:56 +0200 Subject: [PATCH 29/39] Fix chunked combat factions and asynchronous terrain loading --- .../convertedCombat.integration.test.ts | 150 ++++++++++++++++++ .../__tests__/loadRecording.test.ts | 2 +- .../__tests__/useRenderBridge.test.tsx | 92 +++++++++++ .../pages/recording-playback/loadRecording.ts | 6 +- .../recording-playback/useRenderBridge.ts | 8 +- ui/src/playback/combatEventSides.ts | 39 +++++ ui/src/playback/engine.ts | 26 ++- ui/src/playback/eventManager.ts | 13 +- 8 files changed, 321 insertions(+), 15 deletions(-) create mode 100644 ui/src/pages/recording-playback/__tests__/convertedCombat.integration.test.ts create mode 100644 ui/src/playback/combatEventSides.ts diff --git a/ui/src/pages/recording-playback/__tests__/convertedCombat.integration.test.ts b/ui/src/pages/recording-playback/__tests__/convertedCombat.integration.test.ts new file mode 100644 index 000000000..b04385c8f --- /dev/null +++ b/ui/src/pages/recording-playback/__tests__/convertedCombat.integration.test.ts @@ -0,0 +1,150 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ApiClient } from "../../../data/apiClient"; +import { ChunkManager } from "../../../data/chunkManager"; +import { ProtobufDecoder } from "../../../data/decoders/protobufDecoder"; +import { + Chunk as PbChunk, + EntityType as PbEntityType, + Manifest as PbManifest, + Side as PbSide, +} from "../../../data/decoders/generated/ocap.pb"; +import type { EntityState, Recording } from "../../../data/types"; +import { PlaybackEngine } from "../../../playback/engine"; +import { Unit } from "../../../playback/entities/unit"; +import { HitKilledEvent } from "../../../playback/events/hitKilledEvent"; +import { MarkerManager } from "../../../playback/markerManager"; +import { MockRenderer } from "../../../renderers/mockRenderer"; +import { loadRecording } from "../loadRecording"; + +const engines: PlaybackEngine[] = []; +afterEach(() => { + for (const engine of engines) engine.dispose(); + engines.length = 0; +}); + +const buffer = (bytes: Uint8Array): ArrayBuffer => new Uint8Array(bytes).buffer; + +function fixture(recordSides = true) { + const killFrames = [1, 301, 901, 1201]; + const raw = PbManifest.fromPartial({ + version: 1, worldName: "altis", missionName: "Faction changes", endFrame: 1499, + chunkSize: 300, chunkCount: 5, captureDelayMs: 1000, + entities: [1, 2].map((id) => ({ + id, type: PbEntityType.ENTITY_TYPE_UNIT, name: `Unit ${id}`, side: PbSide.SIDE_WEST, + isPlayer: true, startFrame: 0, endFrame: 1499, + })), + events: [ + ...killFrames.map((frameNum) => ({ frameNum, type: "killed", sourceId: 1, targetId: 2, weapon: "MX" })), + { frameNum: 302, type: "hit", sourceId: 1, targetId: 2, weapon: "MX" }, + ], + }); + const state = (id: number, frame: number): EntityState => ({ + position: [frame, id], direction: 0, alive: 1, + ...(recordSides ? { side: id === 1 + ? (frame < 300 || (frame >= 900 && frame < 1200) ? "EAST" : "WEST") + : (frame < 300 ? "WEST" : "EAST") } as const : {}), + }); + const getChunk = vi.fn(async (_filename: string, index: number) => buffer(PbChunk.encode(PbChunk.fromPartial({ + startFrame: index * 300, frameCount: 300, + frames: Array.from({ length: 300 }, (_, offset) => { + const frameNum = index * 300 + offset; + return { frameNum, entities: [1, 2].map((entityId) => { + const current = state(entityId, frameNum); + return { entityId, posX: current.position[0], posY: current.position[1], alive: 1, side: current.side ?? "" }; + }) }; + }), + })).finish())); + const api = { + getWorldConfig: vi.fn(async () => ({ worldName: "altis", worldSize: 30720, minZoom: 0, maxZoom: 6 })), + getManifest: vi.fn(async () => buffer(PbManifest.encode(raw).finish())), + getChunk, + } as unknown as ApiClient; + const rec: Recording = { + id: "42", filename: "faction-changes", worldName: "altis", missionName: "Faction changes", + storageFormat: "protobuf", date: "2026-10-17", missionDuration: 1500, + }; + const renderer = new MockRenderer(); + const engine = new PlaybackEngine(renderer); + engines.push(engine); + const markerManager = new MarkerManager(renderer); + return { api, rec, engine, markerManager, getChunk, raw, state }; +} + +function combat(engine: PlaybackEngine): HitKilledEvent[] { + return engine.eventManager.getAll().filter((event): event is HitKilledEvent => event instanceof HitKilledEvent); +} + +describe("converted recording event-time factions", () => { + it("uses an already-loaded chunk when resolving engine references directly", async () => { + const { api, engine } = fixture(); + const chunks = new ChunkManager(new ProtobufDecoder(), api); + const manifest = await chunks.loadManifest("faction-changes"); + await chunks.loadChunk(0); + engine.loadRecording(manifest, chunks); + const first = combat(engine).find((event) => event.frameNum === 1)!; + expect(engine.getStateAt(1, 1)?.side).toBe("EAST"); + expect(first.isFriendlyFire()).toBe(false); + expect(first.causerKillScore).toBe(1); + }); + + it("resolves hits and kills outside the initial chunk before playback is ready", async () => { + const { api, rec, engine, markerManager, getChunk } = fixture(); + await loadRecording(api, engine, markerManager, rec); + + expect(engine.entityManager.getEntity(1)?.positions).toBeNull(); + const kills = combat(engine).filter((event) => event.type === "killed"); + expect(kills.map((event) => [event.causerSide, event.victimSide, event.isFriendlyFire(), event.causerKillScore])) + .toEqual([ + ["EAST", "WEST", false, 1], + ["WEST", "EAST", false, 2], + ["EAST", "EAST", true, 1], + ["WEST", "EAST", false, 2], + ]); + expect(combat(engine).find((event) => event.type === "hit")?.isFriendlyFire()).toBe(false); + const shooter = engine.entityManager.getEntity(1) as Unit; + expect([shooter.killCount, shooter.teamKillCount]).toEqual([4, 1]); + expect(engine.eventManager.getKillDeathCounts(301).teamKills.get(1)).toBeUndefined(); + expect(engine.eventManager.getKillDeathCounts(1499).teamKills.get(1)).toBe(1); + // Four distinct event chunks overflow the real three-chunk cache. The + // event sides remain correct even though chunk 1 has already been evicted. + expect(engine.getStateAt(1, 301)).toBeNull(); + expect(engine.getStateAt(1, 0)?.side).toBe("EAST"); + expect(getChunk.mock.calls.map((call) => call[1])).not.toContain(2); + }); + + it("matches dense JSON results and retains the spawn-side fallback for older protobuf files", async () => { + for (const recordSides of [true, false]) { + const { api, rec, engine, markerManager, raw, state } = fixture(recordSides); + await loadRecording(api, engine, markerManager, rec); + const manifest = new ProtobufDecoder().decodeManifest(buffer(PbManifest.encode(raw).finish())); + manifest.chunkCount = 0; + for (const entity of manifest.entities) { + entity.positions = Array.from({ length: 1500 }, (_, frame) => state(entity.id, frame)); + } + const dense = new PlaybackEngine(new MockRenderer()); + engines.push(dense); + dense.loadRecording(manifest); + expect(combat(engine).map((event) => [event.causerSide, event.victimSide, event.causerKillScore])) + .toEqual(combat(dense).map((event) => [event.causerSide, event.victimSide, event.causerKillScore])); + if (!recordSides) expect(combat(engine).every((event) => event.isFriendlyFire())).toBe(true); + } + }); + + it("reports an unavailable event chunk instead of publishing spawn-side classifications", async () => { + const { api, rec, engine, markerManager, getChunk } = fixture(); + const original = getChunk.getMockImplementation()!; + getChunk.mockImplementation(async (filename, index) => { + if (index === 3) throw new Error("event chunk unavailable"); + return original(filename, index); + }); + await expect(loadRecording(api, engine, markerManager, rec)).rejects.toThrow("event chunk unavailable"); + expect(engine.eventManager.getAll()).toEqual([]); + }); + + it("loads only the initial chunk when the recording has no combat events", async () => { + const { api, rec, engine, markerManager, getChunk, raw } = fixture(); + raw.events = []; + await loadRecording(api, engine, markerManager, rec); + expect(getChunk.mock.calls.map((call) => call[1])).toEqual([0]); + }); +}); diff --git a/ui/src/pages/recording-playback/__tests__/loadRecording.test.ts b/ui/src/pages/recording-playback/__tests__/loadRecording.test.ts index 53aee1a8b..c61dbd693 100644 --- a/ui/src/pages/recording-playback/__tests__/loadRecording.test.ts +++ b/ui/src/pages/recording-playback/__tests__/loadRecording.test.ts @@ -294,7 +294,7 @@ describe("loadRecording", () => { const rec = makeRecording({ storageFormat: "protobuf" }); await loadRecording(api, engine, markerManager, rec); - expect(engine.loadRecording).toHaveBeenCalledWith(manifest, mockChunkMgr); + expect(engine.loadRecording).toHaveBeenCalledWith(manifest, mockChunkMgr, new Map()); }); it("returns correct LoadResult for protobuf path", async () => { diff --git a/ui/src/pages/recording-playback/__tests__/useRenderBridge.test.tsx b/ui/src/pages/recording-playback/__tests__/useRenderBridge.test.tsx index 7a44b37ff..36b5bc493 100644 --- a/ui/src/pages/recording-playback/__tests__/useRenderBridge.test.tsx +++ b/ui/src/pages/recording-playback/__tests__/useRenderBridge.test.tsx @@ -7,6 +7,8 @@ import { useRenderBridge } from "../useRenderBridge"; import { setLeftPanelVisible } from "../shortcuts"; import { unitDef, vehicleDef, makeManifest, hitEvent } from "./testHelpers"; import type { RendererEvent } from "../../../renderers/renderer.types"; +import type { EventDef, WorldConfig } from "../../../data/types"; +import * as dem from "../../../playback/radioRange/demGrid"; /** * Extends MockRenderer with a fire() method to programmatically @@ -38,6 +40,38 @@ function flush(): Promise { return new Promise((resolve) => queueMicrotask(resolve)); } +const radioEvents = (): EventDef[] => [ + ...[1, 2].map((unitId): EventDef => ({ + frameNum: 0, type: "radioSnapshot", payload: { + unitId, radios: [{ class: "TFAR_anprc152", mod: "TFAR", type: "SW", channel: 1, + frequency: 100, code: "", rangeMeters: 1000, additional: false }], + }, + })), + { frameNum: 0, type: "radioTransmission", payload: { + unitId: 1, radio: "TFAR_anprc152", type: "SW", action: "Start", channel: 1, + additional: false, frequency: 100, code: "", + } }, +]; + +const radioWorld: WorldConfig = { + worldName: "altis", worldSize: 1000, hasDem: true, minZoom: 0, maxZoom: 6, +}; + +function ridge(): dem.DemGrid { + const grid = dem.makeFlatDem(1000); + for (let row = 0; row < grid.rows; row++) { + for (const col of [4, 5, 6]) grid.data[row * grid.cols + col] = 1000; + } + return grid; +} + +function loadRadioRecording(engine: PlaybackEngine): void { + engine.loadRecording(makeManifest([ + unitDef({ id: 1, positions: [{ position: [100, 100, 0], direction: 0, alive: 1 }] }), + unitDef({ id: 2, positions: [{ position: [900, 100, 0], direction: 0, alive: 1 }] }), + ], radioEvents(), 10)); +} + describe("useRenderBridge", () => { beforeEach(() => { setLeftPanelVisible(true); @@ -47,6 +81,64 @@ describe("useRenderBridge", () => { vi.restoreAllMocks(); }); + it("loads terrain when asynchronous startup resolves the world and redraws blocked transmissions", async () => { + const { engine, renderer, markerManager } = createTestSetup(); + let resolveGrid!: (grid: dem.DemGrid) => void; + const loadDem = vi.spyOn(dem, "loadWorldDem").mockReturnValue(new Promise((resolve) => { resolveGrid = resolve; })); + const lines = vi.spyOn(renderer, "addLine"); + const dispose = createRoot((dispose) => { + useRenderBridge(engine, renderer, markerManager); + return dispose; + }); + try { + await flush(); + expect(loadDem).not.toHaveBeenCalled(); + // The bridge already exists when the async recording loader publishes its world. + engine.setWorldConfig(radioWorld); + loadRadioRecording(engine); + await flush(); + expect(loadDem).toHaveBeenCalledOnce(); + expect(loadDem).toHaveBeenCalledWith(expect.objectContaining({ worldName: "altis", hasDem: true })); + expect(lines.mock.calls.slice(-1)[0]?.[2].color).toBe("#4ade80"); + resolveGrid(ridge()); + await flush(); + await flush(); + expect(lines.mock.calls.slice(-1)[0]?.[2].color).toBe("#f87171"); + } finally { dispose(); engine.dispose(); } + }); + + it("ignores terrain from a previous world and clears terrain when the world is removed", async () => { + const { engine, renderer, markerManager } = createTestSetup(); + let resolveOld!: (grid: dem.DemGrid) => void; + let resolveCurrent!: (grid: dem.DemGrid) => void; + vi.spyOn(dem, "loadWorldDem") + .mockReturnValueOnce(new Promise((resolve) => { resolveOld = resolve; })) + .mockReturnValueOnce(new Promise((resolve) => { resolveCurrent = resolve; })); + const lines = vi.spyOn(renderer, "addLine"); + const dispose = createRoot((dispose) => { + useRenderBridge(engine, renderer, markerManager); + return dispose; + }); + try { + engine.setWorldConfig(radioWorld); + loadRadioRecording(engine); + await flush(); + engine.setWorldConfig({ ...radioWorld, worldName: "stratis" }); + await flush(); + resolveCurrent(ridge()); + await flush(); + await flush(); + expect(lines.mock.calls.slice(-1)[0]?.[2].color).toBe("#f87171"); + resolveOld(dem.makeFlatDem(1000)); + await flush(); + await flush(); + expect(lines.mock.calls.slice(-1)[0]?.[2].color).toBe("#f87171"); + engine.setWorldConfig(undefined); + await flush(); + expect(lines.mock.calls.slice(-1)[0]?.[2].color).toBe("#4ade80"); + } finally { dispose(); engine.dispose(); } + }); + it("registers a dragstart listener on the renderer", () => { const { engine, renderer, markerManager } = createTestSetup(); engine.loadRecording(makeManifest([unitDef()])); diff --git a/ui/src/pages/recording-playback/loadRecording.ts b/ui/src/pages/recording-playback/loadRecording.ts index f032a1c13..1fecc48df 100644 --- a/ui/src/pages/recording-playback/loadRecording.ts +++ b/ui/src/pages/recording-playback/loadRecording.ts @@ -4,6 +4,7 @@ import { JsonDecoder } from "../../data/decoders/jsonDecoder"; import { ProtobufDecoder } from "../../data/decoders/protobufDecoder"; import type { DecoderStrategy } from "../../data/decoders/decoder.interface"; import { ChunkManager } from "../../data/chunkManager"; +import { loadCombatEventSides } from "../../playback/combatEventSides"; import type { PlaybackEngine } from "../../playback/engine"; import type { MarkerManager } from "../../playback/markerManager"; @@ -38,8 +39,11 @@ export async function loadRecording( decoder = new ProtobufDecoder(); const chunkMgr = new ChunkManager(decoder, api); manifest = await chunkMgr.loadManifest(filename); + const eventSides = await loadCombatEventSides(manifest, chunkMgr); + // Combat-event reads may evict the starting chunk. Restore it before + // publishing the recording so the map and scores are ready together. await chunkMgr.loadChunk(0); - engine.loadRecording(manifest, chunkMgr); + engine.loadRecording(manifest, chunkMgr, eventSides); } else { decoder = new JsonDecoder(); const buffer = await api.getRecordingData(filename); diff --git a/ui/src/pages/recording-playback/useRenderBridge.ts b/ui/src/pages/recording-playback/useRenderBridge.ts index c0bc2fed9..e40caae1e 100644 --- a/ui/src/pages/recording-playback/useRenderBridge.ts +++ b/ui/src/pages/recording-playback/useRenderBridge.ts @@ -448,9 +448,11 @@ export function useRenderBridge( // the comms tab or a profile card has already pulled it. const [commsDem, setCommsDem] = createSignal(null); createEffect(() => { - if (commsDem()) return; const world = engine.worldConfig; + setCommsDem(null); if (!world) return; + let active = true; + onCleanup(() => { active = false; }); void loadWorldDem({ tileBaseUrl: world.tileBaseUrl, worldName: world.worldName, @@ -458,8 +460,8 @@ export function useRenderBridge( hasDem: world.hasDem, hasHeightmap: world.hasHeightmap, }).then((grid) => { - if (grid) setCommsDem(grid); - }); + if (active) setCommsDem(grid ?? null); + }).catch((error) => console.error("Failed to load transmission terrain", error)); }); // Pairing folds every Start/Stop in the recording into spans. That depends on diff --git a/ui/src/playback/combatEventSides.ts b/ui/src/playback/combatEventSides.ts new file mode 100644 index 000000000..c37360b00 --- /dev/null +++ b/ui/src/playback/combatEventSides.ts @@ -0,0 +1,39 @@ +import type { ChunkManager } from "../data/chunkManager"; +import type { EventDef, Manifest, Side } from "../data/types"; + +/** Event frame -> unit id -> recorded side. Independent of the chunk LRU. */ +export type CombatEventSides = ReadonlyMap>; + +/** Read only combat-event frames, retaining their sides before each chunk can be evicted. */ +export async function loadCombatEventSides( + manifest: Manifest, + chunks: ChunkManager, +): Promise { + const chunkSize = manifest.chunkSize || 300; + const byChunk = new Map>(); + for (const event of manifest.events) { + if (event.type !== "hit" && event.type !== "killed") continue; + const index = Math.floor(event.frameNum / chunkSize); + if (index < 0 || index >= manifest.chunkCount) continue; + const events = byChunk.get(index) ?? []; + events.push(event); + byChunk.set(index, events); + } + + const entities = new Map(manifest.entities.map((entity) => [entity.id, entity])); + const sides = new Map>(); + for (const [index, events] of [...byChunk].sort((a, b) => a[0] - b[0])) { + const chunk = await chunks.loadChunk(index); + for (const event of events) { + const atFrame = sides.get(event.frameNum) ?? new Map(); + for (const id of [event.victimId, event.causedById]) { + const entity = entities.get(id); + if (!entity || event.frameNum < entity.startFrame || event.frameNum > entity.endFrame) continue; + const side = chunk.entities.get(id)?.[event.frameNum - index * chunkSize]?.side; + if (side) atFrame.set(id, side); + } + sides.set(event.frameNum, atFrame); + } + } + return sides; +} diff --git a/ui/src/playback/engine.ts b/ui/src/playback/engine.ts index c9a60f60b..33b014a9a 100644 --- a/ui/src/playback/engine.ts +++ b/ui/src/playback/engine.ts @@ -16,6 +16,7 @@ import type { GameEvent } from "./events/gameEvent"; import type { CounterState } from "./events/counterEvent"; import { EntityManager } from "./entityManager"; import { EventManager } from "./eventManager"; +import type { CombatEventSides } from "./combatEventSides"; import { HitKilledEvent } from "./events/hitKilledEvent"; import { ConnectEvent } from "./events/connectEvent"; import { EndMissionEvent } from "./events/endMissionEvent"; @@ -133,7 +134,8 @@ export class PlaybackEngine { private renderer: MapRenderer; private chunkManager: ChunkManager | null = null; private manifest: Manifest | null = null; - private _worldConfig: WorldConfig | undefined; + private _worldConfig: Accessor; + private _setWorldConfig: (world: WorldConfig | undefined) => void; // ─── Signals (reactive state) ─── private _currentFrame: Accessor; @@ -177,6 +179,10 @@ export class PlaybackEngine { constructor(renderer: MapRenderer) { this.renderer = renderer; + const [worldConfig, setWorldConfig] = createSignal(); + this._worldConfig = worldConfig; + this._setWorldConfig = setWorldConfig; + const [currentFrame, setCurrentFrame] = createSignal(0); this._currentFrame = currentFrame; this._setCurrentFrame = setCurrentFrame; @@ -256,10 +262,10 @@ export class PlaybackEngine { return this._playerSnapshotsVersion; } get worldConfig(): WorldConfig | undefined { - return this._worldConfig; + return this._worldConfig(); } setWorldConfig(world: WorldConfig | undefined): void { - this._worldConfig = world; + this._setWorldConfig(world); } get radioPropagation(): TfarRadioPropagation { return resolveTfarPropagation(this.manifest?.radioPropagation); @@ -359,8 +365,14 @@ export class PlaybackEngine { /** * Populate entities and events from a manifest, and wire up the chunk manager. + * Converted recordings supply event-time sides sampled before publication, + * so combat classification does not depend on which chunks remain cached. */ - loadRecording(manifest: Manifest, chunkManager?: ChunkManager | null): void { + loadRecording( + manifest: Manifest, + chunkManager?: ChunkManager | null, + eventSides?: CombatEventSides, + ): void { // Reset state this.clearTimer(); this._setIsPlaying(false); @@ -429,7 +441,9 @@ export class PlaybackEngine { } // Resolve entity references on hit/killed events - this.eventManager.resolveReferences(this.entityManager); + this.eventManager.resolveReferences(this.entityManager, (id, frame) => + eventSides?.get(frame)?.get(id) ?? this.getStateAt(id, frame)?.side, + ); // Build counter state from counter events this.buildCounterState(manifest.events); @@ -604,7 +618,7 @@ export class PlaybackEngine { this.eventManager.clear(); this.chunkManager = null; this.manifest = null; - this._worldConfig = undefined; + this._setWorldConfig(undefined); } // ─── Playback loop ─── diff --git a/ui/src/playback/eventManager.ts b/ui/src/playback/eventManager.ts index 627fa7c12..b8cc5f4b6 100644 --- a/ui/src/playback/eventManager.ts +++ b/ui/src/playback/eventManager.ts @@ -15,7 +15,7 @@ import { } from "./events/zeusEvents"; import { ExplosionEvent, RadioTransmissionEvent } from "./events/supportEvents"; import { applySnapshotDiff, isSnapshotDiff } from "../data/snapshotDiff"; -import type { PlayerSnapshotPayload, PlayerSnapshotType, WeatherPayload } from "../data/types"; +import type { PlayerSnapshotPayload, PlayerSnapshotType, Side, WeatherPayload } from "../data/types"; import { ZEUS_RC_KILL_GRACE_FRAMES, type ZeusCameraSample, type ZeusEntityInfo, type ZeusFrameState } from "./zeus"; /** @@ -415,12 +415,17 @@ export class EventManager { /** * Resolve entity references on HitKilledEvent instances. * Populates names, sides at the event frame, and computes kill counts. + * `sideAt` supplies chunked state; missing per-frame sides retain the dense + * position/spawn-side fallback used by older recordings. * * Kill score formula (matching old frontend): * killCount - (teamKillCount * 2) * Only "killed" events with a Unit victim (not Vehicle) increment counts. */ - resolveReferences(entityManager: EntityManager): void { + resolveReferences( + entityManager: EntityManager, + sideAt?: (unitId: number, frame: number) => Side | undefined, + ): void { // First pass: resolve names/sides for (const event of this.events) { if (event instanceof HitKilledEvent) { @@ -429,7 +434,7 @@ export class EventManager { event.victimName = victim.name; event.victimIsVehicle = victim instanceof Vehicle; if (victim instanceof Unit) { - event.victimSide = victim.sideAtFrame(event.frameNum); + event.victimSide = sideAt?.(victim.id, event.frameNum) ?? victim.sideAtFrame(event.frameNum); } } @@ -437,7 +442,7 @@ export class EventManager { if (causer) { event.causerName = causer.name; if (causer instanceof Unit) { - event.causerSide = causer.sideAtFrame(event.frameNum); + event.causerSide = sideAt?.(causer.id, event.frameNum) ?? causer.sideAtFrame(event.frameNum); } } } From 72be422821c54fe35d93289a88d9d65e5cba7280 Mon Sep 17 00:00:00 2001 From: Yethe Samartaka Date: Sat, 17 Oct 2026 20:56:58 +0200 Subject: [PATCH 30/39] Keep combat sampling lazy and load radio sidecars for map transmissions --- ui/src/data/chunkManager.ts | 4 +- .../convertedCombat.integration.test.ts | 53 ++++++- .../__tests__/loadRecording.test.ts | 2 +- .../transmissionSidecars.integration.test.tsx | 132 ++++++++++++++++++ .../components/EventsTab.tsx | 10 +- .../components/StatsTab.tsx | 7 +- .../pages/recording-playback/loadRecording.ts | 6 +- .../recording-playback/useRenderBridge.ts | 22 ++- ui/src/playback/__tests__/engine.test.ts | 36 +++++ ui/src/playback/combatEventSides.ts | 62 ++++---- ui/src/playback/engine.ts | 67 +++++++-- ui/src/playback/eventManager.ts | 7 + 12 files changed, 344 insertions(+), 64 deletions(-) create mode 100644 ui/src/pages/recording-playback/__tests__/transmissionSidecars.integration.test.tsx diff --git a/ui/src/data/chunkManager.ts b/ui/src/data/chunkManager.ts index 3b65ecf9f..a74446339 100644 --- a/ui/src/data/chunkManager.ts +++ b/ui/src/data/chunkManager.ts @@ -124,8 +124,8 @@ export class ChunkManager { /** * Load one player's snapshot sidecar. Snapshots are the bulk of a recording's - * event stream but are read one unit at a time, so they are fetched only when - * that player's profile is opened, and each unit is fetched at most once. + * event stream. Profiles load one unit at a time; the transmission overlay + * and Comms tab load all radio owners. Each unit is fetched at most once. * * Returns an empty list for a unit the manifest does not index — either the * player has no snapshots, or this is a recording whose snapshots are still diff --git a/ui/src/pages/recording-playback/__tests__/convertedCombat.integration.test.ts b/ui/src/pages/recording-playback/__tests__/convertedCombat.integration.test.ts index b04385c8f..8e6b8de5c 100644 --- a/ui/src/pages/recording-playback/__tests__/convertedCombat.integration.test.ts +++ b/ui/src/pages/recording-playback/__tests__/convertedCombat.integration.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { createMemo, createRoot } from "solid-js"; import type { ApiClient } from "../../../data/apiClient"; import { ChunkManager } from "../../../data/chunkManager"; import { ProtobufDecoder } from "../../../data/decoders/protobufDecoder"; @@ -87,9 +88,15 @@ describe("converted recording event-time factions", () => { expect(first.causerKillScore).toBe(1); }); - it("resolves hits and kills outside the initial chunk before playback is ready", async () => { + it("opens with chunk zero and retains event-time factions as later chunks arrive", async () => { const { api, rec, engine, markerManager, getChunk } = fixture(); await loadRecording(api, engine, markerManager, rec); + expect(getChunk.mock.calls.map((call) => call[1])).toEqual([0]); + + for (const frame of [301, 901, 1201, 0]) { + engine.seekTo(frame); + await vi.waitFor(() => expect(engine.getStateAt(1, frame)).not.toBeNull()); + } expect(engine.entityManager.getEntity(1)?.positions).toBeNull(); const kills = combat(engine).filter((event) => event.type === "killed"); @@ -116,6 +123,10 @@ describe("converted recording event-time factions", () => { for (const recordSides of [true, false]) { const { api, rec, engine, markerManager, raw, state } = fixture(recordSides); await loadRecording(api, engine, markerManager, rec); + for (const frame of [301, 901, 1201]) { + engine.seekTo(frame); + await vi.waitFor(() => expect(engine.getStateAt(1, frame)).not.toBeNull()); + } const manifest = new ProtobufDecoder().decodeManifest(buffer(PbManifest.encode(raw).finish())); manifest.chunkCount = 0; for (const entity of manifest.entities) { @@ -130,15 +141,49 @@ describe("converted recording event-time factions", () => { } }); - it("reports an unavailable event chunk instead of publishing spawn-side classifications", async () => { + it("opens early playback even when a distant combat chunk is unavailable", async () => { const { api, rec, engine, markerManager, getChunk } = fixture(); const original = getChunk.getMockImplementation()!; getChunk.mockImplementation(async (filename, index) => { if (index === 3) throw new Error("event chunk unavailable"); return original(filename, index); }); - await expect(loadRecording(api, engine, markerManager, rec)).rejects.toThrow("event chunk unavailable"); - expect(engine.eventManager.getAll()).toEqual([]); + await expect(loadRecording(api, engine, markerManager, rec)).resolves.toBeDefined(); + expect(getChunk.mock.calls.map((call) => call[1])).toEqual([0]); + expect(engine.getStateAt(1, 0)).not.toBeNull(); + expect(combat(engine)[0].isFriendlyFire()).toBe(false); + const errors = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + engine.seekTo(901); + await vi.waitFor(() => expect(errors).toHaveBeenCalled()); + engine.seekTo(0); + expect(engine.getStateAt(1, 0)).not.toBeNull(); + } finally { errors.mockRestore(); } + }); + + it("refreshes classification and scores at a paused frame without counting a kill twice", async () => { + const { api, rec, engine, markerManager } = fixture(); + await loadRecording(api, engine, markerManager, rec); + const dispose = createRoot((dispose) => { + const friendly = createMemo(() => { + engine.eventReferencesVersion(); + return combat(engine).find((event) => event.frameNum === 301)!.isFriendlyFire(); + }); + return { dispose, friendly }; + }); + try { + engine.seekTo(301); + await vi.waitFor(() => expect(dispose.friendly()).toBe(false)); + const shooter = engine.entityManager.getEntity(1) as Unit; + const counts = [shooter.killCount, shooter.teamKillCount, shooter.deathCount]; + for (const frame of [601, 901, 1201, 0, 301]) { + engine.seekTo(frame); + await vi.waitFor(() => expect(engine.getStateAt(1, frame)).not.toBeNull()); + } + expect(shooter.killCount).toBe(counts[0]); + expect(shooter.deathCount).toBe(counts[2]); + expect(shooter.teamKillCount).toBe(1); + } finally { dispose.dispose(); } }); it("loads only the initial chunk when the recording has no combat events", async () => { diff --git a/ui/src/pages/recording-playback/__tests__/loadRecording.test.ts b/ui/src/pages/recording-playback/__tests__/loadRecording.test.ts index c61dbd693..53aee1a8b 100644 --- a/ui/src/pages/recording-playback/__tests__/loadRecording.test.ts +++ b/ui/src/pages/recording-playback/__tests__/loadRecording.test.ts @@ -294,7 +294,7 @@ describe("loadRecording", () => { const rec = makeRecording({ storageFormat: "protobuf" }); await loadRecording(api, engine, markerManager, rec); - expect(engine.loadRecording).toHaveBeenCalledWith(manifest, mockChunkMgr, new Map()); + expect(engine.loadRecording).toHaveBeenCalledWith(manifest, mockChunkMgr); }); it("returns correct LoadResult for protobuf path", async () => { diff --git a/ui/src/pages/recording-playback/__tests__/transmissionSidecars.integration.test.tsx b/ui/src/pages/recording-playback/__tests__/transmissionSidecars.integration.test.tsx new file mode 100644 index 000000000..30ef7d2dc --- /dev/null +++ b/ui/src/pages/recording-playback/__tests__/transmissionSidecars.integration.test.tsx @@ -0,0 +1,132 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createRoot } from "solid-js"; +import type { ApiClient } from "../../../data/apiClient"; +import { ChunkManager } from "../../../data/chunkManager"; +import { ProtobufDecoder } from "../../../data/decoders/protobufDecoder"; +import { + Chunk as PbChunk, Manifest as PbManifest, PlayerSnapshotSeries, + EntityType, Side, +} from "../../../data/decoders/generated/ocap.pb"; +import { PlaybackEngine } from "../../../playback/engine"; +import { MarkerManager } from "../../../playback/markerManager"; +import { MockRenderer } from "../../../renderers/mockRenderer"; +import { useRenderBridge } from "../useRenderBridge"; + +const cleanup: Array<() => void> = []; +afterEach(() => { for (const dispose of cleanup.splice(0).reverse()) dispose(); vi.restoreAllMocks(); }); +const buffer = (bytes: Uint8Array): ArrayBuffer => new Uint8Array(bytes).buffer; + +async function setup(transmit = true) { + const manifest = PbManifest.fromPartial({ + version: 1, worldName: "altis", missionName: "Sidecar radios", endFrame: 10, + chunkSize: 300, chunkCount: 1, captureDelayMs: 1000, snapshotUnitIds: [1, 2, 3], + entities: [1, 2, 3].map((id) => ({ id, name: `Unit ${id}`, type: EntityType.ENTITY_TYPE_UNIT, + side: Side.SIDE_WEST, isPlayer: true, startFrame: 0, endFrame: 10 })), + events: transmit ? [{ frameNum: 0, type: "radioTransmission", message: JSON.stringify({ + unitId: 1, radio: "TFAR_anprc152", type: "SW", action: "Start", channel: 1, + additional: false, frequency: 100, code: "", + }) }] : [], + }); + const pending = new Map void; reject: (error: Error) => void }>(); + const getPlayerSnapshots = vi.fn((_filename: string, id: number) => new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + })); + const api = { + getManifest: async () => buffer(PbManifest.encode(manifest).finish()), + getChunk: vi.fn(async () => buffer(PbChunk.encode(PbChunk.fromPartial({ + startFrame: 0, frameCount: 11, frames: [{ frameNum: 0, + entities: [1, 2, 3].map((entityId) => ({ entityId, posX: entityId * 100, posY: 100, alive: 1 })), + }], + })).finish())), + getPlayerSnapshots, + } as unknown as ApiClient; + const chunks = new ChunkManager(new ProtobufDecoder(), api); + const decoded = await chunks.loadManifest("sidecar-radios"); + await chunks.loadChunk(0); + const renderer = new MockRenderer(); + const engine = new PlaybackEngine(renderer); + cleanup.push(() => engine.dispose()); + engine.loadRecording(decoded, chunks); + const lines = vi.spyOn(renderer, "addLine"); + const mount = () => cleanup.push(createRoot((dispose) => { + useRenderBridge(engine, renderer, new MarkerManager(renderer)); + return dispose; + })); + const finish = (id: number) => pending.get(id)!.resolve(buffer(PlayerSnapshotSeries.encode(PlayerSnapshotSeries.fromPartial({ + unitId: id, events: [{ frameNum: 0, type: "radioSnapshot", message: JSON.stringify({ + unitId: id, radios: [{ class: "TFAR_anprc152", mod: "TFAR", type: "SW", channel: 1, + frequency: 100, code: "", rangeMeters: 1000, additional: false }], + }) }], + })).finish())); + return { engine, chunks, decoded, renderer, lines, mount, getPlayerSnapshots, finish, pending }; +} + +describe("map transmissions with lazy radio sidecars", () => { + it("requests all radio owners without panel visits and redraws at the same paused frame", async () => { + const { engine, lines, mount, getPlayerSnapshots, finish } = await setup(); + mount(); + await vi.waitFor(() => expect(getPlayerSnapshots).toHaveBeenCalledTimes(3)); + expect(engine.eventManager.getPlayerSnapshotUnitIds()).toEqual([]); + finish(1); + finish(2); + await vi.waitFor(() => expect(lines).toHaveBeenCalled()); + finish(3); + await vi.waitFor(() => expect(engine.playerSnapshotsVersion()).toBe(3)); + expect(engine.currentFrame()).toBe(0); + expect(lines.mock.calls.slice(-2).map((call) => call[1][0])).toEqual([200, 300]); + expect(lines.mock.calls.slice(-2).every((call) => call[2].color === "#4ade80")).toBe(true); + await engine.ensureAllPlayerSnapshots(); + expect(getPlayerSnapshots).toHaveBeenCalledTimes(3); + }); + + it("completes the receiver set even if a profile already requested only one sidecar", async () => { + const { engine, lines, mount, getPlayerSnapshots, finish } = await setup(); + const profileLoad = engine.ensurePlayerSnapshots(2); + finish(2); + await profileLoad; + mount(); + await vi.waitFor(() => expect(getPlayerSnapshots).toHaveBeenCalledTimes(3)); + finish(1); + finish(3); + await vi.waitFor(() => expect(engine.playerSnapshotsVersion()).toBe(3)); + expect(new Set(lines.mock.calls.slice(-2).map((call) => call[1][0]))).toEqual(new Set([200, 300])); + expect(getPlayerSnapshots.mock.calls.filter((call) => call[1] === 2)).toHaveLength(1); + }); + + it("keeps playback and available receiver lines usable when one sidecar fails", async () => { + const { engine, lines, mount, getPlayerSnapshots, finish, pending } = await setup(); + const errors = vi.spyOn(console, "error").mockImplementation(() => {}); + mount(); + await vi.waitFor(() => expect(getPlayerSnapshots).toHaveBeenCalledTimes(3)); + finish(1); + pending.get(2)!.reject(new Error("sidecar unavailable")); + finish(3); + await vi.waitFor(() => expect(lines).toHaveBeenCalled()); + expect(lines.mock.calls.slice(-1)[0][1][0]).toBe(300); + expect(engine.getStateAt(1, 0)).not.toBeNull(); + expect(errors).toHaveBeenCalled(); + }); + + it("does not request sidecars when there are no transmissions", async () => { + const { mount, getPlayerSnapshots } = await setup(false); + mount(); + await Promise.resolve(); + expect(getPlayerSnapshots).not.toHaveBeenCalled(); + }); + + it("clears the overlay when replaced by a recording with the same duration", async () => { + const { engine, renderer, lines, decoded, mount, getPlayerSnapshots, finish } = await setup(); + const removed = vi.spyOn(renderer, "removeLine"); + mount(); + await vi.waitFor(() => expect(getPlayerSnapshots).toHaveBeenCalledTimes(3)); + for (const id of [1, 2, 3]) finish(id); + await vi.waitFor(() => expect(engine.playerSnapshotsVersion()).toBe(3)); + expect(lines.mock.calls.length - removed.mock.calls.length).toBe(2); + const entities = decoded.entities.map((entity) => ({ + ...entity, positions: [engine.getStateAt(entity.id, 0)!], + })); + engine.loadRecording({ ...decoded, entities, events: [], snapshotUnitIds: [], chunkCount: 0 }); + await vi.waitFor(() => expect(lines.mock.calls.length - removed.mock.calls.length).toBe(0)); + expect(getPlayerSnapshots).toHaveBeenCalledTimes(3); + }); +}); diff --git a/ui/src/pages/recording-playback/components/EventsTab.tsx b/ui/src/pages/recording-playback/components/EventsTab.tsx index 53b532f46..b894bb65b 100644 --- a/ui/src/pages/recording-playback/components/EventsTab.tsx +++ b/ui/src/pages/recording-playback/components/EventsTab.tsx @@ -149,6 +149,7 @@ export function EventsTab(): JSX.Element { }; const filteredEvents = createMemo(() => { + engine.eventReferencesVersion(); const all = engine.activeEvents(); const text = filterText().toLowerCase(); const f = filters(); @@ -241,6 +242,11 @@ export function EventsTab(): JSX.Element { return formatElapsedTime(frameNum, engine.captureDelayMs()); }; + const combatSide = (event: HitKilledEvent, who: "victim" | "causer"): string | undefined => { + engine.eventReferencesVersion(); + return who === "victim" ? event.victimSide : event.causerSide; + }; + return ( <> {/* Filter bar */} @@ -277,7 +283,7 @@ export function EventsTab(): JSX.Element { {event instanceof HitKilledEvent ? ( <> - + {event.victimName ?? "Unknown"} {event.victimId === event.causedById ? ( @@ -292,7 +298,7 @@ export function EventsTab(): JSX.Element { {"\u2190"} {" "} - + {event.causerName ?? "Unknown"} diff --git a/ui/src/pages/recording-playback/components/StatsTab.tsx b/ui/src/pages/recording-playback/components/StatsTab.tsx index 06db89602..6e6d8449b 100644 --- a/ui/src/pages/recording-playback/components/StatsTab.tsx +++ b/ui/src/pages/recording-playback/components/StatsTab.tsx @@ -123,9 +123,10 @@ export function StatsTab(): JSX.Element { }); // Frame-aware kill/death counts - const killDeathCounts = createMemo(() => - engine.eventManager.getKillDeathCounts(engine.currentFrame()), - ); + const killDeathCounts = createMemo(() => { + engine.eventReferencesVersion(); + return engine.eventManager.getKillDeathCounts(engine.currentFrame()); + }); const sideStats = createMemo((): SideStats[] => { const snaps = engine.entitySnapshots(); diff --git a/ui/src/pages/recording-playback/loadRecording.ts b/ui/src/pages/recording-playback/loadRecording.ts index 1fecc48df..f032a1c13 100644 --- a/ui/src/pages/recording-playback/loadRecording.ts +++ b/ui/src/pages/recording-playback/loadRecording.ts @@ -4,7 +4,6 @@ import { JsonDecoder } from "../../data/decoders/jsonDecoder"; import { ProtobufDecoder } from "../../data/decoders/protobufDecoder"; import type { DecoderStrategy } from "../../data/decoders/decoder.interface"; import { ChunkManager } from "../../data/chunkManager"; -import { loadCombatEventSides } from "../../playback/combatEventSides"; import type { PlaybackEngine } from "../../playback/engine"; import type { MarkerManager } from "../../playback/markerManager"; @@ -39,11 +38,8 @@ export async function loadRecording( decoder = new ProtobufDecoder(); const chunkMgr = new ChunkManager(decoder, api); manifest = await chunkMgr.loadManifest(filename); - const eventSides = await loadCombatEventSides(manifest, chunkMgr); - // Combat-event reads may evict the starting chunk. Restore it before - // publishing the recording so the map and scores are ready together. await chunkMgr.loadChunk(0); - engine.loadRecording(manifest, chunkMgr, eventSides); + engine.loadRecording(manifest, chunkMgr); } else { decoder = new JsonDecoder(); const buffer = await api.getRecordingData(filename); diff --git a/ui/src/pages/recording-playback/useRenderBridge.ts b/ui/src/pages/recording-playback/useRenderBridge.ts index e40caae1e..d8a749a8b 100644 --- a/ui/src/pages/recording-playback/useRenderBridge.ts +++ b/ui/src/pages/recording-playback/useRenderBridge.ts @@ -467,10 +467,9 @@ export function useRenderBridge( // Pairing folds every Start/Stop in the recording into spans. That depends on // the recording, not on the playhead, so it is computed once per recording // rather than rebuilt on each of the sixty frames a second the effect below - // runs at. `endFrame` is the signal that settles when a recording finishes - // loading, which is what makes this recompute for the next one. + // runs at. The recording version also changes for missions of equal length. const pairedTransmissions = createMemo(() => { - engine.endFrame(); + engine.recordingVersion(); return pairTransmissions( engine.eventManager.getRadioTransmissions().map((event) => ({ frameNum: event.frameNum, @@ -479,6 +478,18 @@ export function useRenderBridge( ); }); + const liveTransmissions = createMemo(() => + transmissionsAtFrame(pairedTransmissions(), engine.currentFrame()), + ); + const hasLiveTransmission = createMemo(() => liveTransmissions().length > 0); + createEffect(() => { + engine.recordingVersion(); + if (!hasLiveTransmission()) return; + // Receiver membership needs every player's radios. Share the engine's + // deduplicated sidecar loads with panels, without waiting for a panel visit. + void engine.ensureAllPlayerSnapshots(); + }); + createEffect(() => { const frame = engine.currentFrame(); const snapshots = engine.entitySnapshots(); @@ -486,11 +497,12 @@ export function useRenderBridge( for (const handle of commsLines) renderer.removeLine(handle); commsLines = []; - const live = transmissionsAtFrame(pairedTransmissions(), frame); + const live = liveTransmissions(); if (live.length === 0) return; + engine.playerSnapshotsVersion(); // Radios for everyone with a snapshot at this frame, so net membership can - // be resolved without reaching for the sidecars the comms tab loads. + // be resolved again as the overlay's own sidecar requests finish. const radiosByUnit = new Map(); for (const unitId of engine.eventManager.getPlayerSnapshotUnitIds()) { const snapshot = engine.eventManager.getPlayerSnapshots(unitId, frame).get("radioSnapshot"); diff --git a/ui/src/playback/__tests__/engine.test.ts b/ui/src/playback/__tests__/engine.test.ts index 88f03915e..9db0d2067 100644 --- a/ui/src/playback/__tests__/engine.test.ts +++ b/ui/src/playback/__tests__/engine.test.ts @@ -194,6 +194,42 @@ describe("PlaybackEngine", () => { // ─── Player snapshot sidecars ─── describe("ensurePlayerSnapshots", () => { + it("does not mix a previous recording's late sidecar into the current recording", async () => { + const oldChunks = makeMockChunkManager(); + let finishOld!: (defs: unknown[]) => void; + (oldChunks.loadPlayerSnapshots as ReturnType).mockReturnValue( + new Promise((resolve) => { finishOld = resolve; }), + ); + engine.loadRecording(makeManifest({ snapshotUnitIds: [7] }), oldChunks); + const oldLoad = engine.ensurePlayerSnapshots(7); + const currentChunks = makeMockChunkManager(); + (currentChunks.loadPlayerSnapshots as ReturnType).mockResolvedValue([ + { frameNum: 0, type: "staminaSnapshot", payload: { unitId: 7, vanilla: { stamina: 90 } } }, + ]); + engine.loadRecording(makeManifest({ snapshotUnitIds: [7] }), currentChunks); + await engine.ensurePlayerSnapshots(7); + finishOld([{ frameNum: 0, type: "staminaSnapshot", payload: { unitId: 7, vanilla: { stamina: 10 } } }]); + await oldLoad; + expect(engine.eventManager.getPlayerSnapshots(7, 0).get("staminaSnapshot")?.payload) + .toEqual({ unitId: 7, vanilla: { stamina: 90 } }); + expect(engine.playerSnapshotsVersion()).toBe(1); + }); + + it("discards sidecars that finish after the engine is disposed", async () => { + const chunks = makeMockChunkManager(); + let finish!: (defs: unknown[]) => void; + (chunks.loadPlayerSnapshots as ReturnType).mockReturnValue( + new Promise((resolve) => { finish = resolve; }), + ); + engine.loadRecording(makeManifest({ snapshotUnitIds: [7] }), chunks); + const load = engine.ensurePlayerSnapshots(7); + engine.dispose(); + finish([{ frameNum: 0, type: "staminaSnapshot", payload: { unitId: 7, vanilla: {} } }]); + await load; + expect(engine.eventManager.hasPlayerSnapshots(7)).toBe(false); + expect(engine.playerSnapshotsVersion()).toBe(0); + }); + it("loads a unit's sidecar into the event manager and folds its diffs", async () => { const cm = makeMockChunkManager(); (cm.loadPlayerSnapshots as ReturnType).mockResolvedValue([ diff --git a/ui/src/playback/combatEventSides.ts b/ui/src/playback/combatEventSides.ts index c37360b00..9f2c89b2a 100644 --- a/ui/src/playback/combatEventSides.ts +++ b/ui/src/playback/combatEventSides.ts @@ -1,39 +1,45 @@ -import type { ChunkManager } from "../data/chunkManager"; -import type { EventDef, Manifest, Side } from "../data/types"; +import type { ChunkData, EventDef, Manifest, Side } from "../data/types"; -/** Event frame -> unit id -> recorded side. Independent of the chunk LRU. */ -export type CombatEventSides = ReadonlyMap>; +/** Retain only combat-frame sides from chunks that normal playback or analysis has loaded. */ +export class CombatSideIndex { + private readonly byChunk = new Map>(); + private readonly sampled = new Set(); + private readonly sides = new Map>(); + private readonly entities: Map; + private readonly chunkSize: number; -/** Read only combat-event frames, retaining their sides before each chunk can be evicted. */ -export async function loadCombatEventSides( - manifest: Manifest, - chunks: ChunkManager, -): Promise { - const chunkSize = manifest.chunkSize || 300; - const byChunk = new Map>(); - for (const event of manifest.events) { - if (event.type !== "hit" && event.type !== "killed") continue; - const index = Math.floor(event.frameNum / chunkSize); - if (index < 0 || index >= manifest.chunkCount) continue; - const events = byChunk.get(index) ?? []; - events.push(event); - byChunk.set(index, events); + constructor(manifest: Manifest) { + this.chunkSize = manifest.chunkSize || 300; + this.entities = new Map(manifest.entities.map((entity) => [entity.id, entity])); + for (const event of manifest.events) { + if (event.type !== "hit" && event.type !== "killed") continue; + const index = Math.floor(event.frameNum / this.chunkSize); + if (index < 0 || index >= manifest.chunkCount) continue; + const events = this.byChunk.get(index) ?? []; + events.push(event); + this.byChunk.set(index, events); + } } - const entities = new Map(manifest.entities.map((entity) => [entity.id, entity])); - const sides = new Map>(); - for (const [index, events] of [...byChunk].sort((a, b) => a[0] - b[0])) { - const chunk = await chunks.loadChunk(index); + get chunkIndices(): IterableIterator { return this.byChunk.keys(); } + + sideAt = (id: number, frame: number): Side | undefined => this.sides.get(frame)?.get(id); + + /** No network I/O. Once sampled, classification survives eviction and repeated seeks. */ + sample(index: number, chunk: ChunkData): boolean { + const events = this.byChunk.get(index); + if (!events || this.sampled.has(index)) return false; for (const event of events) { - const atFrame = sides.get(event.frameNum) ?? new Map(); + const atFrame = this.sides.get(event.frameNum) ?? new Map(); for (const id of [event.victimId, event.causedById]) { - const entity = entities.get(id); + const entity = this.entities.get(id); if (!entity || event.frameNum < entity.startFrame || event.frameNum > entity.endFrame) continue; - const side = chunk.entities.get(id)?.[event.frameNum - index * chunkSize]?.side; - if (side) atFrame.set(id, side); + const side = chunk.entities.get(id)?.[event.frameNum - index * this.chunkSize]?.side; + atFrame.set(id, side ?? entity.side); } - sides.set(event.frameNum, atFrame); + this.sides.set(event.frameNum, atFrame); } + this.sampled.add(index); + return true; } - return sides; } diff --git a/ui/src/playback/engine.ts b/ui/src/playback/engine.ts index 33b014a9a..b5fea9b57 100644 --- a/ui/src/playback/engine.ts +++ b/ui/src/playback/engine.ts @@ -16,7 +16,7 @@ import type { GameEvent } from "./events/gameEvent"; import type { CounterState } from "./events/counterEvent"; import { EntityManager } from "./entityManager"; import { EventManager } from "./eventManager"; -import type { CombatEventSides } from "./combatEventSides"; +import { CombatSideIndex } from "./combatEventSides"; import { HitKilledEvent } from "./events/hitKilledEvent"; import { ConnectEvent } from "./events/connectEvent"; import { EndMissionEvent } from "./events/endMissionEvent"; @@ -134,6 +134,7 @@ export class PlaybackEngine { private renderer: MapRenderer; private chunkManager: ChunkManager | null = null; private manifest: Manifest | null = null; + private combatSides: CombatSideIndex | null = null; private _worldConfig: Accessor; private _setWorldConfig: (world: WorldConfig | undefined) => void; @@ -166,6 +167,10 @@ export class PlaybackEngine { private _setCaptureDelayMs: (v: number) => void; private _playerSnapshotsVersion: Accessor; private _setPlayerSnapshotsVersion: (v: (prev: number) => number) => void; + private _recordingVersion: Accessor; + private _setRecordingVersion: (v: (prev: number) => number) => void; + private _eventReferencesVersion: Accessor; + private _setEventReferencesVersion: (v: (prev: number) => number) => void; /** unitId -> in-flight or settled sidecar load, so a reopen does not refetch. */ private playerSnapshotLoads = new Map>(); @@ -226,6 +231,8 @@ export class PlaybackEngine { const [playerSnapshotsVersion, setPlayerSnapshotsVersion] = createSignal(0); this._playerSnapshotsVersion = playerSnapshotsVersion; this._setPlayerSnapshotsVersion = setPlayerSnapshotsVersion; + [this._recordingVersion, this._setRecordingVersion] = createSignal(0); + [this._eventReferencesVersion, this._setEventReferencesVersion] = createSignal(0); } // ─── Public signal accessors ─── @@ -261,6 +268,10 @@ export class PlaybackEngine { get playerSnapshotsVersion(): Accessor { return this._playerSnapshotsVersion; } + /** Changes even when two recordings have the same duration. */ + get recordingVersion(): Accessor { return this._recordingVersion; } + /** Combat sides and scores can change when a previously unseen chunk arrives. */ + get eventReferencesVersion(): Accessor { return this._eventReferencesVersion; } get worldConfig(): WorldConfig | undefined { return this._worldConfig(); } @@ -319,12 +330,15 @@ export class PlaybackEngine { this._setActiveEvents(this.eventManager.getActiveEvents(clamped)); // If the needed chunk isn't loaded yet, load it then recompute - if (this.chunkManager) { - void this.chunkManager.ensureLoaded(clamped).then(() => { - if (this._currentFrame() === clamped) { + const chunks = this.chunkManager; + if (chunks) { + void chunks.ensureLoaded(clamped).then(() => { + if (this.chunkManager === chunks && this._currentFrame() === clamped) { this.computeSnapshots(clamped); this._setActiveEvents(this.eventManager.getActiveEvents(clamped)); } + }).catch((error) => { + if (this.chunkManager === chunks) console.error(`Failed to load playback frame ${clamped}`, error); }); } } @@ -365,13 +379,12 @@ export class PlaybackEngine { /** * Populate entities and events from a manifest, and wire up the chunk manager. - * Converted recordings supply event-time sides sampled before publication, - * so combat classification does not depend on which chunks remain cached. + * Combat sides are sampled from already loaded chunks and retained as later + * chunks arrive. Initialization never requests the rest of the mission. */ loadRecording( manifest: Manifest, chunkManager?: ChunkManager | null, - eventSides?: CombatEventSides, ): void { // Reset state this.clearTimer(); @@ -384,13 +397,18 @@ export class PlaybackEngine { this.manifest = manifest; this.chunkManager = chunkManager ?? null; + this.combatSides = this.chunkManager ? new CombatSideIndex(manifest) : null; // When a chunk finishes loading, recompute snapshots only if the loaded // chunk is the one needed for the current frame. This avoids redundant // recomputation when a prefetched future chunk arrives. if (this.chunkManager) { - this.chunkManager.setCallbacks({ + const chunks = this.chunkManager; + chunks.setCallbacks({ onChunkLoaded: (chunkIndex: number) => { + if (this.chunkManager !== chunks || this.manifest !== manifest) return; + const chunk = chunks.getChunkForFrame(chunkIndex * (manifest.chunkSize || 300)); + if (chunk && this.combatSides?.sample(chunkIndex, chunk)) this.resolveCombatReferences(); const frame = this._currentFrame(); const chunkSize = this.manifest?.chunkSize || 300; const currentChunkIndex = Math.floor(frame / chunkSize); @@ -441,9 +459,13 @@ export class PlaybackEngine { } // Resolve entity references on hit/killed events - this.eventManager.resolveReferences(this.entityManager, (id, frame) => - eventSides?.get(frame)?.get(id) ?? this.getStateAt(id, frame)?.side, - ); + if (this.combatSides && this.chunkManager) { + for (const index of this.combatSides.chunkIndices) { + const chunk = this.chunkManager.getChunkForFrame(index * (manifest.chunkSize || 300)); + if (chunk) this.combatSides.sample(index, chunk); + } + } + this.resolveCombatReferences(); // Build counter state from counter events this.buildCounterState(manifest.events); @@ -455,6 +477,14 @@ export class PlaybackEngine { // Initial snapshot computation this.computeSnapshots(0); this._setActiveEvents(this.eventManager.getActiveEvents(0)); + this._setRecordingVersion((version) => version + 1); + } + + private resolveCombatReferences(): void { + this.eventManager.resolveReferences(this.entityManager, (id, frame) => + this.combatSides?.sideAt(id, frame) ?? this.getStateAt(id, frame)?.side, + ); + this._setEventReferencesVersion((version) => version + 1); } /** @@ -472,6 +502,7 @@ export class PlaybackEngine { if (existing) return existing; const chunkManager = this.chunkManager; + const manifest = this.manifest; if (!chunkManager || !this.manifest?.snapshotUnitIds?.includes(unitId)) { const resolved = Promise.resolve(); this.playerSnapshotLoads.set(unitId, resolved); @@ -481,6 +512,7 @@ export class PlaybackEngine { const load = chunkManager .loadPlayerSnapshots(unitId) .then((defs) => { + if (this.chunkManager !== chunkManager || this.manifest !== manifest) return; for (const def of defs) { const event = createGameEvent(def); if (event) this.eventManager.addEvent(event); @@ -489,6 +521,7 @@ export class PlaybackEngine { this._setPlayerSnapshotsVersion((v) => v + 1); }) .catch((error) => { + if (this.chunkManager !== chunkManager || this.manifest !== manifest) return; // A missing or corrupt sidecar must not break playback; the card falls // back to whatever the manifest already carried for this unit. console.error(`Failed to load snapshots for unit ${unitId}`, error); @@ -504,8 +537,8 @@ export class PlaybackEngine { * * The profile card reads one player at a time, which is why sidecars are * lazy. A comms rollup is the opposite shape: it needs every player's radios - * at once to know who shared a net, so it pays the whole fan-out up front, - * once. A recording that keeps its snapshots in the manifest resolves + * at once to know who shared a net, as does an active map transmission. + * A recording that keeps its snapshots in the manifest resolves * immediately without a single request. */ ensureAllPlayerSnapshots(): Promise { @@ -616,9 +649,12 @@ export class PlaybackEngine { this._setIsPlaying(false); this.entityManager.clear(); this.eventManager.clear(); + this.playerSnapshotLoads.clear(); + this.combatSides = null; this.chunkManager = null; this.manifest = null; this._setWorldConfig(undefined); + this._setRecordingVersion((version) => version + 1); } // ─── Playback loop ─── @@ -674,7 +710,10 @@ export class PlaybackEngine { // Ensure current chunk is loaded (async — the onChunkLoaded callback // will recompute snapshots if this triggers a new load). if (this.chunkManager) { - void this.chunkManager.ensureLoaded(nextFrame); + const chunks = this.chunkManager; + void chunks.ensureLoaded(nextFrame).catch((error) => { + if (this.chunkManager === chunks) console.error(`Failed to load playback frame ${nextFrame}`, error); + }); } // Compute entity snapshots with whatever chunk data is in memory diff --git a/ui/src/playback/eventManager.ts b/ui/src/playback/eventManager.ts index b8cc5f4b6..8e61dc3dd 100644 --- a/ui/src/playback/eventManager.ts +++ b/ui/src/playback/eventManager.ts @@ -426,6 +426,13 @@ export class EventManager { entityManager: EntityManager, sideAt?: (unitId: number, frame: number) => Side | undefined, ): void { + // Resolution may run again as streamed faction data arrives. Rebuild scores + // from events instead of incrementing counters left by the previous pass. + for (const unit of entityManager.getUnits()) { + unit.killCount = 0; + unit.teamKillCount = 0; + unit.deathCount = 0; + } // First pass: resolve names/sides for (const event of this.events) { if (event instanceof HitKilledEvent) { From e952a8a805dda2cd31e5bb4feca20775538d9562 Mon Sep 17 00:00:00 2001 From: Yethe Samartaka Date: Sat, 17 Oct 2026 21:18:39 +0200 Subject: [PATCH 31/39] Normalize converted FPS samples and retain transmission factions --- internal/storage/parser_v1.go | 11 +++- .../storage/server_fps_conversion_test.go | 65 +++++++++++++++++++ .../__tests__/EventsTab.test.tsx | 38 ++++++++++- .../convertedCombat.integration.test.ts | 32 +++++++++ .../components/EventsTab.tsx | 2 +- ui/src/playback/engine.ts | 35 ++++++---- .../{combatEventSides.ts => eventSides.ts} | 15 +++-- 7 files changed, 177 insertions(+), 21 deletions(-) create mode 100644 internal/storage/server_fps_conversion_test.go rename ui/src/playback/{combatEventSides.ts => eventSides.ts} (75%) diff --git a/internal/storage/parser_v1.go b/internal/storage/parser_v1.go index 3b5ae8910..91965da6e 100644 --- a/internal/storage/parser_v1.go +++ b/internal/storage/parser_v1.go @@ -146,9 +146,16 @@ func parseEventArray(evtArr []interface{}) *Event { return nil } + eventType := toString(evtArr[1]) + frameNum := toFloat64(evtArr[0]) + // Older recorders emitted an initial FPS sample before frame zero. Match + // JSON playback's normalization before converting to the unsigned schema. + if eventType == "serverFps" && frameNum < 0 { + frameNum = 0 + } event := &Event{ - FrameNum: uint32(toFloat64(evtArr[0])), - Type: toString(evtArr[1]), + FrameNum: uint32(frameNum), + Type: eventType, } // Connect/disconnect events: [frameNum, "type", "playerName"] diff --git a/internal/storage/server_fps_conversion_test.go b/internal/storage/server_fps_conversion_test.go new file mode 100644 index 000000000..305eecc99 --- /dev/null +++ b/internal/storage/server_fps_conversion_test.go @@ -0,0 +1,65 @@ +package storage + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + pbv1 "github.com/OCAP2/web/pkg/schemas/protobuf/v1" +) + +func TestPreFrameServerFPSConversion(t *testing.T) { + // Older recorders sampled FPS before the first frame. Both conversion paths + // must match JSON playback's frame-zero normalization, without wrapping -1. + const recording = `{ + "worldName":"altis", "missionName":"Pre-frame FPS", "endFrame":20, + "captureDelay":1, "entities":[], "events":[ + [-1,"serverFps",{"fps":40}], + [10,"serverFps",{"fps":48}], + [15,"connected","Player"] + ] + }` + for _, streaming := range []bool{false, true} { + name := "parser-and-writer" + if streaming { + name = "streaming-converter" + } + t.Run(name, func(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + output := filepath.Join(dir, "converted") + if streaming { + input := filepath.Join(dir, "recording.json") + require.NoError(t, os.WriteFile(input, []byte(recording), 0600)) + require.NoError(t, NewConverter(300).Convert(ctx, input, output)) + } else { + require.NoError(t, os.MkdirAll(output, 0755)) + var data map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(recording), &data)) + result, err := (&ParserV1{}).Parse(data, 300) + require.NoError(t, err) + require.NoError(t, (&ProtobufWriterV1{}).WriteManifest(ctx, output, result)) + } + + data, err := os.ReadFile(filepath.Join(output, "manifest.pb")) + require.NoError(t, err) + var manifest pbv1.Manifest + require.NoError(t, proto.Unmarshal(data, &manifest)) + require.Len(t, manifest.Events, 3) + for i, frame := range []uint32{0, 10, 15} { + require.Equal(t, frame, manifest.Events[i].FrameNum) + require.LessOrEqual(t, manifest.Events[i].FrameNum, manifest.EndFrame) + } + require.Equal(t, "serverFps", manifest.Events[0].Type) + require.JSONEq(t, `{"fps":40}`, manifest.Events[0].Message) + require.JSONEq(t, `{"fps":48}`, manifest.Events[1].Message) + require.Equal(t, "connected", manifest.Events[2].Type) + require.Equal(t, "Player", manifest.Events[2].Message) + }) + } +} diff --git a/ui/src/pages/recording-playback/__tests__/EventsTab.test.tsx b/ui/src/pages/recording-playback/__tests__/EventsTab.test.tsx index 52146fda2..ae711602f 100644 --- a/ui/src/pages/recording-playback/__tests__/EventsTab.test.tsx +++ b/ui/src/pages/recording-playback/__tests__/EventsTab.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, afterEach, vi } from "vitest"; import { render, screen, cleanup, fireEvent } from "@solidjs/testing-library"; -import { EventsTab } from "../components/EventsTab"; +import { EventsTab, eventInvolvedSides } from "../components/EventsTab"; import type { EventDef } from "../../../data/types"; import { createTestEngine, @@ -53,6 +53,42 @@ afterEach(() => { }); describe("EventsTab", () => { + it.each([true, false])("keeps transmission faction filters stable after a side switch and departure (recorded side: %s)", (recordSide) => { + const { engine, renderer } = createTestEngine(); + const events: EventDef[] = [{ + type: "radioTransmission", frameNum: 0, + payload: { unitId: 1, radio: "TFAR_anprc152", type: "SW", action: "Start", channel: 1, + additional: false, frequency: 100, code: "blue" }, + }]; + engine.loadRecording(makeManifest([unitDef({ + side: recordSide ? "EAST" : "WEST", + endFrame: 1, + positions: [ + { position: [100, 200], direction: 0, alive: 1, ...(recordSide ? { side: "WEST" as const } : {}) }, + { position: [100, 200], direction: 0, alive: 1, side: "EAST" }, + ], + })], events, 10)); + const transmission = engine.eventManager.getAll()[0]; + render(() => ( + + )); + fireEvent.click(screen.getByLabelText("Event filters")); + fireEvent.click(screen.getByText("Transmissions")); + expect(screen.getByTestId("event-row-0")).toBeTruthy(); + fireEvent.click(screen.getByText("BLUFOR")); + + for (const frame of [0, 1, 2]) { + engine.seekTo(frame); + if (frame === 1) expect(engine.entitySnapshots().get(1)?.side).toBe("EAST"); + expect(eventInvolvedSides(transmission, engine)).toEqual(["WEST"]); + expect(screen.queryByTestId("event-row-0")).toBeNull(); + } + expect(engine.entitySnapshots().has(1)).toBe(false); + fireEvent.click(screen.getByText("BLUFOR")); + expect(screen.getByTestId("event-row-0")).toBeTruthy(); + engine.dispose(); + }); + it("shows 'no events' placeholder when no events exist", () => { const { engine, renderer } = createTestEngine(); engine.loadRecording(makeManifest([unitDef()])); diff --git a/ui/src/pages/recording-playback/__tests__/convertedCombat.integration.test.ts b/ui/src/pages/recording-playback/__tests__/convertedCombat.integration.test.ts index 8e6b8de5c..619ee1d5c 100644 --- a/ui/src/pages/recording-playback/__tests__/convertedCombat.integration.test.ts +++ b/ui/src/pages/recording-playback/__tests__/convertedCombat.integration.test.ts @@ -16,6 +16,7 @@ import { HitKilledEvent } from "../../../playback/events/hitKilledEvent"; import { MarkerManager } from "../../../playback/markerManager"; import { MockRenderer } from "../../../renderers/mockRenderer"; import { loadRecording } from "../loadRecording"; +import { eventInvolvedSides } from "../components/EventsTab"; const engines: PlaybackEngine[] = []; afterEach(() => { @@ -76,6 +77,37 @@ function combat(engine: PlaybackEngine): HitKilledEvent[] { } describe("converted recording event-time factions", () => { + it("retains transmission factions after the speaker departs and the event chunk is evicted", async () => { + const { api, rec, engine, markerManager, getChunk, raw } = fixture(); + raw.events = PbManifest.fromPartial({ events: [{ + frameNum: 901, type: "radioTransmission", + message: JSON.stringify({ unitId: 1, radio: "TFAR_anprc152", type: "SW", action: "Start", + channel: 1, additional: false, frequency: 100, code: "blue" }), + }] }).events; + raw.entities[0].endFrame = 1000; + await loadRecording(api, engine, markerManager, rec); + const transmission = engine.eventManager.getAll()[0]; + expect(getChunk.mock.calls.map((call) => call[1])).toEqual([0]); + // An unavailable event-frame history uses the stable spawn side, not the + // speaker's EAST side at the playhead in chunk zero, and causes no fetch. + expect(engine.entitySnapshots().get(1)?.side).toBe("EAST"); + expect(eventInvolvedSides(transmission, engine)).toEqual(["WEST"]); + + const version = engine.eventReferencesVersion(); + engine.seekTo(901); + await vi.waitFor(() => expect(engine.getStateAt(1, 901)).not.toBeNull()); + expect(engine.eventReferencesVersion()).toBeGreaterThan(version); + expect(eventInvolvedSides(transmission, engine)).toEqual(["EAST"]); + for (const frame of [1201, 0, 301, 601]) { + engine.seekTo(frame); + await vi.waitFor(() => expect(engine.getStateAt(2, frame)).not.toBeNull()); + expect(eventInvolvedSides(transmission, engine)).toEqual(["EAST"]); + if (frame === 1201) expect(engine.entitySnapshots().has(1)).toBe(false); + } + expect(engine.getStateAt(1, 901)).toBeNull(); + expect(getChunk.mock.calls.map((call) => call[1])).toEqual([0, 3, 4, 1, 2]); + }); + it("uses an already-loaded chunk when resolving engine references directly", async () => { const { api, engine } = fixture(); const chunks = new ChunkManager(new ProtobufDecoder(), api); diff --git a/ui/src/pages/recording-playback/components/EventsTab.tsx b/ui/src/pages/recording-playback/components/EventsTab.tsx index b894bb65b..0f05391fc 100644 --- a/ui/src/pages/recording-playback/components/EventsTab.tsx +++ b/ui/src/pages/recording-playback/components/EventsTab.tsx @@ -92,7 +92,7 @@ export function eventInvolvedSides(event: GameEvent, engine: PlaybackEngine): st if (event instanceof ServiceEvent) return event.payload.side ? [event.payload.side] : []; if (event instanceof StaticWeaponEvent) return event.payload.side ? [event.payload.side] : []; if (event instanceof RadioTransmissionEvent) { - const side = engine.entitySnapshots().get(event.unitId)?.side; + const side = engine.getEventSideAt(event.unitId, event.frameNum); return side ? [side] : []; } return []; diff --git a/ui/src/playback/engine.ts b/ui/src/playback/engine.ts index b5fea9b57..982be6807 100644 --- a/ui/src/playback/engine.ts +++ b/ui/src/playback/engine.ts @@ -1,6 +1,6 @@ import { createSignal, type Accessor } from "solid-js"; -import type { EntityState, Manifest, EventDef, WorldConfig } from "../data/types"; +import type { EntityState, Manifest, EventDef, Side, WorldConfig } from "../data/types"; import type { ArmaCoord } from "../utils/coordinates"; import { resolveAcrePropagation, @@ -16,7 +16,7 @@ import type { GameEvent } from "./events/gameEvent"; import type { CounterState } from "./events/counterEvent"; import { EntityManager } from "./entityManager"; import { EventManager } from "./eventManager"; -import { CombatSideIndex } from "./combatEventSides"; +import { EventSideIndex } from "./eventSides"; import { HitKilledEvent } from "./events/hitKilledEvent"; import { ConnectEvent } from "./events/connectEvent"; import { EndMissionEvent } from "./events/endMissionEvent"; @@ -134,7 +134,7 @@ export class PlaybackEngine { private renderer: MapRenderer; private chunkManager: ChunkManager | null = null; private manifest: Manifest | null = null; - private combatSides: CombatSideIndex | null = null; + private eventSides: EventSideIndex | null = null; private _worldConfig: Accessor; private _setWorldConfig: (world: WorldConfig | undefined) => void; @@ -270,7 +270,7 @@ export class PlaybackEngine { } /** Changes even when two recordings have the same duration. */ get recordingVersion(): Accessor { return this._recordingVersion; } - /** Combat sides and scores can change when a previously unseen chunk arrives. */ + /** Event sides and combat scores can change when a previously unseen chunk arrives. */ get eventReferencesVersion(): Accessor { return this._eventReferencesVersion; } get worldConfig(): WorldConfig | undefined { return this._worldConfig(); @@ -397,7 +397,7 @@ export class PlaybackEngine { this.manifest = manifest; this.chunkManager = chunkManager ?? null; - this.combatSides = this.chunkManager ? new CombatSideIndex(manifest) : null; + this.eventSides = this.chunkManager ? new EventSideIndex(manifest) : null; // When a chunk finishes loading, recompute snapshots only if the loaded // chunk is the one needed for the current frame. This avoids redundant @@ -408,7 +408,7 @@ export class PlaybackEngine { onChunkLoaded: (chunkIndex: number) => { if (this.chunkManager !== chunks || this.manifest !== manifest) return; const chunk = chunks.getChunkForFrame(chunkIndex * (manifest.chunkSize || 300)); - if (chunk && this.combatSides?.sample(chunkIndex, chunk)) this.resolveCombatReferences(); + if (chunk && this.eventSides?.sample(chunkIndex, chunk)) this.resolveCombatReferences(); const frame = this._currentFrame(); const chunkSize = this.manifest?.chunkSize || 300; const currentChunkIndex = Math.floor(frame / chunkSize); @@ -458,11 +458,12 @@ export class PlaybackEngine { }); } - // Resolve entity references on hit/killed events - if (this.combatSides && this.chunkManager) { - for (const index of this.combatSides.chunkIndices) { + // Retain event-time sides from chunks already in the cache; never fetch + // mission-wide history on the startup path. + if (this.eventSides && this.chunkManager) { + for (const index of this.eventSides.chunkIndices) { const chunk = this.chunkManager.getChunkForFrame(index * (manifest.chunkSize || 300)); - if (chunk) this.combatSides.sample(index, chunk); + if (chunk) this.eventSides.sample(index, chunk); } } this.resolveCombatReferences(); @@ -482,7 +483,7 @@ export class PlaybackEngine { private resolveCombatReferences(): void { this.eventManager.resolveReferences(this.entityManager, (id, frame) => - this.combatSides?.sideAt(id, frame) ?? this.getStateAt(id, frame)?.side, + this.getEventSideAt(id, frame), ); this._setEventReferencesVersion((version) => version + 1); } @@ -574,6 +575,16 @@ export class PlaybackEngine { return entity.positions?.[entity.getRelativeFrameIndex(frame)] ?? null; } + /** Historical event affiliation, independent of the playhead and chunk eviction. */ + getEventSideAt(entityId: number, frame: number): Side | undefined { + const side = this.eventSides?.sideAt(entityId, frame) ?? this.getStateAt(entityId, frame)?.side; + if (side) return side; + // A recording without per-frame sides, or history not loaded yet, uses + // the stable spawn faction even after the unit leaves the current roster. + const entity = this.entityManager.getEntity(entityId); + return entity instanceof Unit ? entity.sideAtFrame(frame) : undefined; + } + /** Position of one entity at an arbitrary frame, or null when not loaded. */ getPositionAt(entityId: number, frame: number): ArmaCoord | null { return this.getStateAt(entityId, frame)?.position ?? null; @@ -650,7 +661,7 @@ export class PlaybackEngine { this.entityManager.clear(); this.eventManager.clear(); this.playerSnapshotLoads.clear(); - this.combatSides = null; + this.eventSides = null; this.chunkManager = null; this.manifest = null; this._setWorldConfig(undefined); diff --git a/ui/src/playback/combatEventSides.ts b/ui/src/playback/eventSides.ts similarity index 75% rename from ui/src/playback/combatEventSides.ts rename to ui/src/playback/eventSides.ts index 9f2c89b2a..4845641d4 100644 --- a/ui/src/playback/combatEventSides.ts +++ b/ui/src/playback/eventSides.ts @@ -1,8 +1,10 @@ import type { ChunkData, EventDef, Manifest, Side } from "../data/types"; -/** Retain only combat-frame sides from chunks that normal playback or analysis has loaded. */ -export class CombatSideIndex { - private readonly byChunk = new Map>(); +type FactionEvent = Extract; + +/** Retain event-frame sides from chunks that normal playback or analysis has loaded. */ +export class EventSideIndex { + private readonly byChunk = new Map(); private readonly sampled = new Set(); private readonly sides = new Map>(); private readonly entities: Map; @@ -12,7 +14,7 @@ export class CombatSideIndex { this.chunkSize = manifest.chunkSize || 300; this.entities = new Map(manifest.entities.map((entity) => [entity.id, entity])); for (const event of manifest.events) { - if (event.type !== "hit" && event.type !== "killed") continue; + if (event.type !== "hit" && event.type !== "killed" && event.type !== "radioTransmission") continue; const index = Math.floor(event.frameNum / this.chunkSize); if (index < 0 || index >= manifest.chunkCount) continue; const events = this.byChunk.get(index) ?? []; @@ -31,7 +33,10 @@ export class CombatSideIndex { if (!events || this.sampled.has(index)) return false; for (const event of events) { const atFrame = this.sides.get(event.frameNum) ?? new Map(); - for (const id of [event.victimId, event.causedById]) { + const ids = event.type === "radioTransmission" + ? [event.payload.unitId] + : [event.victimId, event.causedById]; + for (const id of ids) { const entity = this.entities.get(id); if (!entity || event.frameNum < entity.startFrame || event.frameNum > entity.endFrame) continue; const side = chunk.entities.get(id)?.[event.frameNum - index * this.chunkSize]?.side; From 289e39da50df9a6852cec5031f8b257ab00590bf Mon Sep 17 00:00:00 2001 From: Yethe Samartaka Date: Sat, 17 Oct 2026 21:36:31 +0200 Subject: [PATCH 32/39] Refresh system marker labels when zoom changes --- .../leaflet/__tests__/leafletRenderer.test.ts | 2 +- .../__tests__/markerLabelVisibility.test.ts | 83 +++++++++++++++++++ ui/src/renderers/leaflet/leafletRenderer.ts | 39 ++++----- 3 files changed, 104 insertions(+), 20 deletions(-) create mode 100644 ui/src/renderers/leaflet/__tests__/markerLabelVisibility.test.ts diff --git a/ui/src/renderers/leaflet/__tests__/leafletRenderer.test.ts b/ui/src/renderers/leaflet/__tests__/leafletRenderer.test.ts index 9afb79e04..f5369c34b 100644 --- a/ui/src/renderers/leaflet/__tests__/leafletRenderer.test.ts +++ b/ui/src/renderers/leaflet/__tests__/leafletRenderer.test.ts @@ -168,7 +168,7 @@ describe("setMarkerDisplayMode", () => { renderer._markerDisplayMode = mdm; renderer._setMarkerDisplayMode = setMdm; renderer.map = map; - renderer.layers = { briefingMarkers: group }; + renderer.layers = { briefingMarkers: group, systemMarkers: L.layerGroup().addTo(map) }; }); afterEach(() => { diff --git a/ui/src/renderers/leaflet/__tests__/markerLabelVisibility.test.ts b/ui/src/renderers/leaflet/__tests__/markerLabelVisibility.test.ts new file mode 100644 index 000000000..8902a41ac --- /dev/null +++ b/ui/src/renderers/leaflet/__tests__/markerLabelVisibility.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { LeafletRenderer } from "../leafletRenderer"; + +describe("marker label zoom visibility", () => { + let container: HTMLDivElement; + let renderer: LeafletRenderer; + + beforeEach(() => { + container = document.createElement("div"); + container.style.cssText = "width:400px;height:400px"; + document.body.appendChild(container); + renderer = new LeafletRenderer(); + renderer.init(container, { + worldName: "test", worldSize: 10000, imageSize: 10000, + maxZoom: 6, minZoom: 0, multiplier: 1, tileBaseUrl: "", + }); + }); + + afterEach(() => { + renderer.dispose(); + container.remove(); + }); + + function zoom(level: number): void { + renderer.setView([5000, 5000], level, false); + expect(renderer.getZoom()).toBe(level); + } + + function createLabels(): HTMLElement[] { + for (const [textClass, text, layer] of [ + ["zeus-ping-label", "[Zeus ping] - Alice - 00:01", "systemMarkers"], + ["zeus-module-label", "[Zeus module] - Bob - 00:02", "systemMarkers"], + ["sector-label", "Sector Alpha", "briefingMarkers"], + ] as const) { + renderer.createBriefingMarker({ + shape: "ICON", type: "Empty", color: "FFFFFF", side: "GLOBAL", + text, textClass, layer, + }); + } + const labels = Array.from(container.querySelectorAll(".marker-text-label")); + expect(labels).toHaveLength(3); + return labels; + } + + it.each([4, 5])("refreshes existing labels in both zoom directions after creation at zoom %s", (initialZoom) => { + zoom(initialZoom); + const labels = createLabels(); + for (const label of labels) expect(label.style.display).toBe(initialZoom <= 4 ? "none" : ""); + + // Only the camera changes, as when playback is paused: no marker updates + // or recreation can repair visibility for the renderer. + for (const level of initialZoom === 4 ? [5, 4] : [4, 5]) { + zoom(level); + for (const label of labels) { + expect(label.isConnected).toBe(true); + expect(label.style.display).toBe(level <= 4 ? "none" : ""); + } + } + }); + + it.each([true, false])("preserves projectile labels across zoom and marker-label changes (enabled: %s)", (enabled) => { + zoom(4); + renderer.setProjectileLabelsVisible(enabled); + renderer.createBriefingMarker({ + shape: "ICON", type: "mil_dot", color: "FFFFFF", side: "GLOBAL", + text: "Projectile label", layer: "projectileMarkers", + }); + const labels = createLabels(); + const hasProjectileLabel = () => Array.from(container.querySelectorAll(".leaflet-popup-content")) + .some((element) => element.textContent === "Projectile label"); + expect(hasProjectileLabel()).toBe(enabled); + for (const level of [5, 4, 5]) { + zoom(level); + expect(hasProjectileLabel()).toBe(enabled); + } + renderer.setMarkerDisplayMode("noLabels"); + for (const label of labels) expect(label.style.display).toBe("none"); + expect(hasProjectileLabel()).toBe(enabled); + renderer.setMarkerDisplayMode("all"); + for (const label of labels) expect(label.style.display).toBe(""); + expect(hasProjectileLabel()).toBe(enabled); + }); +}); diff --git a/ui/src/renderers/leaflet/leafletRenderer.ts b/ui/src/renderers/leaflet/leafletRenderer.ts index d5ee28c62..c4f46dfee 100644 --- a/ui/src/renderers/leaflet/leafletRenderer.ts +++ b/ui/src/renderers/leaflet/leafletRenderer.ts @@ -1332,28 +1332,29 @@ export class LeafletRenderer implements MapRenderer { return this._markerDisplayMode() === "all" && !this.hideMarkerPopups; } - /** Apply `briefingLabelsVisible()` to every briefing marker on the map. */ + /** Refresh briefing and system labels; projectile labels have their own setting. */ private refreshBriefingLabelVisibility(): void { - const group = this.layers.briefingMarkers; - if (!this.map.hasLayer(group)) return; const visible = this.briefingLabelsVisible(); - group.eachLayer((layer) => { - if (!(layer instanceof L.Marker)) return; - // Text-label markers (e.g. sector names) — toggle element visibility - if (this.isTextLabelMarker(layer)) { - const el = layer.getElement?.(); - if (el) el.style.display = visible ? "" : "none"; - return; - } - // ICON markers with popups — toggle popup - if (!layer.getPopup()) return; - if (visible) { - layer.openPopup(); - } else { - layer.closePopup(); - } - }); + for (const group of [this.layers.briefingMarkers, this.layers.systemMarkers]) { + if (!this.map.hasLayer(group)) continue; + group.eachLayer((layer) => { + if (!(layer instanceof L.Marker)) return; + // Text-label markers (e.g. sector names) — toggle element visibility + if (this.isTextLabelMarker(layer)) { + const el = layer.getElement?.(); + if (el) el.style.display = visible ? "" : "none"; + return; + } + // ICON markers with popups — toggle popup + if (!layer.getPopup()) return; + if (visible) { + layer.openPopup(); + } else { + layer.closePopup(); + } + }); + } } /** From 9e53433abbcaaf40e1f5a8928036bb8c17406cb7 Mon Sep 17 00:00:00 2001 From: Yethe Samartaka Date: Sat, 17 Oct 2026 21:51:23 +0200 Subject: [PATCH 33/39] Apply TFAR range multiplier without terrain data --- ui/src/playback/__tests__/comms.test.ts | 31 +++++++++++++++++++++++++ ui/src/playback/comms.ts | 9 +++---- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/ui/src/playback/__tests__/comms.test.ts b/ui/src/playback/__tests__/comms.test.ts index c939f2553..3b9f89d56 100644 --- a/ui/src/playback/__tests__/comms.test.ts +++ b/ui/src/playback/__tests__/comms.test.ts @@ -226,6 +226,37 @@ describe("reachability", () => { expect(result[0].inRange).toBe(false); }); + it.each([ + { coefficient: 0.5, distances: [2500, 2501, 3000], expected: [true, false, false] }, + { coefficient: 1, distances: [3000, 5000, 5001], expected: [true, true, false] }, + { coefficient: 2, distances: [6000, 10000, 10001], expected: [true, true, false] }, + ])("uses TFAR range coefficient $coefficient with or without flat terrain", ({ coefficient, distances, expected }) => { + const input = { + origin: [1000, 1000, 0] as [number, number, number], + peers: distances.map((distance, index) => ({ + unitId: index + 2, name: `Receiver ${index}`, side: "WEST", + position: [1000 + distance, 1000, 0] as [number, number, number], monitoring: false, + })), + rangeMeters: 5000, mod: "TFAR", + tfar: { ...TFAR, globalRadioRangeCoef: coefficient }, acre: ACRE, + }; + const withoutTerrain = reachability({ ...input, grid: null }); + expect(withoutTerrain.map((peer) => peer.inRange)).toEqual(expected); + // Loading unobstructed terrain cannot change the recorded range settings. + expect(reachability({ ...input, grid: makeFlatDem(20000, 0, 100) })).toEqual(withoutTerrain); + expect(withoutTerrain.map((peer) => peer.distance)).toEqual(distances); + }); + + it("keeps ACRE's no-terrain range independent of TFAR settings", () => { + const result = reachability({ + origin: [1000, 1000, 0], + peers: [{ ...peers[0], position: [4000, 1000, 0] }], + rangeMeters: 5000, mod: "ACRE", grid: null, + tfar: { ...TFAR, globalRadioRangeCoef: 0.5 }, acre: ACRE, + }); + expect(result[0].inRange).toBe(true); + }); + it("agrees with the terrain model over flat ground", () => { const grid = makeFlatDem(10000, 0, 100); const result = reachability({ diff --git a/ui/src/playback/comms.ts b/ui/src/playback/comms.ts index c36165e5e..412243de2 100644 --- a/ui/src/playback/comms.ts +++ b/ui/src/playback/comms.ts @@ -1,7 +1,7 @@ import type { RadioSnapshotEntry, RadioTransmissionPayload } from "../data/types"; import type { TfarRadioPropagation, AcreRadioPropagation } from "../data/radioPropagation"; import type { DemGrid } from "./radioRange/demGrid"; -import { inTfarRange } from "./radioRange/tfarCoverage"; +import { effectiveDistance, inTfarRange } from "./radioRange/tfarCoverage"; import { inAcreRange } from "./radioRange/acreCoverage"; /** @@ -212,8 +212,8 @@ export interface ReachabilityInput { * * With a DEM loaded this is the same terrain-aware test the coverage overlay * draws with, so a ridge between two units puts them out of contact exactly - * where the overlay says it does. Without one it degrades to straight-line - * distance, which is what the "simple range" mode already shows. + * where the overlay says it does. Without one it uses straight-line distance + * while retaining TFAR's recorded range multiplier. * * What it cannot know: whether the receiver's radio was switched on, their * volume, or whether they were listening. This is reachability, not attention. @@ -229,7 +229,8 @@ export function reachability(input: ReachabilityInput): ReachabilityResult[] { let inRange: boolean; if (!input.grid) { - inRange = distance <= input.rangeMeters; + const effective = isAcre ? distance : effectiveDistance(distance, 0, input.tfar); + inRange = effective <= input.rangeMeters; } else if (isAcre) { inRange = inAcreRange( input.grid, ox, oy, oz, px, py, pz, From 701fa315aeb6771388fdab3c5f71b8556f5e6267 Mon Sep 17 00:00:00 2001 From: Yethe Samartaka Date: Sat, 17 Oct 2026 22:01:11 +0200 Subject: [PATCH 34/39] Restore shared playback positions outside saved focus --- .../recording-playback/RecordingPlayback.tsx | 10 +++- .../__tests__/RecordingPlayback.test.tsx | 49 ++++++++++++++++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/ui/src/pages/recording-playback/RecordingPlayback.tsx b/ui/src/pages/recording-playback/RecordingPlayback.tsx index 8ec740dc5..d602eceaf 100644 --- a/ui/src/pages/recording-playback/RecordingPlayback.tsx +++ b/ui/src/pages/recording-playback/RecordingPlayback.tsx @@ -186,7 +186,15 @@ export function RecordingPlayback(): JSX.Element { const link = readDeepLink(); if (link.tab) setActivePanelTab(link.tab); if (link.side) setActiveSide(link.side); - if (link.frame !== undefined) engine.seekTo(link.frame); + if (link.frame !== undefined) { + const focus = focusRange(); + if (focus && (link.frame < focus.inFrame || link.frame > focus.outFrame)) { + // A shared position outside the saved focus implies FULL mode. + // Restore that before seeking so the focus effect cannot move it. + setShowFullTimeline(true); + } + engine.seekTo(link.frame); + } if (link.unit !== undefined && engine.entityManager.getEntity(link.unit)) { engine.followEntity(link.unit); } diff --git a/ui/src/pages/recording-playback/__tests__/RecordingPlayback.test.tsx b/ui/src/pages/recording-playback/__tests__/RecordingPlayback.test.tsx index 96a0eaee0..d0cb91bed 100644 --- a/ui/src/pages/recording-playback/__tests__/RecordingPlayback.test.tsx +++ b/ui/src/pages/recording-playback/__tests__/RecordingPlayback.test.tsx @@ -5,8 +5,9 @@ import { I18nProvider } from "../../../hooks/useLocale"; import { CustomizeProvider } from "../../../hooks/useCustomize"; import { AuthProvider } from "../../../hooks/useAuth"; import { MockRenderer } from "../../../renderers/mockRenderer"; -import { setAuthToken } from "../../../data/apiClient"; +import { ApiClient, setAuthToken } from "../../../data/apiClient"; import type { LoadResult } from "../loadRecording"; +import { makeManifest } from "./testHelpers"; // ─── Mocks (must be before imports that use them) ─── @@ -940,6 +941,52 @@ describe("RecordingPlayback", () => { }); }); + describe("shared positions with a saved focus range", () => { + let capturedEngine: PlaybackEngine; + + beforeEach(() => { + vi.spyOn(ApiClient.prototype, "getRecording").mockResolvedValue({ + id: "42", worldName: "Altis", missionName: "Op Alpha", missionDuration: 100, + filename: "test-42", date: "2024-01-15", storageFormat: "json", + focusStart: 20, focusEnd: 80, + }); + vi.spyOn(ApiClient.prototype, "getMarkerBlacklist").mockResolvedValue([]); + mockLoadRecording.mockImplementation(async (_api, engine: PlaybackEngine) => { + capturedEngine = engine; + engine.loadRecording(makeManifest([], [], 100)); + return { + worldConfig: { worldName: "Altis", worldSize: 30720, maxZoom: 6, minZoom: 0 }, + missionName: "Op Alpha", recordingId: "42", recordingFilename: "test-42", + }; + }); + }); + + it.each([ + { search: "?t=0", frame: 0, mode: "FULL" }, + { search: "?t=10", frame: 10, mode: "FULL" }, + { search: "?t=90", frame: 90, mode: "FULL" }, + { search: "?t=20", frame: 20, mode: "FOCUS" }, + { search: "?t=50", frame: 50, mode: "FOCUS" }, + { search: "?t=80", frame: 80, mode: "FOCUS" }, + { search: "", frame: 20, mode: "FOCUS" }, + ])("opens '$search' at frame $frame in $mode mode", async ({ search, frame, mode }) => { + renderPlayback({ initialPath: `/recording/42/test-42${search}` }); + await vi.waitFor(() => expect(screen.getByTestId("loading-screen").style.opacity).toBe("0")); + + expect(capturedEngine.currentFrame()).toBe(frame); + expect(screen.getByText(mode)).toBeTruthy(); + await vi.waitFor(() => expect(new URLSearchParams(window.location.search).get("t")).toBe(String(frame))); + + if (mode === "FULL") { + // Restoring FULL does not discard the saved range or disable the + // viewer's ability to constrain playback again. + fireEvent.click(screen.getByText("FULL")); + expect(screen.getByText("FOCUS")).toBeTruthy(); + expect(capturedEngine.currentFrame()).toBe(Math.max(20, Math.min(80, frame))); + } + }); + }); + it("clamps frame to inFrame when seeking below focus range", async () => { let capturedEngine: PlaybackEngine; From bf241c96f614904640a315951ff832260c4bd974 Mon Sep 17 00:00:00 2001 From: Yethe Samartaka Date: Sat, 17 Oct 2026 22:13:59 +0200 Subject: [PATCH 35/39] Prefer current ORBAT slots and georeference heightmap fallback --- .../__tests__/orbat.test.ts | 41 +++++- ui/src/pages/recording-playback/orbat.ts | 15 ++- .../__tests__/heightmapFallback.test.ts | 126 ++++++++++++++++++ ui/src/playback/radioRange/demGrid.ts | 93 +++++++++---- 4 files changed, 242 insertions(+), 33 deletions(-) create mode 100644 ui/src/playback/radioRange/__tests__/heightmapFallback.test.ts diff --git a/ui/src/pages/recording-playback/__tests__/orbat.test.ts b/ui/src/pages/recording-playback/__tests__/orbat.test.ts index 8c7c493ef..9f866fd9b 100644 --- a/ui/src/pages/recording-playback/__tests__/orbat.test.ts +++ b/ui/src/pages/recording-playback/__tests__/orbat.test.ts @@ -194,10 +194,49 @@ describe("buildOrbat", () => { const group = buildOrbat(engine, "WEST", 1)[0]; expect(group.members).toHaveLength(1); - // The longer-lived entity is the real slot. + // Without per-frame control flags or a later start, lifespan breaks the tie. expect(group.members[0].unitId).toBe(1); }); + it.each([false, true])("prefers the current player over their longer-recorded corpse (reversed: %s)", (reversed) => { + const corpse = unit(1, "Player", "WEST", "Alpha", Array.from({ length: 101 }, (_, frame) => + state({ isPlayer: frame < 50, alive: frame < 50 ? 1 : 0 }))); + const replacement = new Unit(2, "Player", "man", 50, 100, "WEST", true, "Alpha", "", + Array.from({ length: 51 }, () => state({ isPlayer: true }))); + const entities = [corpse, replacement]; + const engine = fakeEngine(reversed ? entities.reverse() : entities, 100); + + expect(buildOrbat(engine, "WEST", 40)[0].members[0].unitId).toBe(1); + const group = buildOrbat(engine, "WEST", 60)[0]; + expect(group.members).toHaveLength(1); + expect(group.members[0]).toMatchObject({ unitId: 2, isPlayer: true, alive: true }); + expect(group.alive).toBe(1); + }); + + it("prefers a controlled replacement over an abandoned living body", () => { + const abandoned = unit(1, "Player", "WEST", "Alpha", [state({ isPlayer: false }), state({ isPlayer: false })]); + const controlled = unit(2, "Player", "WEST", "Alpha", [state({ isPlayer: true })]); + const group = buildOrbat(fakeEngine([abandoned, controlled], 1), "WEST", 0)[0]; + expect(group.members[0].unitId).toBe(2); + }); + + it("prefers the living, later-started respawn when legacy states omit control flags", () => { + const corpse = unit(1, "Player", "WEST", "Alpha", Array.from({ length: 101 }, () => state({ alive: 0 }))); + const replacement = new Unit(2, "Player", "man", 50, 100, "WEST", true, "Alpha", "", + Array.from({ length: 51 }, () => state())); + const group = buildOrbat(fakeEngine([corpse, replacement], 100), "WEST", 60)[0]; + expect(group.members[0].unitId).toBe(2); + expect(group.alive).toBe(1); + }); + + it("uses the most recently spawned entity when legacy bodies both remain alive", () => { + const abandoned = unit(1, "Player", "WEST", "Alpha", Array.from({ length: 101 }, () => state())); + const replacement = new Unit(2, "Player", "man", 50, 100, "WEST", true, "Alpha", "", + Array.from({ length: 51 }, () => state())); + const group = buildOrbat(fakeEngine([abandoned, replacement], 100), "WEST", 60)[0]; + expect(group.members[0].unitId).toBe(2); + }); + it("comes back empty for a side that has only AI", () => { const states = [state({ groupName: "Alpha" })]; const engine = fakeEngine( diff --git a/ui/src/pages/recording-playback/orbat.ts b/ui/src/pages/recording-playback/orbat.ts index 0de085936..6d6942037 100644 --- a/ui/src/pages/recording-playback/orbat.ts +++ b/ui/src/pages/recording-playback/orbat.ts @@ -75,11 +75,13 @@ export function buildOrbat( /** * One row per named slot, so a respawn or a reconnect does not list the same - * player twice. Both entities can be live at this frame — the recorder keeps - * writing the abandoned body — and the longer-lived one is the real slot. + * player twice. Prefer a living, currently controlled entity at this frame; + * the recorder may keep writing an abandoned body longer than its replacement. * Unnamed entities are never folded together; they are distinct bodies. */ - const bySlot = new Map(); + const bySlot = new Map(); const unnamed: Array<{ group: string; member: OrbatMember }> = []; for (const entity of engine.entityManager.getAll()) { @@ -111,8 +113,13 @@ export function buildOrbat( } const key = `${groupName}\u0000${name}`; const span = entity.endFrame - entity.startFrame; + const priority = Number(member.alive) * 2 + Number(state.isPlayer ?? entity.isPlayer); const seen = bySlot.get(key); - if (!seen || span > seen.span) bySlot.set(key, { group: groupName, member, span }); + if (!seen || priority > seen.priority || (priority === seen.priority && ( + entity.startFrame > seen.startFrame || (entity.startFrame === seen.startFrame && span > seen.span) + ))) { + bySlot.set(key, { group: groupName, member, priority, startFrame: entity.startFrame, span }); + } } for (const { group, member } of [...bySlot.values(), ...unnamed]) { diff --git a/ui/src/playback/radioRange/__tests__/heightmapFallback.test.ts b/ui/src/playback/radioRange/__tests__/heightmapFallback.test.ts new file mode 100644 index 000000000..a35989f27 --- /dev/null +++ b/ui/src/playback/radioRange/__tests__/heightmapFallback.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { loadWorldDem, resetDemCache, sampleDem, demExtent, makeFlatDem, encodeDemBinary } from "../demGrid"; +import { METERS_PER_DEGREE } from "../../../utils/coordinates"; + +const archive = vi.hoisted(() => ({ getHeader: vi.fn(), getZxy: vi.fn() })); +vi.mock("pmtiles", () => ({ PMTiles: vi.fn(function () { return archive; }) })); + +const ZOOM = 11; +const N = 2 ** ZOOM; +const TILE_SIZE = 256; +interface Bitmap { + width: number; + height: number; + data: Uint8ClampedArray; + close: () => void; +} + +// A native-size canvas blit, with transparent pixels for anything not drawn. +// No projection/cropping behavior is mocked: that must come from the loader. +class Canvas { + readonly data: Uint8ClampedArray; + constructor(readonly width: number, readonly height: number) { + this.data = new Uint8ClampedArray(width * height * 4); + } + getContext() { + return { + drawImage: (bitmap: Bitmap, x: number, y: number) => { + for (let row = 0; row < bitmap.height; row++) { + this.data.set(bitmap.data.subarray(row * bitmap.width * 4, (row + 1) * bitmap.width * 4), + ((y + row) * this.width + x) * 4); + } + }, + getImageData: () => ({ data: this.data }), + }; + } +} + +let heightAt: (x: number, y: number) => number; +let opaqueAt: (x: number, y: number) => boolean; + +beforeEach(() => { + resetDemCache(); + vi.clearAllMocks(); + heightAt = () => 100; + opaqueAt = () => true; + archive.getHeader.mockResolvedValue({ minZoom: ZOOM }); + archive.getZxy.mockImplementation(async (z: number, x: number, y: number) => { + if (y >= N / 2) return undefined; // The world has no tile south of the equator. + return { data: new TextEncoder().encode(JSON.stringify([z, x, y])).buffer }; + }); + vi.stubGlobal("OffscreenCanvas", Canvas); + vi.stubGlobal("createImageBitmap", vi.fn(async (blob: Blob): Promise => { + const [, tileX, tileY] = JSON.parse(await blob.text()) as number[]; + const data = new Uint8ClampedArray(TILE_SIZE * TILE_SIZE * 4); + for (let row = 0; row < TILE_SIZE; row++) { + const lat = Math.atan(Math.sinh(Math.PI * (1 - 2 * (tileY + (row + 0.5) / TILE_SIZE) / N))) * 180 / Math.PI; + const y = lat * METERS_PER_DEGREE; + for (let col = 0; col < TILE_SIZE; col++) { + const x = ((tileX + (col + 0.5) / TILE_SIZE) * 360 / N - 180) * METERS_PER_DEGREE; + const value = Math.round((heightAt(x, y) + 10000) * 10); + const offset = (row * TILE_SIZE + col) * 4; + data.set([value >> 16, (value >> 8) & 255, value & 255, opaqueAt(x, y) ? 255 : 0], offset); + } + } + return { width: TILE_SIZE, height: TILE_SIZE, data, close: vi.fn() }; + })); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + resetDemCache(); +}); + +const load = (worldSize = 12800) => loadWorldDem({ + tileBaseUrl: "https://maps.test/heightmap-only", worldSize, hasDem: false, hasHeightmap: true, +}); + +describe("heightmap.pmtiles fallback", () => { + it("continues to prefer the compact binary DEM when available", async () => { + const binary = encodeDemBinary(makeFlatDem(12800, 40, 100)); + const fetch = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new Uint8Array(binary).buffer })); + vi.stubGlobal("fetch", fetch); + const grid = await loadWorldDem({ tileBaseUrl: "https://maps.test/binary", worldSize: 12800, hasDem: true }); + expect(grid).toBeDefined(); + expect(sampleDem(grid!, 100, 100)).toBeCloseTo(40); + expect(archive.getHeader).not.toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledOnce(); + }); + + it("excludes southern tiles and preserves a constant height to the world boundaries", async () => { + const grid = await load(); + expect(grid).toBeDefined(); + expect(demExtent(grid!)).toEqual({ minX: 0, minY: 0, maxX: 12800, maxY: 12800 }); + for (const [x, y] of [[0, 0], [100, 100], [6400, 6400], [12800, 12800]]) { + expect(sampleDem(grid!, x, y)).toBeCloseTo(100); + } + expect(archive.getZxy.mock.calls.every(([, , y]) => y < N / 2)).toBe(true); + }); + + it.each([12800, 42000])("maps pixel elevations to geographic world coordinates for a %s m world", async (worldSize) => { + heightAt = (x, y) => 100 + x / 100 + y / 50; + const grid = await load(worldSize); + expect(grid).toBeDefined(); + for (const [x, y] of [[1000, 1000], [worldSize / 2, worldSize * 0.75], [worldSize - 1000, 2000]]) { + expect(sampleDem(grid!, x, y)).toBeCloseTo(heightAt(x, y), 0); + } + }); + + it("ignores transparent padding outside the geographic world bounds", async () => { + opaqueAt = (x, y) => x >= 0 && y >= 0 && x <= 12800 && y <= 12800; + const grid = await load(); + expect(grid).toBeDefined(); + expect(sampleDem(grid!, 12800, 12800)).toBeCloseTo(100); + }); + + it("declines terrain with transparent holes rather than decoding them as -10000 metres", async () => { + opaqueAt = (x, y) => !(x > 4000 && x < 8000 && y > 4000 && y < 8000); + expect(await load() === undefined).toBe(true); + }); + + it("declines an incomplete set of in-world tiles", async () => { + const original = archive.getZxy.getMockImplementation()!; + archive.getZxy.mockImplementation((z, x, y) => x === N / 2 + 1 ? undefined : original(z, x, y)); + expect(await load(42000) === undefined).toBe(true); + }); +}); diff --git a/ui/src/playback/radioRange/demGrid.ts b/ui/src/playback/radioRange/demGrid.ts index 3889cd92d..4b5b07564 100644 --- a/ui/src/playback/radioRange/demGrid.ts +++ b/ui/src/playback/radioRange/demGrid.ts @@ -1,4 +1,5 @@ import { basePath } from "../../data/basePath"; +import { METERS_PER_DEGREE } from "../../utils/coordinates"; /** * In-memory heightmap in Arma metres. Row 0 is south, matching the map-tool DEM. @@ -303,6 +304,7 @@ async function loadWorldDemUncached(opts: { } async function loadDemFromHeightmapPmtiles(url: string, worldSize: number): Promise { + const tiles: { x: number; y: number; bitmap: ImageBitmap }[] = []; try { const { PMTiles } = await import("pmtiles"); const archive = new PMTiles(url); @@ -311,48 +313,58 @@ async function loadDemFromHeightmapPmtiles(url: string, worldSize: number): Prom const n = 2 ** z; const west = 0; const south = 0; - const east = worldSize / 111320; - const north = worldSize / 111320; - const minX = lngToTile(west, n); - const maxX = lngToTile(east, n); - const minY = latToTile(north, n); - const maxY = latToTile(south, n); - - const tiles: { x: number; y: number; bitmap: ImageBitmap }[] = []; + const east = worldSize / METERS_PER_DEGREE; + const north = worldSize / METERS_PER_DEGREE; + // Fetch only tiles whose interior intersects the world. In particular, + // the equator is a tile edge, not a reason to request the southern tile. + const minX = Math.floor(lngToTile(west, n)); + const maxX = Math.ceil(lngToTile(east, n)) - 1; + const minY = Math.floor(latToTile(north, n)); + const maxY = Math.ceil(latToTile(south, n)) - 1; + let tileSize = 0; for (let x = minX; x <= maxX; x++) { for (let y = minY; y <= maxY; y++) { const tile = await archive.getZxy(z, x, y); - if (!tile?.data) continue; + // Incomplete terrain must fall back to distance-only coverage rather + // than inventing elevations from the canvas's transparent pixels. + if (!tile?.data) return undefined; const blob = new Blob([tile.data], { type: "image/png" }); - const bitmap = await createImageBitmap(blob); - tileSize = bitmap.width; + const bitmap = await createImageBitmap(blob, { colorSpaceConversion: "none", premultiplyAlpha: "none" }); tiles.push({ x, y, bitmap }); + if (bitmap.width !== bitmap.height || (tileSize && bitmap.width !== tileSize)) return undefined; + tileSize = bitmap.width; } } if (!tiles.length || tileSize <= 0) return undefined; - const cols = (maxX - minX + 1) * tileSize; - const rows = (maxY - minY + 1) * tileSize; - const canvas = new OffscreenCanvas(cols, rows); + const width = (maxX - minX + 1) * tileSize; + const height = (maxY - minY + 1) * tileSize; + const canvas = new OffscreenCanvas(width, height); const ctx = canvas.getContext("2d"); if (!ctx) return undefined; for (const tile of tiles) { ctx.drawImage(tile.bitmap, (tile.x - minX) * tileSize, (tile.y - minY) * tileSize); } - const image = ctx.getImageData(0, 0, cols, rows); + const image = ctx.getImageData(0, 0, width, height); + // A tile rectangle generally exceeds the world bounds. Resample at regular + // Arma-metre intervals using each point's actual Mercator pixel location, + // rather than stretching that rectangle onto the world. Pixels are centred + // half a pixel inside their tile; DemGrid samples include both world edges. + const steps = Math.max(1, Math.ceil((lngToTile(east, n) - lngToTile(west, n)) * tileSize)); + const cols = steps + 1; + const rows = cols; + const cellSize = worldSize / steps; const data = new Float32Array(cols * rows); - for (let i = 0; i < cols * rows; i++) { - const o = i * 4; - const val = (image.data[o] << 16) | (image.data[o + 1] << 8) | image.data[o + 2]; - data[i] = val / 10 - 10000; - } - // Canvas row 0 is north; DemGrid row 0 is south. - const flipped = new Float32Array(cols * rows); for (let r = 0; r < rows; r++) { - flipped.set(data.subarray((rows - 1 - r) * cols, (rows - r) * cols), r * cols); + const py = (latToTile(r * cellSize / METERS_PER_DEGREE, n) - minY) * tileSize - 0.5; + for (let c = 0; c < cols; c++) { + const px = (lngToTile(c * cellSize / METERS_PER_DEGREE, n) - minX) * tileSize - 0.5; + const elevation = sampleTerrainRgb(image.data, width, height, px, py); + if (elevation === undefined) return undefined; + data[r * cols + c] = elevation; + } } - const cellSize = worldSize / Math.max(cols - 1, 1); return { cols, rows, @@ -360,19 +372,44 @@ async function loadDemFromHeightmapPmtiles(url: string, worldSize: number): Prom originY: 0, cellSize, worldSize, - data: flipped, + data, }; } catch { return undefined; + } finally { + for (const { bitmap } of tiles) bitmap.close(); + } +} + +/** Interpolate heights, not RGB channels, excluding transparent no-data padding. */ +function sampleTerrainRgb(pixels: Uint8ClampedArray, width: number, height: number, x: number, y: number): number | undefined { + const px = Math.max(0, Math.min(width - 1, x)); + const py = Math.max(0, Math.min(height - 1, y)); + const x0 = Math.floor(px); + const y0 = Math.floor(py); + const tx = px - x0; + const ty = py - y0; + let total = 0; + let weights = 0; + for (let dy = 0; dy <= 1; dy++) { + for (let dx = 0; dx <= 1; dx++) { + const weight = (dx ? tx : 1 - tx) * (dy ? ty : 1 - ty); + const offset = (Math.min(height - 1, y0 + dy) * width + Math.min(width - 1, x0 + dx)) * 4; + if (weight === 0 || pixels[offset + 3] !== 255) continue; + const value = (pixels[offset] << 16) | (pixels[offset + 1] << 8) | pixels[offset + 2]; + total += (value / 10 - 10000) * weight; + weights += weight; + } } + return weights > 0 ? total / weights : undefined; } function lngToTile(lng: number, n: number): number { - return Math.max(0, Math.min(n - 1, Math.floor(((lng + 180) / 360) * n))); + return Math.max(0, Math.min(n, ((lng + 180) / 360) * n)); } function latToTile(lat: number, n: number): number { const rad = (lat * Math.PI) / 180; - const y = Math.floor(((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n); - return Math.max(0, Math.min(n - 1, y)); + const y = ((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n; + return Math.max(0, Math.min(n, y)); } From 183a54f3f8ef7bc0e1a7c290947fbd227a123655 Mon Sep 17 00:00:00 2001 From: Yethe Samartaka Date: Sat, 17 Oct 2026 22:24:19 +0200 Subject: [PATCH 36/39] Restore manifest roles in protobuf ORBAT --- .../convertedOrbat.integration.test.ts | 56 +++++++++++++++++++ .../__tests__/orbat.test.ts | 20 ++++++- ui/src/pages/recording-playback/orbat.ts | 3 +- 3 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 ui/src/pages/recording-playback/__tests__/convertedOrbat.integration.test.ts diff --git a/ui/src/pages/recording-playback/__tests__/convertedOrbat.integration.test.ts b/ui/src/pages/recording-playback/__tests__/convertedOrbat.integration.test.ts new file mode 100644 index 000000000..8b022c27c --- /dev/null +++ b/ui/src/pages/recording-playback/__tests__/convertedOrbat.integration.test.ts @@ -0,0 +1,56 @@ +import { expect, it, vi } from "vitest"; +import type { ApiClient } from "../../../data/apiClient"; +import { ChunkManager } from "../../../data/chunkManager"; +import { ProtobufDecoder } from "../../../data/decoders/protobufDecoder"; +import { + Chunk as PbChunk, + Manifest as PbManifest, + EntityType as PbEntityType, + Side as PbSide, +} from "../../../data/decoders/generated/ocap.pb"; +import { PlaybackEngine } from "../../../playback/engine"; +import { MockRenderer } from "../../../renderers/mockRenderer"; +import { buildOrbat, isLeader } from "../orbat"; + +const buffer = (bytes: Uint8Array): ArrayBuffer => new Uint8Array(bytes).buffer; + +it("preserves manifest roles and leader priority through actual protobuf chunk playback", async () => { + const manifest = PbManifest.fromPartial({ + version: 1, worldName: "altis", missionName: "ORBAT roles", endFrame: 1, + chunkSize: 300, chunkCount: 1, captureDelayMs: 1000, + entities: [ + { id: 1, name: "Aaron", role: "Rifleman" }, + { id: 2, name: "Zulu", role: "Squad Leader" }, + ].map((entity) => ({ + ...entity, type: PbEntityType.ENTITY_TYPE_UNIT, side: PbSide.SIDE_WEST, + groupName: "Alpha", isPlayer: true, startFrame: 0, endFrame: 1, + })), + }); + const chunk = PbChunk.fromPartial({ + startFrame: 0, frameCount: 2, + frames: [0, 1].map((frameNum) => ({ + frameNum, entities: [1, 2].map((entityId) => ({ + entityId, posX: 100, posY: 200, alive: 1, isPlayer: true, + })), + })), + }); + const api = { + getManifest: vi.fn(async () => buffer(PbManifest.encode(manifest).finish())), + getChunk: vi.fn(async () => buffer(PbChunk.encode(chunk).finish())), + } as unknown as ApiClient; + const cache = new ChunkManager(new ProtobufDecoder(), api); + const decodedManifest = await cache.loadManifest("orbat-roles"); + await cache.loadChunk(0); + const engine = new PlaybackEngine(new MockRenderer()); + try { + engine.loadRecording(decodedManifest, cache); + engine.seekTo(1); + expect(engine.entityManager.getEntity(1)?.positions).toBeNull(); + expect(engine.getStateAt(1, 1)?.role).toBeUndefined(); + const [group] = buildOrbat(engine, "WEST", 1); + expect(group.members.map((member) => [member.name, member.role, isLeader(member.role)])) + .toEqual([["Zulu", "Squad Leader", true], ["Aaron", "Rifleman", false]]); + } finally { + engine.dispose(); + } +}); diff --git a/ui/src/pages/recording-playback/__tests__/orbat.test.ts b/ui/src/pages/recording-playback/__tests__/orbat.test.ts index 9f866fd9b..fc4c6add4 100644 --- a/ui/src/pages/recording-playback/__tests__/orbat.test.ts +++ b/ui/src/pages/recording-playback/__tests__/orbat.test.ts @@ -103,9 +103,9 @@ describe("buildOrbat", () => { const states = [state({ groupName: "Alpha" })]; const engine = fakeEngine( [ - unit(1, "Zulu", "WEST", "Alpha", states, { role: "Rifleman" }), + unit(1, "Mike", "WEST", "Alpha", states, { role: "Rifleman" }), unit(2, "Alpha AI", "WEST", "Alpha", states, { role: "Rifleman", isPlayer: false }), - unit(3, "Boss", "WEST", "Alpha", states, { role: "Squad Leader" }), + unit(3, "Zulu", "WEST", "Alpha", states, { role: "Squad Leader" }), ], 0, ); @@ -113,7 +113,21 @@ describe("buildOrbat", () => { // playersOnly is off here so the player/AI tiebreaker is actually exercised; // the default roster would drop the AI before the sort ever saw it. const members = buildOrbat(engine, "WEST", 0, { playersOnly: false })[0].members; - expect(members.map((m) => m.name)).toEqual(["Boss", "Zulu", "Alpha AI"]); + expect(members.map((m) => m.name)).toEqual(["Zulu", "Mike", "Alpha AI"]); + }); + + it("uses per-frame roles for promotions and demotions ahead of recorded roles", () => { + const promoted = unit(1, "Aaron", "WEST", "Alpha", [ + state({ role: "Rifleman" }), state({ role: "Squad Leader" }), + ], { role: "Rifleman" }); + const demoted = unit(2, "Zulu", "WEST", "Alpha", [ + state(), state({ role: "Rifleman" }), + ], { role: "Squad Leader" }); + const engine = fakeEngine([promoted, demoted], 1); + expect(buildOrbat(engine, "WEST", 0)[0].members.map((member) => [member.name, member.role])) + .toEqual([["Zulu", "Squad Leader"], ["Aaron", "Rifleman"]]); + expect(buildOrbat(engine, "WEST", 1)[0].members.map((member) => [member.name, member.role])) + .toEqual([["Aaron", "Squad Leader"], ["Zulu", "Rifleman"]]); }); // ── Players only ── diff --git a/ui/src/pages/recording-playback/orbat.ts b/ui/src/pages/recording-playback/orbat.ts index 6d6942037..31b342343 100644 --- a/ui/src/pages/recording-playback/orbat.ts +++ b/ui/src/pages/recording-playback/orbat.ts @@ -100,7 +100,8 @@ export function buildOrbat( const member: OrbatMember = { unitId: entity.id, name, - role: state.role ?? "", + // Protobuf chunks omit roles; the manifest retains the recorded role. + role: state.role ?? entity.role, isPlayer, alive: !!state.alive, mounted: state.isInVehicle === true, From 8d3c301445fb6ad01f39d02daff5c4b19cb8bbc8 Mon Sep 17 00:00:00 2001 From: Yethe Samartaka Date: Sat, 17 Oct 2026 22:47:42 +0200 Subject: [PATCH 37/39] Fix TFAR coverage bounds and Zeus placeholder replacement --- .../playback/__tests__/entityManager.test.ts | 13 +++++++++++ ui/src/playback/__tests__/zeus.test.ts | 22 ++++++++++++++++--- ui/src/playback/engine.ts | 4 ++-- ui/src/playback/entityManager.ts | 6 +++++ .../radioRange/__tests__/tfarCoverage.test.ts | 15 +++++++++++++ ui/src/playback/radioRange/tfarCoverage.ts | 3 ++- 6 files changed, 57 insertions(+), 6 deletions(-) diff --git a/ui/src/playback/__tests__/entityManager.test.ts b/ui/src/playback/__tests__/entityManager.test.ts index 1258047d3..f06a1f7eb 100644 --- a/ui/src/playback/__tests__/entityManager.test.ts +++ b/ui/src/playback/__tests__/entityManager.test.ts @@ -615,6 +615,19 @@ describe("EntityManager", () => { }); }); + it("bypasses lifespan selection only for an explicit replacement", () => { + const placeholder = mgr.addEntity(placeholderDef({ endFrame: 200 })); + const other = mgr.addEntity(unitDef({ id: 1 })); + const definition = unitDef({ id: 0, type: "zeus", side: "VIRTUAL", startFrame: 1, endFrame: 200 }); + expect(mgr.addEntity(definition)).toBe(placeholder); + const replacement = mgr.replaceEntity(definition); + expect(replacement).toBeInstanceOf(Unit); + expect(replacement.type).toBe("zeus"); + expect(mgr.getEntity(0)).toBe(replacement); + expect(mgr.getEntity(1)).toBe(other); + expect(mgr.getAll()).toHaveLength(2); + }); + describe("getEntity", () => { it("returns entity by ID", () => { mgr.addEntity(unitDef({ id: 1 })); diff --git a/ui/src/playback/__tests__/zeus.test.ts b/ui/src/playback/__tests__/zeus.test.ts index 977b949a6..dd4f72d99 100644 --- a/ui/src/playback/__tests__/zeus.test.ts +++ b/ui/src/playback/__tests__/zeus.test.ts @@ -345,7 +345,7 @@ describe("PlaybackEngine Zeus synthesis", () => { expect(possessing?.fov).toBeUndefined(); }); - it("synthesizes VIRTUAL Zeus when curatorId 0 is an empty entity hole", () => { + it.each([0, 1])("synthesizes VIRTUAL Zeus starting at frame %s when curatorId 0 is an empty entity hole", (startFrame) => { engine.loadRecording(makeManifest({ entities: [ unitDef({ @@ -362,12 +362,12 @@ describe("PlaybackEngine Zeus synthesis", () => { ], events: [ { - frameNum: 1, + frameNum: startFrame, type: "zeusEntity", payload: { curatorId: 0, name: "YetheSamartaka", playerUid: "7656", bodyUnitId: -1 }, }, { - frameNum: 1, + frameNum: startFrame, type: "zeusCamera", payload: { curatorId: 0, x: 8504, y: -902, dir: 0, fov: 1 }, }, @@ -379,12 +379,28 @@ describe("PlaybackEngine Zeus synthesis", () => { expect(zeus?.type).toBe("zeus"); expect((zeus as Unit).side).toBe("VIRTUAL"); expect(zeus?.name).toBe("YetheSamartaka"); + expect(zeus?.startFrame).toBe(startFrame); + expect(engine.entityManager.getEntity(1)?.name).toBe("Horacek"); expect(engine.entityManager.getBySide("VIRTUAL")).toHaveLength(1); engine.seekTo(1); expect(engine.entitySnapshots().get(0)?.iconType).toBe("zeus"); expect(engine.entitySnapshots().get(0)?.position).toEqual([8504, -902]); }); + + it.each([ + { type: "man" as const, name: "Real Player" }, + { type: "car" as const, name: "" }, + ])("preserves a real $type entity when a Zeus event reuses its ID", ({ type, name }) => { + engine.loadRecording(makeManifest({ + entities: [unitDef({ id: 0, type, name })], + events: [{ frameNum: 1, type: "zeusEntity", + payload: { curatorId: 0, name: "Curator", playerUid: "7656", bodyUnitId: -1 } }], + })); + expect(engine.entityManager.getEntity(0)?.type).toBe(type); + expect(engine.entityManager.getEntity(0)?.name).toBe(name); + expect(engine.entityManager.getAll()).toHaveLength(1); + }); }); describe("Zeus pings", () => { diff --git a/ui/src/playback/engine.ts b/ui/src/playback/engine.ts index 982be6807..02807671b 100644 --- a/ui/src/playback/engine.ts +++ b/ui/src/playback/engine.ts @@ -444,8 +444,8 @@ export class PlaybackEngine { // export leaves a nameless hole at that index. Do not treat the hole as // a real unit — overwrite it so VIRTUAL playback can use the id. if (existing && existing.type === "zeus") continue; - if (existing && existing.name) continue; - this.entityManager.addEntity({ + if (existing && (existing.name || existing.type !== "unknown")) continue; + this.entityManager.replaceEntity({ id: zeus.curatorId, type: "zeus", name: zeus.name, diff --git a/ui/src/playback/entityManager.ts b/ui/src/playback/entityManager.ts index 1c122e78f..5123a6cd5 100644 --- a/ui/src/playback/entityManager.ts +++ b/ui/src/playback/entityManager.ts @@ -60,6 +60,12 @@ export class EntityManager { return entity; } + /** Explicit replacement, bypassing normal duplicate-ID lifetime selection. */ + replaceEntity(def: EntityDef): Entity { + this.entities.delete(def.id); + return this.addEntity(def); + } + /** Look up an entity by ID. */ getEntity(id: number): Entity | null { return this.entities.get(id) ?? null; diff --git a/ui/src/playback/radioRange/__tests__/tfarCoverage.test.ts b/ui/src/playback/radioRange/__tests__/tfarCoverage.test.ts index 90d3a8a7d..e151322e9 100644 --- a/ui/src/playback/radioRange/__tests__/tfarCoverage.test.ts +++ b/ui/src/playback/radioRange/__tests__/tfarCoverage.test.ts @@ -102,6 +102,21 @@ describe("inTfarRange", () => { }); describe("marchCoverage", () => { + it.each([0.5, 2])("matches the reachability radius with a range multiplier of %s", (coefficient) => { + const dem = makeFlatDem(30000, 0, 100); + const origin: [number, number] = [15000, 15000]; + const propagation = { ...TFAR_DEFAULT_PROPAGATION, globalRadioRangeCoef: coefficient }; + const radius = 5000 * coefficient; + const verts = marchCoverage(dem, origin, { rangeMeters: 5000, propagation, bearings: 16 }); + expect(verts).toHaveLength(16); + for (const [x, y] of verts) { + expect(Math.hypot(x - origin[0], y - origin[1])).toBeCloseTo(radius, -1); + expect(inTfarRange(dem, ...origin, 1.6, x, y, 1.6, 5000, propagation)).toBe(true); + } + expect(inTfarRange(dem, ...origin, 1.6, origin[0] + radius * 0.75, origin[1], 1.6, 5000, propagation)).toBe(true); + expect(inTfarRange(dem, ...origin, 1.6, origin[0] + radius + 10, origin[1], 1.6, 5000, propagation)).toBe(false); + }); + it("is a circle of rangeMeters on a flat DEM", () => { const dem = makeFlatDem(12000, 0, 50); const verts = marchCoverage(dem, [4000, 4000], { diff --git a/ui/src/playback/radioRange/tfarCoverage.ts b/ui/src/playback/radioRange/tfarCoverage.ts index e1bee1e9c..7893f27ff 100644 --- a/ui/src/playback/radioRange/tfarCoverage.ts +++ b/ui/src/playback/radioRange/tfarCoverage.ts @@ -129,6 +129,7 @@ export function marchCoverage( options: CoverageOptions, ): [number, number][] { const range = Math.max(0, options.rangeMeters); + const maxDistance = range * options.propagation.globalRadioRangeCoef; const eye = options.eyeHeightMeters ?? TFAR_EYE_HEIGHT_METERS; const canopy = options.canopyMeters ?? 0; const bearings = Math.max(8, options.bearings ?? DEFAULT_BEARINGS); @@ -145,7 +146,7 @@ export function marchCoverage( const dx = Math.cos(angle); const dy = Math.sin(angle); let lo = 0; - let hi = range; + let hi = maxDistance; for (let step = 0; step < 12; step++) { const mid = (lo + hi) / 2; const rx = tx + dx * mid; From af4c2b569f06825284bc16ddc9c0988623d7e414 Mon Sep 17 00:00:00 2001 From: Yethe Samartaka Date: Sat, 17 Oct 2026 23:25:21 +0200 Subject: [PATCH 38/39] Center paused follow targets after asynchronous seeks --- .../__tests__/RecordingPlayback.test.tsx | 97 +++++++++++++++++++ ui/src/playback/engine.ts | 3 + 2 files changed, 100 insertions(+) diff --git a/ui/src/pages/recording-playback/__tests__/RecordingPlayback.test.tsx b/ui/src/pages/recording-playback/__tests__/RecordingPlayback.test.tsx index d0cb91bed..22ae7a40e 100644 --- a/ui/src/pages/recording-playback/__tests__/RecordingPlayback.test.tsx +++ b/ui/src/pages/recording-playback/__tests__/RecordingPlayback.test.tsx @@ -6,6 +6,9 @@ import { CustomizeProvider } from "../../../hooks/useCustomize"; import { AuthProvider } from "../../../hooks/useAuth"; import { MockRenderer } from "../../../renderers/mockRenderer"; import { ApiClient, setAuthToken } from "../../../data/apiClient"; +import { ChunkManager } from "../../../data/chunkManager"; +import { ProtobufDecoder } from "../../../data/decoders/protobufDecoder"; +import { Chunk as PbChunk, Manifest as PbManifest, EntityType as PbEntityType, Side as PbSide } from "../../../data/decoders/generated/ocap.pb"; import type { LoadResult } from "../loadRecording"; import { makeManifest } from "./testHelpers"; @@ -941,6 +944,100 @@ describe("RecordingPlayback", () => { }); }); + describe("shared follow targets in unloaded protobuf chunks", () => { + let capturedEngine: PlaybackEngine; + let cache: ChunkManager; + let finishChunk: () => void; + let requests: number[]; + + beforeEach(() => { + requests = []; + vi.spyOn(ApiClient.prototype, "getRecording").mockResolvedValue({ + id: "42", worldName: "Altis", missionName: "Op Alpha", missionDuration: 900, + filename: "test-42", date: "2024-01-15", storageFormat: "protobuf", + }); + vi.spyOn(ApiClient.prototype, "getMarkerBlacklist").mockResolvedValue([]); + const buffer = (bytes: Uint8Array): ArrayBuffer => new Uint8Array(bytes).buffer; + const manifest = PbManifest.fromPartial({ + version: 1, worldName: "Altis", missionName: "Op Alpha", endFrame: 899, + chunkSize: 300, chunkCount: 3, captureDelayMs: 1000, + entities: [{ id: 1, name: "Player", type: PbEntityType.ENTITY_TYPE_UNIT, + side: PbSide.SIDE_WEST, isPlayer: true, startFrame: 0, endFrame: 899 }], + }); + const chunk = (index: number) => buffer(PbChunk.encode(PbChunk.fromPartial({ + startFrame: index * 300, frameCount: 300, + frames: Array.from({ length: 300 }, (_, offset) => ({ + frameNum: index * 300 + offset, + entities: [{ entityId: 1, posX: index === 2 ? 9000 : 1000, posY: index === 2 ? 8000 : 2000, alive: 1 }], + })), + })).finish()); + const pending = new Promise((resolve) => { finishChunk = () => resolve(chunk(2)); }); + const api = { + getManifest: async () => buffer(PbManifest.encode(manifest).finish()), + getChunk: async (_filename: string, index: number) => { + requests.push(index); + return index === 2 ? pending : chunk(index); + }, + } as unknown as ApiClient; + mockLoadRecording.mockImplementation(async (_api, engine: PlaybackEngine) => { + capturedEngine = engine; + cache = new ChunkManager(new ProtobufDecoder(), api); + const decoded = await cache.loadManifest("test-42"); + await cache.loadChunk(0); + engine.loadRecording(decoded, cache); + return { + worldConfig: { worldName: "Altis", worldSize: 30720, maxZoom: 6, minZoom: 0 }, + missionName: "Op Alpha", recordingId: "42", recordingFilename: "test-42", + }; + }); + }); + + async function openLink() { + renderPlayback({ initialPath: "/recording/42/test-42?renderer=dom&t=650&u=1&tab=units" }); + await vi.waitFor(() => expect(screen.getByTestId("loading-screen").style.opacity).toBe("0")); + expect(capturedEngine.currentFrame()).toBe(650); + expect(capturedEngine.followTarget()).toBe(1); + expect(capturedEngine.entitySnapshots().has(1)).toBe(false); + expect(requests).toEqual([0, 2]); + } + + it("centers the shared unit once its delayed chunk arrives while playback stays paused", async () => { + const pan = vi.spyOn(MockRenderer.prototype, "setView"); + await openLink(); + expect(pan).not.toHaveBeenCalled(); + finishChunk(); + await vi.waitFor(() => expect(pan).toHaveBeenCalledWith([9000, 8000, 0])); + expect(capturedEngine.entitySnapshots().get(1)?.position).toEqual([9000, 8000, 0]); + expect(capturedEngine.isPlaying()).toBe(false); + expect(capturedEngine.currentFrame()).toBe(650); + }); + + it("respects unfollowing before the shared unit's chunk arrives", async () => { + const pan = vi.spyOn(MockRenderer.prototype, "setView"); + await openLink(); + capturedEngine.unfollowEntity(); + finishChunk(); + await cache.loadChunk(2); + await Promise.resolve(); + expect(capturedEngine.entitySnapshots().get(1)?.position).toEqual([9000, 8000, 0]); + expect(pan).not.toHaveBeenCalled(); + }); + + it("does not recenter on a stale linked frame after the viewer seeks elsewhere", async () => { + const pan = vi.spyOn(MockRenderer.prototype, "setView"); + await openLink(); + capturedEngine.seekTo(50); + await vi.waitFor(() => expect(pan).toHaveBeenCalledWith([1000, 2000, 0])); + pan.mockClear(); + finishChunk(); + await cache.loadChunk(2); + await Promise.resolve(); + expect(capturedEngine.currentFrame()).toBe(50); + expect(capturedEngine.entitySnapshots().get(1)?.position).toEqual([1000, 2000, 0]); + expect(pan).not.toHaveBeenCalled(); + }); + }); + describe("shared positions with a saved focus range", () => { let capturedEngine: PlaybackEngine; diff --git a/ui/src/playback/engine.ts b/ui/src/playback/engine.ts index 02807671b..a8db8caa5 100644 --- a/ui/src/playback/engine.ts +++ b/ui/src/playback/engine.ts @@ -336,6 +336,9 @@ export class PlaybackEngine { if (this.chunkManager === chunks && this._currentFrame() === clamped) { this.computeSnapshots(clamped); this._setActiveEvents(this.eventManager.getActiveEvents(clamped)); + // A paused shared link may begin following before its chunk exists. + const target = this._followTarget(); + if (target !== null) this.panToEntity(target); } }).catch((error) => { if (this.chunkManager === chunks) console.error(`Failed to load playback frame ${clamped}`, error); From 80fe9cafd13187f2eddbf84481addbb952322a7d Mon Sep 17 00:00:00 2001 From: Yethe Samartaka Date: Sat, 17 Oct 2026 23:45:42 +0200 Subject: [PATCH 39/39] Preserve transmission timeout across repeated starts --- ui/src/playback/__tests__/comms.test.ts | 23 +++++++++++++++++++++++ ui/src/playback/comms.ts | 6 +++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/ui/src/playback/__tests__/comms.test.ts b/ui/src/playback/__tests__/comms.test.ts index 3b9f89d56..cac6b4130 100644 --- a/ui/src/playback/__tests__/comms.test.ts +++ b/ui/src/playback/__tests__/comms.test.ts @@ -346,6 +346,29 @@ describe("pairTransmissions", () => { expect(result).toHaveLength(2); expect(result[0].endFrame).toBe(20); expect(result[1].startFrame).toBe(20); + expect(transmissionsAtFrame(result, 21)).toEqual([result[1]]); + }); + + it("keeps the missing-Stop cap when the same net is keyed much later", () => { + const result = pairTransmissions([tx(0, {}), tx(3600, {})]); + + expect(result).toHaveLength(2); + expect(result[0].endFrame).toBe(TRANSMISSION_MAX_FRAMES); + expect(result[0].openEnded).toBe(true); + expect(transmissionsAtFrame(result, TRANSMISSION_MAX_FRAMES)).toEqual([result[0]]); + expect(transmissionsAtFrame(result, TRANSMISSION_MAX_FRAMES + 1)).toEqual([]); + expect(transmissionsAtFrame(result, 1800)).toEqual([]); + expect(transmissionsAtFrame(result, 3600)).toEqual([result[1]]); + expect(result[1].endFrame).toBe(3600 + TRANSMISSION_MAX_FRAMES); + }); + + it("allows a recorded Stop to close a transmission beyond the missing-Stop cap", () => { + const result = pairTransmissions([tx(0, {}), tx(120, { action: "Stop" })]); + + expect(result).toHaveLength(1); + expect(result[0].endFrame).toBe(120); + expect(result[0].openEnded).toBe(false); + expect(transmissionsAtFrame(result, 90)).toEqual(result); }); it("keeps two speakers on one net apart", () => { diff --git a/ui/src/playback/comms.ts b/ui/src/playback/comms.ts index 412243de2..96dd7126d 100644 --- a/ui/src/playback/comms.ts +++ b/ui/src/playback/comms.ts @@ -307,11 +307,11 @@ export function pairTransmissions(events: RawTransmission[]): Transmission[] { continue; } - // A second Start without a Stop closes the first at this frame: the player - // is demonstrably still talking, and leaving both open would double-count. + // A second Start closes the first no later than its missing-Stop cap: + // a later Start does not prove continuous activity during the gap. const previous = open.get(pairKey); if (previous) { - previous.endFrame = event.frameNum; + previous.endFrame = Math.min(previous.endFrame, event.frameNum); open.delete(pairKey); done.push(previous); }