diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 854990d94..8184e8e99 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/README.md b/README.md index 41133d955..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,8 +326,156 @@ 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 + ├── 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 +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/assets/markers/ocap/fort_barrier.svg b/assets/markers/ocap/fort_barrier.svg new file mode 100644 index 000000000..01e188369 --- /dev/null +++ b/assets/markers/ocap/fort_barrier.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/markers/ocap/fort_bunker.svg b/assets/markers/ocap/fort_bunker.svg new file mode 100644 index 000000000..cbd835ef6 --- /dev/null +++ b/assets/markers/ocap/fort_bunker.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/markers/ocap/fort_sandbag.svg b/assets/markers/ocap/fort_sandbag.svg new file mode 100644 index 000000000..85a340aec --- /dev/null +++ b/assets/markers/ocap/fort_sandbag.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/assets/markers/ocap/fort_static.svg b/assets/markers/ocap/fort_static.svg new file mode 100644 index 000000000..4602a8044 --- /dev/null +++ b/assets/markers/ocap/fort_static.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/markers/ocap/fort_tent.svg b/assets/markers/ocap/fort_tent.svg new file mode 100644 index 000000000..3159cc542 --- /dev/null +++ b/assets/markers/ocap/fort_tent.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/markers/ocap/fort_wire.svg b/assets/markers/ocap/fort_wire.svg new file mode 100644 index 000000000..5ae5d48f9 --- /dev/null +++ b/assets/markers/ocap/fort_wire.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/markers/ocap/fortification.svg b/assets/markers/ocap/fortification.svg new file mode 100644 index 000000000..fe357f2bd --- /dev/null +++ b/assets/markers/ocap/fortification.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/markers/ocap/trench.svg b/assets/markers/ocap/trench.svg new file mode 100644 index 000000000..10d0ad3d6 --- /dev/null +++ b/assets/markers/ocap/trench.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/markers/ocap/trench_big.svg b/assets/markers/ocap/trench_big.svg new file mode 100644 index 000000000..10d0ad3d6 --- /dev/null +++ b/assets/markers/ocap/trench_big.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/markers/ocap/trench_giant.svg b/assets/markers/ocap/trench_giant.svg new file mode 100644 index 000000000..f72a07fbe --- /dev/null +++ b/assets/markers/ocap/trench_giant.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/markers/ocap/trench_long.svg b/assets/markers/ocap/trench_long.svg new file mode 100644 index 000000000..f2cbc0760 --- /dev/null +++ b/assets/markers/ocap/trench_long.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/markers/ocap/trench_short.svg b/assets/markers/ocap/trench_short.svg new file mode 100644 index 000000000..00efc8108 --- /dev/null +++ b/assets/markers/ocap/trench_short.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/markers/ocap/trench_small.svg b/assets/markers/ocap/trench_small.svg new file mode 100644 index 000000000..6e341e5d4 --- /dev/null +++ b/assets/markers/ocap/trench_small.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/markers/ocap/trench_vehicle.svg b/assets/markers/ocap/trench_vehicle.svg new file mode 100644 index 000000000..d9adb7e3e --- /dev/null +++ b/assets/markers/ocap/trench_vehicle.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/markers/ocap/zeus.svg b/assets/markers/ocap/zeus.svg new file mode 100644 index 000000000..71f59a052 --- /dev/null +++ b/assets/markers/ocap/zeus.svg @@ -0,0 +1,7 @@ + + + + + + + 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/cmd/ocap-webserver/main.go b/cmd/ocap-webserver/main.go index eae6df85f..7f2e342b5 100644 --- a/cmd/ocap-webserver/main.go +++ b/cmd/ocap-webserver/main.go @@ -111,29 +111,40 @@ func app() error { var handlerOpts []server.HandlerOption handlerOpts = append(handlerOpts, server.WithStaticFS(staticFS)) - if setting.Conversion.Enabled { - interval, err := time.ParseDuration(setting.Conversion.Interval) - if err != nil { - log.Printf("Invalid conversion interval %q, using default 5m", setting.Conversion.Interval) - interval = 5 * time.Minute - } - worker := conversion.NewWorker( - operation, - conversion.Config{ - DataDir: setting.Data, - Interval: interval, - BatchSize: setting.Conversion.BatchSize, - ChunkSize: setting.Conversion.ChunkSize, - RetryFailed: setting.Conversion.RetryFailed, - }, - ) + interval, err := time.ParseDuration(setting.Conversion.Interval) + if err != nil { + log.Printf("Invalid conversion interval %q, using default 5m", setting.Conversion.Interval) + interval = 5 * time.Minute + } + // The worker is built either way. Conversion is only one of the things it + // does: the dashboard stats it derives (player count, kills, force + // composition) come out of the manifest and are just as computable for a + // recording that stays JSON forever. + worker := conversion.NewWorker( + operation, + conversion.Config{ + DataDir: setting.Data, + Interval: interval, + BatchSize: setting.Conversion.BatchSize, + ChunkSize: setting.Conversion.ChunkSize, + RetryFailed: setting.Conversion.RetryFailed, + }, + ) + + if setting.Conversion.Enabled { // Pass worker to handler for event-driven conversion on upload handlerOpts = append(handlerOpts, server.WithConversionTrigger(worker)) - // Start background worker for retries and batch processing + // Start background worker for retries and batch processing. + // Its startup sequence already runs the stats backfill. go worker.Start(ctx) + } else { + // No conversion, so nothing else would ever fill in the stats: compute + // them on upload, and sweep the recordings already stored without them. + handlerOpts = append(handlerOpts, server.WithStatsComputer(worker)) + go worker.BackfillStats(ctx) } // Auto-detect maptool: enable if all required tools are available. diff --git a/go.mod b/go.mod index f2170ffbc..7fe94fb0e 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,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 eba44f136..d1eea4bf4 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,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/conversion/worker.go b/internal/conversion/worker.go index 9d506bc64..5a90308d2 100644 --- a/internal/conversion/worker.go +++ b/internal/conversion/worker.go @@ -3,6 +3,7 @@ package conversion import ( "context" + "encoding/json" "fmt" "log/slog" "os" @@ -123,7 +124,7 @@ func (w *Worker) Start(ctx context.Context) { w.cleanupInterrupted(ctx) // Backfill stats for existing completed operations - w.backfillStats(ctx) + w.BackfillStats(ctx) // Run immediately on start w.processOnce(ctx) @@ -268,7 +269,35 @@ func computeStats(manifest *storage.Manifest) (playerCount, killCount, playerKil } } + // Zeus operators are not unit entities — they arrive as zeusEntity events + // and play back on the VIRTUAL side. Count unique curatorIds so the + // recording-selector dashboard can show that faction. + seenZeus := make(map[uint32]struct{}) for _, evt := range manifest.Events { + if evt.Type == "zeusEntity" { + var payload struct { + CuratorID uint32 `json:"curatorId"` + Name string `json:"name"` + } + if err := json.Unmarshal([]byte(evt.Message), &payload); err != nil { + continue + } + if _, dup := seenZeus[payload.CuratorID]; dup { + continue + } + seenZeus[payload.CuratorID] = struct{}{} + sc := sides["VIRTUAL"] + sc.Units++ + sc.Players++ + sides["VIRTUAL"] = sc + if payload.Name == "" || !seenPlayerName[payload.Name] { + if payload.Name != "" { + seenPlayerName[payload.Name] = true + } + playerCount++ + } + continue + } if evt.Type == "killed" { killCount++ if entityIsPlayer[evt.SourceID] { @@ -291,8 +320,36 @@ func computeStats(manifest *storage.Manifest) (playerCount, killCount, playerKil return } -// backfillStats populates stats for completed operations that don't have them yet. -func (w *Worker) backfillStats(ctx context.Context) { +// ComputeStatsFor derives and stores the dashboard stats for one operation +// without converting it. +// +// The stats are read out of the manifest and have nothing to do with the +// protobuf format, so this deliberately works on an unconverted recording too: +// the protobuf engine is tried first and a plain `.json.gz` falls back to the +// JSON engine. That is what lets a deployment with conversion switched off still +// show force composition and kill counts. +func (w *Worker) ComputeStatsFor(ctx context.Context, id int64, filename string) error { + manifest, err := w.engine.GetManifest(ctx, filename) + if err != nil { + jsonEngine := storage.NewJSONEngine(w.dataDir) + manifest, err = jsonEngine.GetManifest(ctx, filename) + if err != nil { + return fmt.Errorf("read manifest for stats: %w", err) + } + } + + playerCount, killCount, playerKillCount, sides := computeStats(manifest) + if err := w.repo.UpdateOperationStats(ctx, id, playerCount, killCount, playerKillCount, sides); err != nil { + return fmt.Errorf("store stats: %w", err) + } + return nil +} + +// BackfillStats populates stats for completed operations that don't have them +// yet. Exported because it has to run whether or not conversion is enabled -- +// with conversion off it is the only thing that ever fills in stats for +// recordings uploaded before the fix that added the upload-time path. +func (w *Worker) BackfillStats(ctx context.Context) { ops, err := w.repo.SelectStatsBackfill(ctx) if err != nil { slog.Error("failed to select operations for stats backfill", "error", err) @@ -305,22 +362,14 @@ func (w *Worker) backfillStats(ctx context.Context) { slog.Info("backfilling operation stats", "count", len(ops)) filled := 0 for _, op := range ops { - // Try protobuf engine first, fall back to JSON - manifest, err := w.engine.GetManifest(ctx, op.Filename) - if err != nil { - jsonEngine := storage.NewJSONEngine(w.dataDir) - manifest, err = jsonEngine.GetManifest(ctx, op.Filename) - if err != nil { - slog.Debug("skipping stats backfill", "operation_id", op.ID, "error", err) - continue - } - } - playerCount, killCount, playerKillCount, sides := computeStats(manifest) - if playerCount == 0 && killCount == 0 { - continue + select { + case <-ctx.Done(): + return + default: } - if err := w.repo.UpdateOperationStats(ctx, op.ID, playerCount, killCount, playerKillCount, sides); err != nil { - slog.Warn("failed to backfill stats", "operation_id", op.ID, "error", err) + + if err := w.ComputeStatsFor(ctx, op.ID, op.Filename); err != nil { + slog.Debug("skipping stats backfill", "operation_id", op.ID, "error", err) continue } filled++ diff --git a/internal/conversion/worker_test.go b/internal/conversion/worker_test.go index e17d605db..ba7afbae9 100644 --- a/internal/conversion/worker_test.go +++ b/internal/conversion/worker_test.go @@ -793,6 +793,65 @@ func TestComputeStats_IgnoresNonPlayerSides(t *testing.T) { assert.False(t, hasUnknown) } +func TestComputeStats_CountsVirtualZeus(t *testing.T) { + manifest := &storage.Manifest{ + Entities: []storage.EntityDef{ + {ID: 10, Type: "unit", Name: "Rifleman", Side: "EAST", IsPlayer: true}, + }, + Events: []storage.Event{ + {Type: "zeusEntity", Message: `{"curatorId":53,"name":"Bořek","playerUid":"7656","bodyUnitId":-1}`}, + {Type: "zeusEntity", Message: `{"curatorId":90,"name":"dominik2253","playerUid":"7657","bodyUnitId":-1}`}, + // Duplicate curator must not inflate the VIRTUAL card. + {Type: "zeusEntity", Message: `{"curatorId":53,"name":"Bořek","playerUid":"7656","bodyUnitId":-1}`}, + {Type: "killed", SourceID: 10, TargetID: 99}, + }, + } + + playerCount, killCount, _, sides := computeStats(manifest) + + assert.Equal(t, 3, playerCount, "EAST player plus two unique Zeus operators") + assert.Equal(t, 1, killCount) + assert.Equal(t, 1, sides["EAST"].Players) + assert.Equal(t, 1, sides["EAST"].Units) + assert.Equal(t, 2, sides["VIRTUAL"].Units) + assert.Equal(t, 2, sides["VIRTUAL"].Players) + assert.Equal(t, 0, sides["VIRTUAL"].Dead) + assert.Equal(t, 0, sides["VIRTUAL"].Kills) +} + +func TestComputeStats_ZeusNameAlreadyCounted(t *testing.T) { + manifest := &storage.Manifest{ + Entities: []storage.EntityDef{ + {ID: 10, Type: "unit", Name: "Danny", Side: "WEST", IsPlayer: true}, + }, + Events: []storage.Event{ + {Type: "zeusEntity", Message: `{"curatorId":0,"name":"Danny","bodyUnitId":-1}`}, + }, + } + + playerCount, _, _, sides := computeStats(manifest) + + assert.Equal(t, 1, playerCount, "same name must not inflate the global player count") + assert.Equal(t, 1, sides["WEST"].Players) + assert.Equal(t, 1, sides["VIRTUAL"].Players) + assert.Equal(t, 1, sides["VIRTUAL"].Units) +} + +func TestComputeStats_IgnoresMalformedZeus(t *testing.T) { + manifest := &storage.Manifest{ + Events: []storage.Event{ + {Type: "zeusEntity", Message: `not json`}, + {Type: "zeusEntity", Message: ""}, + }, + } + + playerCount, _, _, sides := computeStats(manifest) + + assert.Equal(t, 0, playerCount) + _, hasVirtual := sides["VIRTUAL"] + assert.False(t, hasVirtual) +} + func TestWorker_CleanupInterrupted(t *testing.T) { dir := t.TempDir() @@ -970,7 +1029,7 @@ func TestBackfillStats(t *testing.T) { }) ctx := context.Background() - worker.backfillStats(ctx) + worker.BackfillStats(ctx) // Verify UpdateOperationStats was called (stored in base mockRepo) stats, ok := base.stats[10] @@ -979,7 +1038,7 @@ func TestBackfillStats(t *testing.T) { assert.Equal(t, "1", stats[1], "killCount should be 1") } -func TestBackfillStats_SkipsZeroStats(t *testing.T) { +func TestBackfillStats_WritesZeroStats(t *testing.T) { dir := t.TempDir() // Create a gzipped JSON file with no players and no events @@ -1015,11 +1074,14 @@ func TestBackfillStats_SkipsZeroStats(t *testing.T) { }) ctx := context.Background() - worker.backfillStats(ctx) - - // Stats should NOT be set (skipped because playerCount=0 and killCount=0) - _, ok := base.stats[20] - assert.False(t, ok, "stats should not be set for zero-stats operation") + worker.BackfillStats(ctx) + + // Empty recordings still get a stats write so stats_revision advances + // and the worker does not re-parse them on every startup. + stats, ok := base.stats[20] + assert.True(t, ok, "stats should be written even when counts are zero") + assert.Equal(t, "0", stats[0], "playerCount should be 0") + assert.Equal(t, "0", stats[1], "killCount should be 0") } func TestBackfillStats_MissingFile(t *testing.T) { @@ -1039,7 +1101,7 @@ func TestBackfillStats_MissingFile(t *testing.T) { ctx := context.Background() // Should not panic, just skip the operation - worker.backfillStats(ctx) + worker.BackfillStats(ctx) _, ok := base.stats[30] assert.False(t, ok, "stats should not be set for missing file") @@ -1064,7 +1126,7 @@ func TestBackfillStats_SelectError(t *testing.T) { ctx := context.Background() // Should not panic, just log the error and return - worker.backfillStats(ctx) + worker.BackfillStats(ctx) // No stats should be set assert.Empty(t, base.stats) @@ -1130,7 +1192,7 @@ func TestBackfillStats_UpdateStatsError(t *testing.T) { ctx := context.Background() // Should not panic, just log the error and continue - worker.backfillStats(ctx) + worker.BackfillStats(ctx) // Stats should NOT be set (update failed) _, ok := base.stats[50] @@ -1155,3 +1217,67 @@ func TestWorker_CleanupInterrupted_ResetFailedError(t *testing.T) { // Should not panic, just log the error worker.cleanupInterrupted(ctx) } + +// ComputeStatsFor is the path an upload takes when conversion is switched off. +// +// The regression it guards: with conversion disabled the upload handler marked a +// recording "completed" and nothing ever derived its stats, so the recording +// played back perfectly while the dashboard showed no force composition, no kill +// counts and a dash for the player count. Nothing here converts anything -- the +// recording stays a plain .json.gz throughout. +func TestComputeStatsFor_UnconvertedJSONRecording(t *testing.T) { + dir := t.TempDir() + + testData := `{ + "worldName": "altis", + "missionName": "No Conversion", + "captureDelay": 1, + "endFrame": 100, + "entities": [ + {"id": 1, "type": "unit", "name": "Player1", "side": "WEST", "isPlayer": 1, "startFrameNum": 0, "positions": [], "framesFired": []}, + {"id": 2, "type": "unit", "name": "Player2", "side": "EAST", "isPlayer": 1, "startFrameNum": 0, "positions": [], "framesFired": []}, + {"id": 3, "type": "unit", "name": "AI1", "side": "WEST", "isPlayer": 0, "startFrameNum": 0, "positions": [], "framesFired": []} + ], + "events": [ + [10, "killed", 3, 1, "rifle", 50] + ], + "times": [] + }` + + jsonPath := filepath.Join(dir, "unconverted.json.gz") + f, err := os.Create(jsonPath) + assert.NoError(t, err) + gw := gzip.NewWriter(f) + _, err = gw.Write([]byte(testData)) + assert.NoError(t, err) + assert.NoError(t, gw.Close()) + assert.NoError(t, f.Close()) + + repo := newMockRepo() + worker := NewWorker(repo, Config{DataDir: dir}) + + // No protobuf directory exists, so this only passes via the JSON fallback. + assert.NoError(t, worker.ComputeStatsFor(context.Background(), 42, "unconverted")) + + stats, ok := repo.stats[42] + assert.True(t, ok, "stats should be stored for an unconverted recording") + assert.Equal(t, "2", stats[0], "playerCount") + assert.Equal(t, "1", stats[1], "killCount") + // Force composition is the part the dashboard renders as the side cards. + assert.Contains(t, stats[2], "WEST") + assert.Contains(t, stats[2], "EAST") +} + +// A missing recording is reported rather than silently storing zeroes, which +// would mark the operation as "stats computed" and hide it from the backfill +// forever. +func TestComputeStatsFor_MissingFileReturnsError(t *testing.T) { + repo := newMockRepo() + worker := NewWorker(repo, Config{DataDir: t.TempDir()}) + + err := worker.ComputeStatsFor(context.Background(), 7, "does_not_exist") + + assert.Error(t, err) + _, ok := repo.stats[7] + assert.False(t, ok, "no stats should be stored when the manifest cannot be read") +} 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/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 1cbfb4e67..4fadb2995 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -4,6 +4,9 @@ import ( "bufio" "bytes" "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -53,6 +56,17 @@ type ConversionTrigger interface { TriggerConversion(id int64, filename string) } +// StatsComputer derives the dashboard stats (player count, kills, force +// composition) for one operation and stores them. +// +// Separate from ConversionTrigger because the two are independent: the stats +// come out of the manifest and are just as computable for a recording that will +// never be converted. Conversion writes them as part of its own manifest read, +// so this is only wired up when conversion is disabled. +type StatsComputer interface { + ComputeStatsFor(ctx context.Context, id int64, filename string) error +} + type Handler struct { repoOperation *RepoOperation repoMarker *RepoMarker @@ -60,6 +74,7 @@ type Handler struct { setting Setting jwt *JWTManager conversionTrigger ConversionTrigger // optional, nil if conversion disabled + statsComputer StatsComputer // optional, set when conversion is disabled staticFS fs.FS // optional, nil disables static file serving maptoolMgr *maptool.JobManager // optional, nil if maptool disabled maptoolCfg *maptoolConfig // optional, nil if maptool disabled @@ -83,6 +98,14 @@ func WithConversionTrigger(trigger ConversionTrigger) HandlerOption { } } +// WithStatsComputer sets the stats computer used when conversion is disabled, +// so an upload still gets its dashboard stats. +func WithStatsComputer(computer StatsComputer) HandlerOption { + return func(h *Handler) { + h.statsComputer = computer + } +} + // WithStaticFS sets the filesystem used to serve the frontend func WithStaticFS(fsys fs.FS) HandlerOption { return func(h *Handler) { @@ -204,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)) } } @@ -498,6 +521,19 @@ func (h *Handler) StoreOperation(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) return } + // Conversion normally computes these while it has the manifest open. + // With it disabled nothing else would, and the recording would play back + // fine while the dashboard showed no force composition, no kills and a + // dash for the player count. Async so a large recording does not hold the + // upload response open; the row is already usable without it. + if h.statsComputer != nil { + id, filename := op.ID, op.Filename + go func() { + if err := h.statsComputer.ComputeStatsFor(context.Background(), id, filename); err != nil { + slog.Warn("failed to compute stats after upload", "operation_id", id, "error", err) + } + }() + } } w.WriteHeader(http.StatusOK) @@ -541,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) } @@ -621,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 { @@ -641,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) } } @@ -652,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 } @@ -662,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/marker.go b/internal/server/marker.go index b68f03f96..3f81b50e0 100644 --- a/internal/server/marker.go +++ b/internal/server/marker.go @@ -210,6 +210,8 @@ func (r *RepoMarker) scanColor(scolor string) (color.Color, error) { c = color.RGBA{0, 127, 0, 255} case "civ", "civilian": c = color.RGBA{102, 0, 127, 255} + case "virtual", "zeus", "logic": + c = color.RGBA{201, 162, 39, 255} case "unconscious": c = color.RGBA{255, 168, 26, 255} default: diff --git a/internal/server/marker_trench_test.go b/internal/server/marker_trench_test.go new file mode 100644 index 000000000..e1bd2bd65 --- /dev/null +++ b/internal/server/marker_trench_test.go @@ -0,0 +1,36 @@ +package server + +import ( + "io" + "strings" + "testing" +) + +// Confirms the trench SVGs land in the marker registry under the exact names the +// addon emits as the marker type, and that the colour template renders. +func TestTrenchMarkerIconsResolve(t *testing.T) { + repo, err := NewRepoMarker("../../assets/markers") + if err != nil { + t.Fatalf("NewRepoMarker: %v", err) + } + for _, name := range []string{ + "trench", "trench_small", "trench_big", "trench_short", + "trench_long", "trench_giant", "trench_vehicle", + } { + r, ct, err := repo.Get(t.Context(), name, "D96600") + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if ct != "image/svg+xml" { + t.Fatalf("%s: content type %q", name, ct) + } + b, _ := io.ReadAll(r) + s := string(b) + if strings.Contains(s, "{{") { + t.Fatalf("%s: template not rendered", name) + } + if !strings.Contains(s, "#d96600ff") { + t.Fatalf("%s: colour not applied: %s", name, s[:120]) + } + } +} diff --git a/internal/server/operation.go b/internal/server/operation.go index de1c374e9..a084e97ab 100644 --- a/internal/server/operation.go +++ b/internal/server/operation.go @@ -36,9 +36,14 @@ type SideCounts struct { Kills int `json:"kills"` } -// SideComposition maps Arma side names (WEST, EAST, GUER, CIV) to player/unit counts. +// SideComposition maps Arma side names (WEST, EAST, GUER, CIV, VIRTUAL) to player/unit counts. type SideComposition map[string]SideCounts +// CurrentStatsRevision is stored on each operation when computeStats last ran. +// Bump it when the derived stats shape changes so completed recordings are +// recomputed once. Revision 1 adds VIRTUAL from zeusEntity events. +const CurrentStatsRevision = 1 + type Operation struct { ID int64 `json:"id"` WorldName string `json:"world_name"` @@ -274,6 +279,22 @@ func (r *RepoOperation) migration() (err error) { } } + if version < 14 { + // Tests rewind the version table to re-run later migrations; ADD COLUMN + // is not idempotent, so skip it when the column already exists. + var n int + if err = r.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('operations') WHERE name = 'stats_revision'`).Scan(&n); err != nil { + return err + } + var stmts []string + if n == 0 { + stmts = append(stmts, `ALTER TABLE operations ADD COLUMN stats_revision INTEGER DEFAULT 0`) + } + if err = r.runMigration(14, stmts...); err != nil { + return err + } + } + return nil } @@ -767,8 +788,8 @@ func (r *RepoOperation) UpdateChunkCount(ctx context.Context, id int64, count in func (r *RepoOperation) UpdateOperationStats(ctx context.Context, id int64, playerCount, killCount, playerKillCount int, sideComposition SideComposition) error { sideJSON := marshalSideComposition(sideComposition) _, err := r.db.ExecContext(ctx, - `UPDATE operations SET player_count = ?, kill_count = ?, player_kill_count = ?, side_composition = ? WHERE id = ?`, - playerCount, killCount, playerKillCount, sideJSON, id) + `UPDATE operations SET player_count = ?, kill_count = ?, player_kill_count = ?, side_composition = ?, stats_revision = ? WHERE id = ?`, + playerCount, killCount, playerKillCount, sideJSON, CurrentStatsRevision, id) return err } @@ -805,13 +826,16 @@ func unmarshalSideComposition(raw string) SideComposition { return sc } -// SelectStatsBackfill returns completed protobuf operations that have no stats yet +// SelectStatsBackfill returns completed operations whose derived stats are stale +// or missing. stats_revision is bumped by UpdateOperationStats so each +// computeStats change is applied once, including recordings that already have +// a player_count (they would otherwise keep the pre-VIRTUAL composition forever). func (r *RepoOperation) SelectStatsBackfill(ctx context.Context) ([]Operation, error) { rows, err := r.db.QueryContext(ctx, `SELECT ` + operationColumns + ` FROM operations - WHERE conversion_status = 'completed' AND player_count = 0 - ORDER BY id ASC`) + WHERE conversion_status = 'completed' AND stats_revision < ? + ORDER BY id ASC`, CurrentStatsRevision) if err != nil { return nil, err } diff --git a/internal/server/operation_test.go b/internal/server/operation_test.go index 9481b0848..3eb48070c 100644 --- a/internal/server/operation_test.go +++ b/internal/server/operation_test.go @@ -519,7 +519,7 @@ func TestMigrationRerun(t *testing.T) { var version int err = repo2.db.QueryRow("SELECT db FROM version ORDER BY db DESC LIMIT 1").Scan(&version) assert.NoError(t, err) - assert.Equal(t, 13, version) + assert.Equal(t, 14, version) } func TestMigrationV10NormalizeWorldName(t *testing.T) { @@ -627,7 +627,7 @@ func TestMigrationV11DecodeFilenames(t *testing.T) { // Version recorded. var version int require.NoError(t, repo2.db.QueryRow(`SELECT MAX(db) FROM version`).Scan(&version)) - assert.Equal(t, 13, version) + assert.Equal(t, 14, version) } func TestDecodeFilename(t *testing.T) { @@ -932,7 +932,7 @@ func TestMigrationV11_DBCollisionSkipped(t *testing.T) { // Version still bumped. var version int require.NoError(t, repo2.db.QueryRow(`SELECT MAX(db) FROM version`).Scan(&version)) - assert.Equal(t, 13, version) + assert.Equal(t, 14, version) } func TestMigrationV11_FilesystemCollisionSkipped(t *testing.T) { @@ -1284,7 +1284,7 @@ func TestSelectStatsBackfill(t *testing.T) { ctx := t.Context() - // completed, player_count=0 → should appear + // completed, stats_revision=0 → should appear op1 := &Operation{ WorldName: "altis", MissionName: "NeedsBackfill", Filename: "backfill1", Date: "2026-01-01", @@ -1292,15 +1292,18 @@ func TestSelectStatsBackfill(t *testing.T) { } require.NoError(t, repo.Store(ctx, op1)) - // completed, player_count>0 → should NOT appear + // completed with stats already written at the current revision → should NOT appear op2 := &Operation{ WorldName: "altis", MissionName: "HasStats", Filename: "has_stats", Date: "2026-01-02", ConversionStatus: "completed", PlayerCount: 5, } require.NoError(t, repo.Store(ctx, op2)) + require.NoError(t, repo.UpdateOperationStats(ctx, op2.ID, 5, 1, 0, SideComposition{ + "WEST": {Players: 5, Units: 10}, + })) - // pending, player_count=0 → should NOT appear (not completed) + // pending, stats_revision=0 → should NOT appear (not completed) op3 := &Operation{ WorldName: "altis", MissionName: "StillPending", Filename: "pending1", Date: "2026-01-03", @@ -1314,6 +1317,19 @@ func TestSelectStatsBackfill(t *testing.T) { assert.Equal(t, "NeedsBackfill", result[0].MissionName) } +func TestMigrationV14StatsRevision(t *testing.T) { + dir := t.TempDir() + pathDB := filepath.Join(dir, "test.db") + + repo, err := NewRepoOperation(pathDB) + require.NoError(t, err) + defer func() { assert.NoError(t, repo.db.Close()) }() + + var revision int + err = repo.db.QueryRow("SELECT stats_revision FROM operations LIMIT 1").Scan(&revision) + assert.ErrorIs(t, err, sql.ErrNoRows) +} + func TestMarshalSideComposition(t *testing.T) { t.Run("nil returns empty JSON object", func(t *testing.T) { assert.Equal(t, "{}", marshalSideComposition(nil)) diff --git a/internal/server/player_snapshot_sidecar_test.go b/internal/server/player_snapshot_sidecar_test.go new file mode 100644 index 000000000..4a5452064 --- /dev/null +++ b/internal/server/player_snapshot_sidecar_test.go @@ -0,0 +1,88 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "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" +) + +// The profile card fetches snapshots/.pb over the same /data route the +// manifest and chunks use, once, when a player is opened. The bytes have to +// come back intact and unencoded so protobuf decoding on the UI side works. +func TestGetDataServesPlayerSnapshotSidecar(t *testing.T) { + dataDir := t.TempDir() + missionDir := filepath.Join(dataDir, "snapshot_mission") + snapshotsDir := filepath.Join(missionDir, storage.SnapshotsDirName) + require.NoError(t, os.MkdirAll(snapshotsDir, 0755)) + + series := &pbv1.PlayerSnapshotSeries{ + UnitId: 7, + Events: []*pbv1.Event{ + {FrameNum: 5, Type: "inventorySnapshot", Message: `{"unitId":7,"massUnits":695}`}, + {FrameNum: 60, Type: "inventorySnapshot", Message: `{"unitId":7,"diffOf":5,"set":{"massUnits":700}}`}, + }, + } + encoded, err := proto.Marshal(series) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(snapshotsDir, "7.pb"), encoded, 0644)) + + hdlr := Handler{setting: Setting{Data: dataDir}} + + t.Run("serves the series for a unit that has one", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/data/snapshot_mission/snapshots/7.pb", nil) + req.SetPathValue("path", "snapshot_mission/snapshots/7.pb") + rec := httptest.NewRecorder() + + hdlr.GetData(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + assert.Empty(t, rec.Header().Get("Content-Encoding")) + + var decoded pbv1.PlayerSnapshotSeries + require.NoError(t, proto.Unmarshal(rec.Body.Bytes(), &decoded)) + assert.Equal(t, uint32(7), decoded.UnitId) + require.Len(t, decoded.Events, 2) + assert.Equal(t, `{"unitId":7,"diffOf":5,"set":{"massUnits":700}}`, decoded.Events[1].Message) + }) + + // A unit with no snapshots has no sidecar. The card logs the miss and keeps + // playing, so a 404 here is the expected, non-fatal answer. + t.Run("404 for a unit without a sidecar", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/data/snapshot_mission/snapshots/99.pb", nil) + req.SetPathValue("path", "snapshot_mission/snapshots/99.pb") + rec := httptest.NewRecorder() + + hdlr.GetData(rec, req) + assert.Equal(t, http.StatusNotFound, rec.Code) + }) + + // A recording made before the feature has no snapshots directory at all. + t.Run("404 when the recording has no snapshots directory", func(t *testing.T) { + require.NoError(t, os.MkdirAll(filepath.Join(dataDir, "old_mission"), 0755)) + req := httptest.NewRequest(http.MethodGet, "/data/old_mission/snapshots/7.pb", nil) + req.SetPathValue("path", "old_mission/snapshots/7.pb") + rec := httptest.NewRecorder() + + hdlr.GetData(rec, req) + assert.Equal(t, http.StatusNotFound, rec.Code) + }) + + // The unit id goes into the URL from the UI, so the sidecar route must not + // become a way out of the data directory. + t.Run("path traversal through the snapshots directory is blocked", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/data/snapshot_mission/snapshots/../../../etc/passwd", nil) + req.SetPathValue("path", "snapshot_mission/snapshots/../../../etc/passwd") + rec := httptest.NewRecorder() + + hdlr.GetData(rec, req) + assert.Equal(t, http.StatusNotFound, rec.Code) + }) +} 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/converted_recording_test.go b/internal/storage/converted_recording_test.go new file mode 100644 index 000000000..4253650fc --- /dev/null +++ b/internal/storage/converted_recording_test.go @@ -0,0 +1,130 @@ +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", + "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) + 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) + switch event.Type { + 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) + default: + 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/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 2f83ff49d..91965da6e 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{}) @@ -143,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"] @@ -164,6 +174,32 @@ 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. + if event.Type == "inventorySnapshot" || event.Type == "medicalSnapshot" || + event.Type == "staminaSnapshot" || event.Type == "radioSnapshot" || + 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" || isStructuredExplosion || + event.Type == "serviceEvent" || event.Type == "staticWeapon" || + event.Type == "radioTransmission" { + 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..948c1de2c --- /dev/null +++ b/internal/storage/player_snapshot_test.go @@ -0,0 +1,410 @@ +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"]) +} + +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"]) +} + +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), + "zeusPing", + map[string]interface{}{ + "curatorId": float64(42), + "unitId": float64(17), + "name": "Danny", + "side": "WEST", + "x": float64(3411), + "y": float64(9002), + }, + }) + + require.NotNil(t, event) + require.Equal(t, "zeusPing", 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(42), payload["curatorId"]) + require.Equal(t, float64(17), payload["unitId"]) + require.Equal(t, "Danny", payload["name"]) + require.Equal(t, "WEST", payload["side"]) + require.Equal(t, float64(3411), payload["x"]) + require.Equal(t, float64(9002), payload["y"]) +} + +// A ping is low-volume and must be readable without selecting a unit, so unlike +// the four per-player snapshot types it stays in the manifest. +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) { + 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", + payload, + }) + + require.NotNil(t, event) + require.Equal(t, "explosion", event.Type) + require.Equal(t, uint32(310), event.FrameNum) + + 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) { + event := parseEventArray([]interface{}{ + float64(75), + "serviceEvent", + map[string]interface{}{ + "kind": "refuel", + "vehicleId": float64(9), + "vehicleName": "Hunter", + "unitId": float64(3), + "unitName": "Danny", + "side": "WEST", + "x": float64(1200), + "y": float64(3400), + "from": 0.12, + "to": 0.98, + }, + }) + + require.NotNil(t, event) + require.Equal(t, "serviceEvent", event.Type) + + var payload map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(event.Message), &payload)) + require.Equal(t, "refuel", payload["kind"]) + require.Equal(t, float64(9), payload["vehicleId"]) + require.Equal(t, 0.12, payload["from"]) + require.Equal(t, 0.98, payload["to"]) +} + +func TestParseStaticWeaponEventPreservesPayload(t *testing.T) { + event := parseEventArray([]interface{}{ + float64(512), + "staticWeapon", + map[string]interface{}{ + "action": "assembled", + "unitId": float64(3), + "unitName": "Danny", + "side": "WEST", + "vehicleId": float64(88), + "class": "B_HMG_01_high_F", + "name": "M2 (High)", + "x": float64(1200), + "y": float64(3400), + }, + }) + + require.NotNil(t, event) + require.Equal(t, "staticWeapon", event.Type) + + var payload map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(event.Message), &payload)) + require.Equal(t, "assembled", payload["action"]) + require.Equal(t, float64(88), payload["vehicleId"]) + require.Equal(t, "M2 (High)", payload["name"]) +} + +// These three are low-volume, global, and readable without selecting a unit, so +// like the Zeus events they stay in the manifest rather than moving into a +// per-player snapshot sidecar. +func TestSupportEventsAreNotPlayerSnapshotEvents(t *testing.T) { + require.False(t, IsPlayerSnapshotEvent("explosion")) + require.False(t, IsPlayerSnapshotEvent("serviceEvent")) + require.False(t, IsPlayerSnapshotEvent("staticWeapon")) +} + +func TestParseRadioTransmissionPreservesPayload(t *testing.T) { + event := parseEventArray([]interface{}{ + float64(30), + "radioTransmission", + map[string]interface{}{ + "unitId": float64(7), + "radio": "AN/PRC-152", + "type": "SW", + "action": "Start", + "channel": float64(3), + "additional": false, + "frequency": 69.9, + "code": "0451", + }, + }) + + require.NotNil(t, event) + require.Equal(t, "radioTransmission", event.Type) + require.Equal(t, uint32(30), event.FrameNum) + + var payload map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(event.Message), &payload)) + require.Equal(t, float64(7), payload["unitId"]) + require.Equal(t, "SW", payload["type"]) + require.Equal(t, "Start", payload["action"]) + require.Equal(t, 69.9, payload["frequency"]) + require.Equal(t, "0451", payload["code"]) +} + +// Transmissions are global and low-volume, so they stay in the manifest rather +// than moving into a per-player snapshot sidecar. +func TestRadioTransmissionIsNotAPlayerSnapshotEvent(t *testing.T) { + require.False(t, IsPlayerSnapshotEvent("radioTransmission")) +} + +// The recorder writes a blast radius to one decimal rather than rounding to a +// whole metre. Recordings written before that change carry plain integers, so +// the manifest parser has to hand both back untouched -- it stores the payload +// as raw JSON precisely so a numeric shape change on the recorder side is not a +// format break. +func TestParseExplosionRadiusPrecisionIsPreserved(t *testing.T) { + for name, radius := range map[string]float64{ + "legacy integer": 28, + "one decimal": 5.4, + "sub metre": 0.4, + } { + t.Run(name, func(t *testing.T) { + event := parseEventArray([]interface{}{ + float64(10), + "explosion", + map[string]interface{}{ + "x": float64(1), + "y": float64(2), + "ammo": "Sh_155mm_AMOS", + "radius": radius, + }, + }) + + require.NotNil(t, event) + require.Equal(t, "explosion", event.Type) + + var payload map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(event.Message), &payload)) + require.Equal(t, radius, payload["radius"]) + }) + } +} diff --git a/internal/storage/player_snapshot_writer_test.go b/internal/storage/player_snapshot_writer_test.go new file mode 100644 index 000000000..b687cab18 --- /dev/null +++ b/internal/storage/player_snapshot_writer_test.go @@ -0,0 +1,143 @@ +package storage + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + pbv1 "github.com/OCAP2/web/pkg/schemas/protobuf/v1" +) + +// The manifest writer and the sidecars have to agree: whatever leaves the +// manifest must be indexed by snapshot_unit_ids and exist on disk, or the UI +// asks for a file that was never written when a profile card opens. +func TestProtobufWriterV1MovesPlayerSnapshotsToSidecars(t *testing.T) { + dir := t.TempDir() + + w := &ProtobufWriterV1{} + result := &ParseResult{ + 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}`}, + {FrameNum: 9, Type: "medicalSnapshot", Message: `{"unitId":12,"ace":{"heartRate":80}}`}, + {FrameNum: 20, Type: "killed", Message: "killed by explosion"}, + {FrameNum: 40, Type: "radioSnapshot", Message: `{"unitId":7,"radios":[{"class":"TFAR_anprc152","rangeMeters":5000}]}`}, + {FrameNum: 60, Type: "inventorySnapshot", Message: `{"unitId":7,"diffOf":5,"set":{"massUnits":700}}`}, + {FrameNum: 80, Type: "staminaSnapshot", Message: `{"unitId":7,"vanilla":{"stance":3}}`}, + }, + } + + require.NoError(t, w.WriteManifest(context.Background(), dir, result)) + + data, err := os.ReadFile(filepath.Join(dir, "manifest.pb")) + require.NoError(t, err) + var manifest pbv1.Manifest + require.NoError(t, proto.Unmarshal(data, &manifest)) + + // The four per-player kinds are gone from the manifest; the global samples + // and the ordinary events every reader needs are still there. + kept := map[string]int{} + for _, e := range manifest.Events { + 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, "weather": 1, "tfarSettings": 1, "acreSettings": 1, "killed": 1}, kept) + + require.Equal(t, []uint32{7, 12}, manifest.SnapshotUnitIds) + for _, unitID := range manifest.SnapshotUnitIds { + assert.FileExists(t, filepath.Join(dir, SnapshotsDirName, snapshotFileName(unitID))) + } + + series := readSnapshotSeries(t, dir, 7) + assert.Equal(t, uint32(7), series.UnitId) + require.Len(t, series.Events, 4) + // Recorded order is preserved within a unit so the diff chain rebuilds. + assert.Equal(t, []uint32{5, 40, 60, 80}, []uint32{ + series.Events[0].FrameNum, series.Events[1].FrameNum, + series.Events[2].FrameNum, series.Events[3].FrameNum, + }) + assert.Equal(t, `{"unitId":7,"diffOf":5,"set":{"massUnits":700}}`, series.Events[2].Message) + + single := readSnapshotSeries(t, dir, 12) + assert.Equal(t, uint32(12), single.UnitId) + require.Len(t, single.Events, 1) + assert.Equal(t, "medicalSnapshot", single.Events[0].Type) +} + +// A recording made before this feature, or by a server with the trackers off, +// must produce exactly the manifest it always did: no index, no sidecar +// directory, nothing an older reader would trip over. +func TestProtobufWriterV1WithoutSnapshotsIsUnchanged(t *testing.T) { + dir := t.TempDir() + + w := &ProtobufWriterV1{} + result := &ParseResult{ + WorldName: "altis", MissionName: "no snapshots", EndFrame: 99, ChunkSize: 50, + Events: []Event{ + {FrameNum: 10, Type: "hit", SourceID: 0, TargetID: 1, Weapon: "rifle"}, + {FrameNum: 20, Type: "killed", SourceID: 0, TargetID: 1}, + }, + } + + require.NoError(t, w.WriteManifest(context.Background(), dir, result)) + + data, err := os.ReadFile(filepath.Join(dir, "manifest.pb")) + require.NoError(t, err) + var manifest pbv1.Manifest + require.NoError(t, proto.Unmarshal(data, &manifest)) + + require.Len(t, manifest.Events, 2) + assert.Empty(t, manifest.SnapshotUnitIds) + assert.NoDirExists(t, filepath.Join(dir, SnapshotsDirName)) +} + +// A snapshot the server never stamped with a unitId has no sidecar to live in, +// so it stays in the manifest rather than being dropped from the recording. +func TestProtobufWriterV1KeepsUnattributedSnapshotsInManifest(t *testing.T) { + dir := t.TempDir() + + w := &ProtobufWriterV1{} + result := &ParseResult{ + WorldName: "altis", MissionName: "unattributed", EndFrame: 9, ChunkSize: 10, + Events: []Event{ + {FrameNum: 1, Type: "inventorySnapshot", Message: `{"massUnits":695}`}, + {FrameNum: 2, Type: "medicalSnapshot", Message: `{"unitId":-1}`}, + {FrameNum: 3, Type: "staminaSnapshot", Message: `{"unitId":4}`}, + }, + } + + require.NoError(t, w.WriteManifest(context.Background(), dir, result)) + + data, err := os.ReadFile(filepath.Join(dir, "manifest.pb")) + require.NoError(t, err) + var manifest pbv1.Manifest + require.NoError(t, proto.Unmarshal(data, &manifest)) + + require.Len(t, manifest.Events, 2) + assert.Equal(t, uint32(1), manifest.Events[0].FrameNum) + assert.Equal(t, uint32(2), manifest.Events[1].FrameNum) + assert.Equal(t, []uint32{4}, manifest.SnapshotUnitIds) +} + +func snapshotFileName(unitID uint32) string { + return fmt.Sprintf("%d.pb", unitID) +} + +func readSnapshotSeries(t *testing.T, dir string, unitID uint32) *pbv1.PlayerSnapshotSeries { + t.Helper() + data, err := os.ReadFile(filepath.Join(dir, SnapshotsDirName, snapshotFileName(unitID))) + require.NoError(t, err) + var series pbv1.PlayerSnapshotSeries + require.NoError(t, proto.Unmarshal(data, &series)) + return &series +} diff --git a/internal/storage/player_snapshots.go b/internal/storage/player_snapshots.go new file mode 100644 index 000000000..371600593 --- /dev/null +++ b/internal/storage/player_snapshots.go @@ -0,0 +1,127 @@ +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, weather, tfarSettings, acreSettings and the Zeus events are +// deliberately absent: they are needed for map-wide playback (Stats, range +// circles, VIRTUAL faction, camera cones, pings, modules) without selecting a +// unit, 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 := WriteArtifact(path, data); 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..851c8566f --- /dev/null +++ b/internal/storage/player_snapshots_test.go @@ -0,0 +1,172 @@ +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(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}}`), + snapshotEvent(20, "inventorySnapshot", `{"unitId":7,"diffOf":5,"set":{"massUnits":700}}`), + snapshotEvent(30, "tfarSettings", `{"terrainInterceptionCoefficient":7}`), + snapshotEvent(12, "zeusEntity", `{"curatorId":90,"name":"Danny"}`), + snapshotEvent(13, "zeusCamera", `{"curatorId":90,"x":1,"y":2,"dir":90,"fov":0.75}`), + } + + manifest, byUnit := SplitPlayerSnapshots(events) + + // Global events stay where every reader already looks for them. + require.Len(t, manifest, 6) + require.Equal(t, "serverFps", manifest[0].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) + 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}], + [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}], + [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}}], + [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, 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" || e.Type == "zeusModule" { + sawZeus++ + require.NotEmpty(t, e.Message, "%s lost its JSON payload", e.Type) + } + } + 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) + 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/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/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/internal/storage/streaming_converter.go b/internal/storage/streaming_converter.go index bd1728c0e..e4d8dba0c 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,16 +268,17 @@ 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) 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) } @@ -322,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/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..6a1632dbc 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 { @@ -36,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) } @@ -281,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) } 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/public/.gitkeep b/ui/public/.gitkeep new file mode 100644 index 000000000..e69de29bb 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 000000000..f69af7a4b Binary files /dev/null and b/ui/src/assets/medical/body/arm_left.png differ diff --git a/ui/src/assets/medical/body/arm_left_b.png b/ui/src/assets/medical/body/arm_left_b.png new file mode 100644 index 000000000..542ff0929 Binary files /dev/null and b/ui/src/assets/medical/body/arm_left_b.png differ 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 000000000..f56fda3a5 Binary files /dev/null and b/ui/src/assets/medical/body/arm_left_s.png differ 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 000000000..3736c04a3 Binary files /dev/null and b/ui/src/assets/medical/body/arm_left_t.png differ 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 000000000..0632169cc Binary files /dev/null and b/ui/src/assets/medical/body/arm_right.png differ diff --git a/ui/src/assets/medical/body/arm_right_b.png b/ui/src/assets/medical/body/arm_right_b.png new file mode 100644 index 000000000..356a4ea99 Binary files /dev/null and b/ui/src/assets/medical/body/arm_right_b.png differ diff --git a/ui/src/assets/medical/body/arm_right_s.png b/ui/src/assets/medical/body/arm_right_s.png new file mode 100644 index 000000000..ca40d3f1a Binary files /dev/null and b/ui/src/assets/medical/body/arm_right_s.png differ 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 000000000..4e7788eea Binary files /dev/null and b/ui/src/assets/medical/body/arm_right_t.png differ diff --git a/ui/src/assets/medical/body/background.png b/ui/src/assets/medical/body/background.png new file mode 100644 index 000000000..abf0ffc0d Binary files /dev/null and b/ui/src/assets/medical/body/background.png differ diff --git a/ui/src/assets/medical/body/head.png b/ui/src/assets/medical/body/head.png new file mode 100644 index 000000000..237bc1d06 Binary files /dev/null and b/ui/src/assets/medical/body/head.png differ diff --git a/ui/src/assets/medical/body/head_guedeltube.png b/ui/src/assets/medical/body/head_guedeltube.png new file mode 100644 index 000000000..684fe805c Binary files /dev/null and b/ui/src/assets/medical/body/head_guedeltube.png differ 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 000000000..b5991e13b Binary files /dev/null and b/ui/src/assets/medical/body/head_kinglt.png differ 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 000000000..92ceddaac Binary files /dev/null and b/ui/src/assets/medical/body/head_nasalcannula.png differ 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 000000000..a9f5a88fe Binary files /dev/null and b/ui/src/assets/medical/body/head_s.png differ diff --git a/ui/src/assets/medical/body/leftarm_aed_vitals_monitor.png b/ui/src/assets/medical/body/leftarm_aed_vitals_monitor.png new file mode 100644 index 000000000..8e648960b Binary files /dev/null and b/ui/src/assets/medical/body/leftarm_aed_vitals_monitor.png differ 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 000000000..261314898 Binary files /dev/null and b/ui/src/assets/medical/body/leftarm_iv.png differ diff --git a/ui/src/assets/medical/body/leftarm_pulseoximeter.png b/ui/src/assets/medical/body/leftarm_pulseoximeter.png new file mode 100644 index 000000000..6201b2cff Binary files /dev/null and b/ui/src/assets/medical/body/leftarm_pulseoximeter.png differ 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 000000000..94a83eaed Binary files /dev/null and b/ui/src/assets/medical/body/leftleg_iv.png differ 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 000000000..21cc45e8a Binary files /dev/null and b/ui/src/assets/medical/body/leg_left.png differ 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 000000000..9085fc705 Binary files /dev/null and b/ui/src/assets/medical/body/leg_left_b.png differ diff --git a/ui/src/assets/medical/body/leg_left_s.png b/ui/src/assets/medical/body/leg_left_s.png new file mode 100644 index 000000000..366905609 Binary files /dev/null and b/ui/src/assets/medical/body/leg_left_s.png differ diff --git a/ui/src/assets/medical/body/leg_left_t.png b/ui/src/assets/medical/body/leg_left_t.png new file mode 100644 index 000000000..29b3156d0 Binary files /dev/null and b/ui/src/assets/medical/body/leg_left_t.png differ 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 000000000..e9e23a77f Binary files /dev/null and b/ui/src/assets/medical/body/leg_right.png differ 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 000000000..1b73515ee Binary files /dev/null and b/ui/src/assets/medical/body/leg_right_b.png differ diff --git a/ui/src/assets/medical/body/leg_right_s.png b/ui/src/assets/medical/body/leg_right_s.png new file mode 100644 index 000000000..d5f398e93 Binary files /dev/null and b/ui/src/assets/medical/body/leg_right_s.png differ 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 000000000..f0620c12a Binary files /dev/null and b/ui/src/assets/medical/body/leg_right_t.png differ 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 000000000..da57af06d Binary files /dev/null and b/ui/src/assets/medical/body/rightarm_aed_vitals_monitor.png differ 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 000000000..a8771e334 Binary files /dev/null and b/ui/src/assets/medical/body/rightarm_iv.png differ diff --git a/ui/src/assets/medical/body/rightarm_pulseoximeter.png b/ui/src/assets/medical/body/rightarm_pulseoximeter.png new file mode 100644 index 000000000..0b295d3e0 Binary files /dev/null and b/ui/src/assets/medical/body/rightarm_pulseoximeter.png differ diff --git a/ui/src/assets/medical/body/rightleg_iv.png b/ui/src/assets/medical/body/rightleg_iv.png new file mode 100644 index 000000000..89490168b Binary files /dev/null and b/ui/src/assets/medical/body/rightleg_iv.png differ diff --git a/ui/src/assets/medical/body/torso.png b/ui/src/assets/medical/body/torso.png new file mode 100644 index 000000000..636de65fb Binary files /dev/null and b/ui/src/assets/medical/body/torso.png differ diff --git a/ui/src/assets/medical/body/torso_aed_pads.png b/ui/src/assets/medical/body/torso_aed_pads.png new file mode 100644 index 000000000..c4a02aed6 Binary files /dev/null and b/ui/src/assets/medical/body/torso_aed_pads.png differ 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 000000000..cd13325ba Binary files /dev/null and b/ui/src/assets/medical/body/torso_chestseal.png differ 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 000000000..14bfed2f6 Binary files /dev/null and b/ui/src/assets/medical/body/torso_i.png differ 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 000000000..5cfa37a7d Binary files /dev/null and b/ui/src/assets/medical/body/torso_io.png differ diff --git a/ui/src/assets/medical/body/torso_pneumothorax.png b/ui/src/assets/medical/body/torso_pneumothorax.png new file mode 100644 index 000000000..ebce9d827 Binary files /dev/null and b/ui/src/assets/medical/body/torso_pneumothorax.png differ diff --git a/ui/src/assets/medical/body/torso_s.png b/ui/src/assets/medical/body/torso_s.png new file mode 100644 index 000000000..e1c450707 Binary files /dev/null and b/ui/src/assets/medical/body/torso_s.png differ diff --git a/ui/src/components/Icons.tsx b/ui/src/components/Icons.tsx index 16f51a5ac..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 => ( @@ -575,3 +582,61 @@ export const ClipboardIcon = (p: IconProps): JSX.Element => ( ); + +// --------------------------------------------------------------------------- +// Support & construction event icons +// --------------------------------------------------------------------------- + +/** A blast: a spiked burst, distinct from ZapIcon's single bolt. */ +export const ExplosionIcon = (p: IconProps): JSX.Element => ( + + + +); + +/** A wrench: repair, refuel and rearm all file under servicing. */ +export const WrenchIcon = (p: IconProps): JSX.Element => ( + + + +); + +/** A static weapon on its tripod. */ +export const StaticWeaponIcon = (p: IconProps): JSX.Element => ( + + + + + + + +); + +/** A radio set: body, antenna and signal arcs. */ +export const RadioIcon = (p: IconProps): JSX.Element => ( + + + + + + + +); + +/** A bookmark ribbon. */ +export const BookmarkIcon = (p: IconProps): JSX.Element => ( + + + +); + +/** An org chart: one box branching into two. */ +export const SitemapIcon = (p: IconProps): JSX.Element => ( + + + + + + + +); diff --git a/ui/src/config/sideColors.ts b/ui/src/config/sideColors.ts index 3454fdb29..ab7c88ae1 100644 --- a/ui/src/config/sideColors.ts +++ b/ui/src/config/sideColors.ts @@ -1,11 +1,24 @@ 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", EAST: "#ff0000", GUER: "#00cc00", CIV: "#c900ff", + VIRTUAL: "#c9a227", }; /** Dark colors for entity markers. Keep in sync with --side-*-dark in variables.css */ @@ -14,6 +27,7 @@ export const SIDE_COLORS_DARK: Record = { EAST: "#800000", GUER: "#007f00", CIV: "#650080", + VIRTUAL: "#8a7018", }; /** Redesign UI colors for side indicators. */ @@ -22,6 +36,7 @@ export const SIDE_COLORS_UI: Record = { EAST: "var(--side-opfor)", GUER: "var(--side-ind)", CIV: "var(--side-civ)", + VIRTUAL: "var(--side-virtual)", }; /** Translucent background variants for redesign UI. */ @@ -30,6 +45,7 @@ export const SIDE_BG_COLORS: Record = { EAST: "color-mix(in srgb, var(--side-opfor) 12%, transparent)", GUER: "color-mix(in srgb, var(--side-ind) 12%, transparent)", CIV: "color-mix(in srgb, var(--side-civ) 12%, transparent)", + VIRTUAL: "color-mix(in srgb, var(--side-virtual) 12%, transparent)", }; /** CSS class name for each side. */ @@ -38,4 +54,5 @@ export const SIDE_CLASS: Record = { EAST: "opfor", GUER: "ind", CIV: "civ", + VIRTUAL: "virtual", }; 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__/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/__tests__/convertedRecording.integration.test.ts b/ui/src/data/__tests__/convertedRecording.integration.test.ts new file mode 100644 index 000000000..314ce0789 --- /dev/null +++ b/ui/src/data/__tests__/convertedRecording.integration.test.ts @@ -0,0 +1,458 @@ +/** + * 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 { beforeAll, 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", () => { + 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); + 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__/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..a07a6a43a --- /dev/null +++ b/ui/src/data/__tests__/snapshotDiff.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from "vitest"; +import { applySnapshotDiff, isSnapshotDiff } from "../snapshotDiff"; +import type { InventorySnapshot, 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 an array wholesale when the diff carries a plain array", () => { + 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("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, + 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/__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/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..a74446339 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. 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 + * 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__/jsonDecoder.test.ts b/ui/src/data/decoders/__tests__/jsonDecoder.test.ts index 14af37a27..2f1280e89 100644 --- a/ui/src/data/decoders/__tests__/jsonDecoder.test.ts +++ b/ui/src/data/decoders/__tests__/jsonDecoder.test.ts @@ -10,6 +10,277 @@ 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("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("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", + missionName: "Zeus", + endFrame: 100, + captureDelay: 1, + events: [ + [2, "zeusEntity", { curatorId: 90, name: "Danny", playerUid: "7656", bodyUnitId: -1 }], + [4, "zeusCamera", { curatorId: 90, x: 1, y: 2, dir: 90, fov: 0.75 }], + [8, "zeusRemoteControl", { curatorId: 90, unitId: 7, active: true }], + [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" }], + ], + })); + + expect(manifest.events).toEqual([ + { frameNum: 2, type: "zeusEntity", payload: { curatorId: 90, name: "Danny", playerUid: "7656", bodyUnitId: -1 } }, + { 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" } }, + ]); + }); + + it("decodes support events and skips malformed payloads", () => { + const manifest = decoder.decodeManifest(toBuffer({ + worldName: "Altis", + missionName: "Support", + endFrame: 600, + captureDelay: 1, + events: [ + [10, "explosion", { x: 3411, y: 9002, ammo: "Sh_155mm_AMOS", name: "155mm HE", radius: 28, power: 180, firerId: 17, vehicleId: 4, side: "WEST", source: "projectile" }], + // A blast with no radius cannot be drawn and says nothing a hit event does not. + [11, "explosion", { x: 1, y: 2, ammo: "B_556x45_Ball", name: "5.56", radius: 0 }], + [20, "serviceEvent", { kind: "refuel", vehicleId: 9, vehicleName: "Hunter", unitId: 3, unitName: "Danny", side: "WEST", x: 1, y: 2, from: 0.12, to: 0.98 }], + [21, "serviceEvent", { kind: "refuel", vehicleId: 9 }], + [30, "staticWeapon", { action: "assembled", unitId: 3, unitName: "Danny", side: "WEST", vehicleId: 88, class: "B_HMG_01_high_F", name: "M2 (High)", x: 1, y: 2 }], + [31, "staticWeapon", { action: "assembled" }], + [40, "radioTransmission", { unitId: 3, radio: "AN/PRC-152", type: "SW", action: "Start", channel: 1, additional: false, frequency: 69.9, code: "" }], + // A transmission with no frequency names no net, so it cannot be placed. + [41, "radioTransmission", { unitId: 3, radio: "AN/PRC-152", type: "SW", action: "Start" }], + ], + })); + + expect(manifest.events).toEqual([ + { frameNum: 10, type: "explosion", payload: { x: 3411, y: 9002, ammo: "Sh_155mm_AMOS", name: "155mm HE", radius: 28, power: 180, firerId: 17, vehicleId: 4, side: "WEST", source: "projectile" } }, + { frameNum: 20, type: "serviceEvent", payload: { kind: "refuel", vehicleId: 9, vehicleName: "Hunter", unitId: 3, unitName: "Danny", side: "WEST", x: 1, y: 2, from: 0.12, to: 0.98 } }, + { frameNum: 30, type: "staticWeapon", payload: { action: "assembled", unitId: 3, unitName: "Danny", side: "WEST", vehicleId: 88, class: "B_HMG_01_high_F", name: "M2 (High)", x: 1, y: 2 } }, + { frameNum: 40, type: "radioTransmission", payload: { unitId: 3, radio: "AN/PRC-152", type: "SW", action: "Start", channel: 1, additional: false, frequency: 69.9, code: "" } }, + ]); + }); + + // The recorder writes the blast radius to one decimal rather than rounding to + // a whole metre, because rounding turned any sub-metre blast into radius 0 and + // the guard below then discarded the event. Recordings written before that + // carry plain integers, so both have to read back. + it("keeps both a legacy integer radius and a fractional one", () => { + const manifest = decoder.decodeManifest(toBuffer({ + worldName: "Altis", + missionName: "Radius", + endFrame: 600, + captureDelay: 1, + events: [ + [10, "explosion", { x: 1, y: 2, ammo: "Sh_155mm_AMOS", name: "155mm", radius: 28 }], + [11, "explosion", { x: 1, y: 2, ammo: "HandGrenade", name: "Grenade", radius: 5.4 }], + [12, "explosion", { x: 1, y: 2, ammo: "Tiny", name: "Tiny", radius: 0.4 }], + ], + })); + + expect(manifest.events.map((e) => (e as { payload: { radius: number } }).payload.radius)) + .toEqual([28, 5.4, 0.4]); + }); + + it("still drops a blast with no drawable radius", () => { + const manifest = decoder.decodeManifest(toBuffer({ + worldName: "Altis", + missionName: "Radius", + endFrame: 600, + captureDelay: 1, + events: [ + [10, "explosion", { x: 1, y: 2, radius: 0 }], + [11, "explosion", { x: 1, y: 2, radius: -3 }], + [12, "explosion", { x: 1, y: 2 }], + ], + })); + + expect(manifest.events).toEqual([]); + }); + + it("skips empty dense-array entity holes", () => { + const manifest = decoder.decodeManifest(toBuffer({ + worldName: "Altis", + missionName: "Zeus", + endFrame: 100, + captureDelay: 1, + entities: [ + { id: 0, name: "", side: "", isPlayer: 0, type: "", startFrameNum: 0, positions: null }, + { + id: 1, + type: "unit", + name: "Horacek", + side: "WEST", + isPlayer: 0, + startFrameNum: 0, + positions: [[[100, 200], 0, 1, 0, "Horacek", 0]], + }, + ], + })); + + expect(manifest.entities).toHaveLength(1); + expect(manifest.entities[0].id).toBe(1); + expect(manifest.entities[0].name).toBe("Horacek"); + }); + + 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", @@ -849,6 +1120,7 @@ describe("JsonDecoder.decodeManifest", () => { expect(positions[0].groupName).toBe("Alpha 1"); expect(positions[0].side).toBe("WEST"); + expect(positions[0].role).toBe("rifleman"); expect(positions[1].groupName).toBe("Bravo 1"); expect(positions[1].side).toBe("EAST"); diff --git a/ui/src/data/decoders/__tests__/protobufDecoder.test.ts b/ui/src/data/decoders/__tests__/protobufDecoder.test.ts index 66b90996b..c16fb9f31 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,11 +16,361 @@ 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", () => { 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("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("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, + worldName: "Altis", + missionName: "Zeus", + endFrame: 100, + chunkSize: 300, + captureDelayMs: 1000, + chunkCount: 1, + events: [ + { frameNum: 2, type: "zeusEntity", message: JSON.stringify({ curatorId: 90, name: "Danny", playerUid: "7656", bodyUnitId: -1 }) }, + { 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 }) }, + ], + }); + + expect(decoder.decodeManifest(buffer).events).toEqual([ + { frameNum: 2, type: "zeusEntity", payload: { curatorId: 90, name: "Danny", playerUid: "7656", bodyUnitId: -1 } }, + { 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 } }, + ]); + }); + + it("decodes support payloads from the generic event message", () => { + const buffer = encodePb(PbManifest, { + version: 1, + worldName: "Altis", + missionName: "Support", + endFrame: 600, + chunkSize: 300, + captureDelayMs: 1000, + chunkCount: 1, + events: [ + { frameNum: 10, type: "explosion", message: JSON.stringify({ x: 3411, y: 9002, ammo: "Sh_155mm_AMOS", name: "155mm HE", radius: 28, power: 180, firerId: 17, vehicleId: 4, side: "WEST", source: "projectile" }) }, + // A blast with no radius cannot be drawn and says nothing a hit event does not. + { frameNum: 11, type: "explosion", message: JSON.stringify({ x: 1, y: 2, radius: 0 }) }, + { frameNum: 20, type: "serviceEvent", message: JSON.stringify({ kind: "rearm", vehicleId: 9, vehicleName: "Hunter", unitId: 3, unitName: "Danny", side: "WEST", x: 1, y: 2, from: 0, to: 0, magazine: "500Rnd_127x99_mag_Tracer_Red", magazineName: "12.7 mm", count: 2, rounds: 500 }) }, + { frameNum: 30, type: "staticWeapon", message: JSON.stringify({ action: "disassembled", unitId: 3, unitName: "Danny", side: "WEST", vehicleId: 88, class: "B_HMG_01_high_F", name: "M2 (High)", x: 1, y: 2 }) }, + { frameNum: 31, type: "staticWeapon", message: "not json" }, + { frameNum: 40, type: "radioTransmission", message: JSON.stringify({ unitId: 3, radio: "AN/PRC-152", type: "SW", action: "Stop", channel: 1, additional: false, frequency: 69.9, code: "0451" }) }, + ], + }); + + expect(decoder.decodeManifest(buffer).events).toEqual([ + { frameNum: 10, type: "explosion", payload: { x: 3411, y: 9002, ammo: "Sh_155mm_AMOS", name: "155mm HE", radius: 28, power: 180, firerId: 17, vehicleId: 4, side: "WEST", source: "projectile" } }, + { frameNum: 20, type: "serviceEvent", payload: { kind: "rearm", vehicleId: 9, vehicleName: "Hunter", unitId: 3, unitName: "Danny", side: "WEST", x: 1, y: 2, from: 0, to: 0, magazine: "500Rnd_127x99_mag_Tracer_Red", magazineName: "12.7 mm", count: 2, rounds: 500 } }, + { frameNum: 30, type: "staticWeapon", payload: { action: "disassembled", unitId: 3, unitName: "Danny", side: "WEST", vehicleId: 88, class: "B_HMG_01_high_F", name: "M2 (High)", x: 1, y: 2 } }, + { frameNum: 40, type: "radioTransmission", payload: { unitId: 3, radio: "AN/PRC-152", type: "SW", action: "Stop", channel: 1, additional: false, frequency: 69.9, code: "0451" } }, + ]); + }); + + 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/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 3d85b4850..a53e093c3 100644 --- a/ui/src/data/decoders/jsonDecoder.ts +++ b/ui/src/data/decoders/jsonDecoder.ts @@ -11,6 +11,12 @@ import type { Side, } from "../types"; import type { DecoderStrategy } from "./decoder.interface"; +import { + extractRadioPropagation, + parseAcreSettingsPayload, + parseTfarSettingsPayload, +} from "../radioPropagation"; +import { parseWeatherPayload } from "../weather"; // ───────── Legacy JSON shape ───────── @@ -115,6 +121,9 @@ function mapSide(rawSide: string): Side { case "CIV": case "CIVILIAN": return "CIV"; + case "VIRTUAL": + case "LOGIC": + return "VIRTUAL"; default: return "CIV"; } @@ -129,9 +138,10 @@ function convertUnitPosition(frame: unknown[]): EntityState { const isInVehicle = vehicleField !== 0; const name = typeof frame[4] === "string" ? frame[4] : undefined; const isPlayer = frame[5] === 1 || frame[5] === true; - // field[6] is currentRole (unused), field[7] is groupID, field[8] is side + // field[6] is currentRole, field[7] is groupID, field[8] is side const groupName = typeof frame[7] === "string" ? frame[7] : undefined; const rawSide = typeof frame[8] === "string" ? frame[8] : undefined; + const role = typeof frame[6] === "string" ? frame[6] : undefined; const state: EntityState = { position: toArmaCoord(pos), direction: dir, @@ -141,6 +151,7 @@ function convertUnitPosition(frame: unknown[]): EntityState { name: name || undefined, isPlayer, }; + if (role) state.role = role; if (groupName) state.groupName = groupName; if (rawSide) state.side = mapSide(rawSide); return state; @@ -304,6 +315,78 @@ 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 }; + } + 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 }; + } + 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; + const radius = (payload as { radius?: unknown }).radius; + if (typeof radius !== "number" || !Number.isFinite(radius) || radius <= 0) return null; + return { frameNum, type, payload } as EventDef; + } + case "radioTransmission": { + const payload = raw[2]; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + const frequency = (payload as { frequency?: unknown }).frequency; + // A transmission with no frequency names no net, so it can neither be + // grouped nor resolved to receivers. + if (typeof frequency !== "number" || !Number.isFinite(frequency)) return null; + return { frameNum, type, payload } as EventDef; + } + case "serviceEvent": + case "staticWeapon": { + 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 "zeusEntity": + case "zeusRemoteControl": + case "zeusCamera": + case "zeusPing": { + const payload = raw[2]; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return 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; @@ -368,10 +451,14 @@ export class JsonDecoder implements DecoderStrategy { const text = new TextDecoder().decode(buffer); 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 entities: EntityDef[] = (data.entities ?? []) + .filter((raw) => Boolean(raw?.type) || Boolean(raw?.name)) + .map(convertEntity); + 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 +479,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, }; } @@ -406,4 +495,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 c6d4bb7fd..c62915a08 100644 --- a/ui/src/data/decoders/protobufDecoder.ts +++ b/ui/src/data/decoders/protobufDecoder.ts @@ -12,9 +12,16 @@ import { type Side, } from "../types"; import type { DecoderStrategy } from "./decoder.interface"; +import { + extractRadioPropagation, + parseAcreSettingsPayload, + parseTfarSettingsPayload, +} from "../radioPropagation"; +import { parseWeatherPayload } from "../weather"; import { Manifest as PbManifest, Chunk as PbChunk, + PlayerSnapshotSeries as PbPlayerSnapshotSeries, EntityType as PbEntityType, Side as PbSide, type EntityDef as PbEntityDef, @@ -63,6 +70,8 @@ function mapSideString(raw: string): Side { case "INDEPENDENT": return "GUER"; case "CIV": case "CIVILIAN": return "CIV"; + case "VIRTUAL": + case "LOGIC": return "VIRTUAL"; default: return "CIV"; } } @@ -149,6 +158,112 @@ 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 "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 "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); + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + if (typeof payload.radius !== "number" || !Number.isFinite(payload.radius) || payload.radius <= 0) return null; + return { frameNum, type, payload } as EventDef; + } catch { + return null; + } + } + case "radioTransmission": { + try { + const payload = JSON.parse(pb.message); + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + if (typeof payload.frequency !== "number" || !Number.isFinite(payload.frequency)) return null; + return { frameNum, type, payload } as EventDef; + } catch { + return null; + } + } + case "serviceEvent": + case "staticWeapon": { + try { + 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 "zeusEntity": + case "zeusRemoteControl": + case "zeusCamera": + case "zeusPing": { + try { + const payload = JSON.parse(pb.message); + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + if (typeof payload.curatorId !== "number") return null; + return { frameNum, type, payload } as EventDef; + } catch { + 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, @@ -232,6 +347,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, @@ -241,8 +359,10 @@ export class ProtobufDecoder implements DecoderStrategy { chunkSize: pb.chunkSize, captureDelayMs: pb.captureDelayMs, chunkCount: pb.chunkCount, - entities: pb.entities.map(convertEntityDef), - events: pb.events.map(convertEvent).filter((e): e is EventDef => e !== null), + entities: pb.entities + .filter((raw) => Boolean(raw.name) || raw.type !== PbEntityType.ENTITY_TYPE_UNKNOWN) + .map(convertEntityDef), + events: extracted.events, markers: pb.markers.map(convertMarkerDef), times: pb.times.map((t) => ({ frameNum: t.frameNum, @@ -252,9 +372,23 @@ export class ProtobufDecoder implements DecoderStrategy { })), extensionVersion: pb.extensionVersion || undefined, 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/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/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/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..805aaf918 --- /dev/null +++ b/ui/src/data/snapshotDiff.ts @@ -0,0 +1,125 @@ +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 + * for the whole mission. + */ +export function isSnapshotDiff(payload: RawPlayerSnapshot): payload is PlayerSnapshotDiff { + return typeof (payload as PlayerSnapshotDiff).diffOf === "number"; +} + +/** + * 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, +): Record { + const result = { ...target }; + for (const [key, value] of Object.entries(patch)) { + const existing = result[key]; + result[key] = isKeyedArrayPatch(value) + ? mergedArray(existing, value) + : 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..cdcf0f3d5 100644 --- a/ui/src/data/types.ts +++ b/ui/src/data/types.ts @@ -1,11 +1,13 @@ import type { ArmaCoord } from "../utils/coordinates"; +import type { AcreRadioPropagation, TfarRadioPropagation } from "./radioPropagation"; /** Faction side. */ -export type Side = "WEST" | "EAST" | "GUER" | "CIV"; +export type Side = "WEST" | "EAST" | "GUER" | "CIV" | "VIRTUAL"; /** Entity class used for icon/behavior selection. */ export type EntityType = | "man" + | "zeus" | "car" | "tank" | "apc" @@ -55,6 +57,8 @@ export interface EntityState { groupName?: string; /** Per-frame side (may change mid-mission). */ side?: Side; + /** Per-frame unit type from getUnitType (Man, MG, AT, Medic, …). */ + role?: string; } // --------------- Event discriminated union --------------- @@ -101,6 +105,396 @@ export interface TerminalHackEventDef { unitName: string; } +export type PlayerSnapshotType = + | "inventorySnapshot" + | "medicalSnapshot" + | "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; + /** + * 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 { + 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; + 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; + /** An English label the recorder writes; never a localized config name. */ + 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[]; +} + +/** + * 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 { + type: PlayerSnapshotType; + payload: RawPlayerSnapshot; +} + +export interface TfarSettingsEventDef { + type: "tfarSettings"; + payload: TfarRadioPropagation; +} + +export interface AcreSettingsEventDef { + type: "acreSettings"; + payload: AcreRadioPropagation; +} + +export interface ServerFpsEventDef { + type: "serverFps"; + 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; + playerUid: string; + bodyUnitId: number; +} + +export interface ZeusRemoteControlPayload { + curatorId: number; + unitId: number; + active: boolean; + playerUid?: string; + playerName?: string; +} + +export interface ZeusCameraPayload { + curatorId: number; + x: number; + y: number; + dir: number; + fov: number; + pitch?: number; +} + +/** A player pinged Zeus. Reported by the Zeus client; position stamped server-side. */ +export interface ZeusPingPayload { + curatorId: number; + unitId: number; + name: string; + side: string; + x: number; + 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. + */ +export interface ExplosionPayload { + x: number; + y: number; + ammo: string; + name: string; + radius: number; + power: number; + firerId: number; + vehicleId: number; + side: string; + source: "projectile" | "explosive" | string; +} + +/** + * A vehicle was repaired, refuelled or rearmed. `from`/`to` span the whole job: + * fuel fraction for a refuel, damage for a repair, both 0 for a rearm, which + * describes itself with the magazine fields instead. + */ +export interface ServiceEventPayload { + kind: "repair" | "refuel" | "rearm" | string; + vehicleId: number; + vehicleName: string; + unitId: number; + unitName: string; + side: string; + x: number; + y: number; + from: number; + to: number; + magazine?: string; + magazineName?: string; + count?: number; + rounds?: number; +} + +/** + * A static weapon was assembled from bags or taken apart. `vehicleId` points at + * the gun's own entity record, or -1 when it never got one. + */ +export interface StaticWeaponPayload { + action: "assembled" | "disassembled" | string; + unitId: number; + unitName: string; + side: string; + vehicleId: number; + class: string; + name: string; + x: number; + y: number; +} + +/** + * One push-to-talk. `Start` and `Stop` arrive as separate events from the same + * unit, so a transmission's duration is the gap between the pair. + * + * Carries no range: what decides who could hear it is the radio, whose range + * and mod are recorded by that player's radioSnapshot. A reader joins the two. + */ +export interface RadioTransmissionPayload { + unitId: number; + radio: string; + type: "SW" | "LR" | "SR" | string; + action: "Start" | "Stop" | string; + channel: number; + additional: boolean; + frequency: number; + code: string; +} + +export interface RadioTransmissionEventDef { + type: "radioTransmission"; + payload: RadioTransmissionPayload; +} + +export interface ExplosionEventDef { + type: "explosion"; + payload: ExplosionPayload; +} + +export interface ServiceEventDef { + type: "serviceEvent"; + payload: ServiceEventPayload; +} + +export interface StaticWeaponEventDef { + type: "staticWeapon"; + payload: StaticWeaponPayload; +} + +export interface ZeusEntityEventDef { + type: "zeusEntity"; + payload: ZeusEntityPayload; +} + +export interface ZeusRemoteControlEventDef { + type: "zeusRemoteControl"; + payload: ZeusRemoteControlPayload; +} + +export interface ZeusCameraEventDef { + type: "zeusCamera"; + payload: ZeusCameraPayload; +} + +export interface ZeusPingEventDef { + type: "zeusPing"; + payload: ZeusPingPayload; +} + +export interface ZeusModuleEventDef { + type: "zeusModule"; + payload: ZeusModulePayload; +} + export type EventDef = { frameNum: number } & ( | HitKilledEventDef | ConnectEventDef @@ -109,6 +503,20 @@ export type EventDef = { frameNum: number } & ( | GeneralEventDef | CapturedEventDef | TerminalHackEventDef + | PlayerSnapshotEventDef + | TfarSettingsEventDef + | AcreSettingsEventDef + | ServerFpsEventDef + | WeatherEventDef + | ZeusEntityEventDef + | ZeusRemoteControlEventDef + | ZeusCameraEventDef + | ZeusPingEventDef + | ZeusModuleEventDef + | ExplosionEventDef + | ServiceEventDef + | StaticWeaponEventDef + | RadioTransmissionEventDef ); // --------------- Markers --------------- @@ -117,7 +525,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 +556,16 @@ 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; + /** + * 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. */ @@ -194,6 +612,8 @@ export interface WorldConfig { hasTopoDark?: boolean; hasTopoRelief?: boolean; hasColorRelief?: boolean; + hasHeightmap?: boolean; + hasDem?: boolean; attribution?: string; } 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/__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..02ef76072 100644 --- a/ui/src/i18n/locales.ts +++ b/ui/src/i18n/locales.ts @@ -147,16 +147,6 @@ export const translations: Record> = { fi: "Jaettu", uk: "Поділитись", }, - copy_link: { - ru: "Скопируйте ссылку", - en: "Copy link", - de: "Link kopieren", - cs: "Kopírovat odkaz", - it: "Copia link", - fr: "Copier le lien", - fi: "Kopioi linkki", - uk: "Скопіюйте посилання", - }, close: { ru: "Закрыть", en: "Close", @@ -1007,16 +997,6 @@ export const translations: Record> = { fi: "Jaa", uk: "Поділитись", }, - link_copied: { - ru: "Ссылка скопирована!", - en: "Link copied!", - de: "Link kopiert!", - cs: "Odkaz zkopírován!", - it: "Link copiato!", - fr: "Lien copié !", - fi: "Linkki kopioitu!", - uk: "Посилання скопійовано!", - }, back_to_recordings: { ru: "К списку записей", en: "Back to recordings", @@ -1247,6 +1227,616 @@ export const translations: Record> = { fi: "Tehtävän tapahtumat", uk: "Події місії", }, + zeus_pings: { + ru: "Пинги Зевса", + en: "Zeus pings", + de: "Zeus-Pings", + cs: "Pingy na Zeuse", + it: "Ping a Zeus", + fr: "Pings Zeus", + fi: "Zeus-pingit", + uk: "Пінги Зевса", + }, + pinged_zeus: { + ru: "запросил внимание Зевса", + en: "pinged Zeus", + de: "hat Zeus angepingt", + cs: "pingnul Zeuse", + it: "ha pingato Zeus", + fr: "a pingé Zeus", + 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", + de: "Explosionen", + cs: "Výbuchy", + it: "Esplosioni", + fr: "Explosions", + fi: "Räjähdykset", + uk: "Вибухи", + }, + servicing: { + ru: "Обслуживание", + en: "Servicing", + de: "Instandsetzung", + cs: "Servis vozidel", + it: "Manutenzione", + fr: "Maintenance", + fi: "Huolto", + uk: "Обслуговування", + }, + constructions: { + ru: "Фортификации", + en: "Fortifications", + de: "Befestigungen", + cs: "Opevnění", + it: "Fortificazioni", + fr: "Fortifications", + fi: "Linnoitteet", + uk: "Фортифікації", + }, + detonated: { + ru: "взорвался", + en: "detonated", + de: "detoniert", + cs: "detonoval", + it: "è esploso", + fr: "a explosé", + fi: "räjähti", + uk: "вибухнув", + }, + repair: { + ru: "отремонтировал", + en: "repaired", + de: "reparierte", + cs: "opravil", + it: "ha riparato", + fr: "a réparé", + fi: "korjasi", + uk: "відремонтував", + }, + refuel: { + ru: "заправил", + en: "refuelled", + de: "betankte", + cs: "natankoval", + it: "ha rifornito", + fr: "a ravitaillé", + fi: "tankkasi", + uk: "заправив", + }, + rearm: { + ru: "пополнил боезапас", + en: "rearmed", + de: "bewaffnete auf", + cs: "doplnil munici", + it: "ha rifornito di munizioni", + fr: "a réarmé", + fi: "täydensi ammukset", + uk: "поповнив боєзапас", + }, + assembled: { + ru: "собрал", + en: "assembled", + de: "baute auf", + cs: "postavil", + it: "ha assemblato", + fr: "a assemblé", + fi: "kokosi", + uk: "зібрав", + }, + disassembled: { + ru: "разобрал", + en: "disassembled", + de: "baute ab", + cs: "rozebral", + it: "ha smontato", + fr: "a démonté", + fi: "purki", + uk: "розібрав", + }, + comms: { + ru: "Связь", + en: "Comms", + de: "Funk", + cs: "Spojení", + it: "Comms", + fr: "Comms", + fi: "Viestit", + uk: "Зв'язок", + }, + comms_nets: { + ru: "РАДИОСЕТИ", + en: "RADIO NETS", + de: "FUNKNETZE", + cs: "RÁDIOVÉ SÍTĚ", + it: "RETI RADIO", + fr: "RÉSEAUX RADIO", + fi: "RADIOVERKOT", + uk: "РАДІОМЕРЕЖІ", + }, + comms_transmissions: { + ru: "ПЕРЕДАЧИ", + en: "TRANSMISSIONS", + de: "FUNKSPRÜCHE", + cs: "PŘENOSY", + it: "TRASMISSIONI", + fr: "TRANSMISSIONS", + fi: "LÄHETYKSET", + uk: "ПЕРЕДАЧІ", + }, + comms_no_data: { + ru: "В этой записи нет данных о радио", + en: "No radio data in this recording", + de: "Keine Funkdaten in dieser Aufzeichnung", + cs: "V tomto záznamu nejsou data o rádiích", + it: "Nessun dato radio in questa registrazione", + fr: "Aucune donnée radio dans cet enregistrement", + fi: "Tässä tallenteessa ei ole radiotietoja", + uk: "У цьому записі немає даних про радіо", + }, + comms_no_nets: { + ru: "В этот момент раций нет", + en: "No radios carried at this moment", + de: "Zu diesem Zeitpunkt keine Funkgeräte", + cs: "V tuto chvíli nikdo nenese rádio", + it: "Nessuna radio in questo momento", + fr: "Aucune radio à cet instant", + fi: "Ei radioita tällä hetkellä", + uk: "Наразі рацій немає", + }, + comms_no_transmissions_here: { + ru: "Рядом с курсором нет передач", + en: "No transmissions near the playhead", + de: "Keine Funksprüche in der Nähe der Abspielposition", + cs: "V okolí přehrávací pozice nejsou přenosy", + it: "Nessuna trasmissione vicino alla posizione", + fr: "Aucune transmission près du curseur", + fi: "Ei lähetyksiä toistokohdan lähellä", + uk: "Поблизу позиції відтворення немає передач", + }, + comms_monitoring: { + ru: "прослушивание", + en: "monitoring", + de: "mithört", + cs: "poslouchá", + it: "in ascolto", + fr: "en écoute", + fi: "kuuntelee", + uk: "прослуховує", + }, + comms_transmitting: { + ru: "передаёт", + en: "transmitting", + de: "sendet", + cs: "vysílá", + it: "trasmette", + fr: "émet", + fi: "lähettää", + uk: "передає", + }, + comms_in_range: { + ru: "в зоне", + en: "in range", + de: "in Reichweite", + cs: "v dosahu", + it: "in portata", + fr: "à portée", + fi: "kantamalla", + uk: "у зоні", + }, + comms_out_of_range: { + ru: "вне зоны", + en: "out of range", + de: "außer Reichweite", + cs: "mimo dosah", + it: "fuori portata", + fr: "hors de portée", + fi: "kantaman ulkopuolella", + uk: "поза зоною", + }, + comms_note_terrain: { + ru: "Дальность проверяется с учётом рельефа — той же моделью, что рисует зону покрытия. Это доступность связи, а не внимание.", + en: "Range is checked against terrain, the same model the coverage overlay draws. It says who could be reached, not who was listening.", + de: "Die Reichweite wird gegen das Gelände geprüft, mit demselben Modell wie die Abdeckungsanzeige. Sie zeigt Erreichbarkeit, nicht Aufmerksamkeit.", + cs: "Dosah se počítá s terénem, stejným modelem jako překryv pokrytí. Říká, kdo byl dosažitelný, ne kdo poslouchal.", + it: "La portata considera il terreno, con lo stesso modello della copertura. Indica chi era raggiungibile, non chi ascoltava.", + fr: "La portée tient compte du terrain, avec le modèle de la couverture. Elle indique qui était joignable, pas qui écoutait.", + fi: "Kantama lasketaan maastoa vasten, samalla mallilla kuin peittoalue. Se kertoo kuka oli tavoitettavissa, ei kuka kuunteli.", + uk: "Дальність перевіряється з урахуванням рельєфу — тією ж моделлю, що малює зону покриття. Це досяжність, а не увага.", + }, + comms_note_line_of_sight: { + ru: "Для этой карты нет данных рельефа, дальность считается по прямой.", + en: "No terrain data for this map, so range is straight-line distance only.", + de: "Keine Geländedaten für diese Karte, die Reichweite ist reine Luftlinie.", + cs: "Pro tuto mapu nejsou data terénu, dosah je jen vzdušnou čarou.", + it: "Nessun dato del terreno per questa mappa: la portata è solo in linea d'aria.", + fr: "Pas de données de terrain pour cette carte, la portée est à vol d'oiseau.", + fi: "Tälle kartalle ei ole maastodataa, joten kantama on vain linnuntietä.", + uk: "Для цієї карти немає даних рельєфу, дальність рахується по прямій.", + }, + transmissions: { + ru: "Передачи", + en: "Transmissions", + de: "Funksprüche", + cs: "Přenosy", + it: "Trasmissioni", + fr: "Transmissions", + fi: "Lähetykset", + uk: "Передачі", + }, + transmitted_on: { + ru: "передал на", + en: "transmitted on", + de: "sendete auf", + cs: "vysílal na", + it: "ha trasmesso su", + fr: "a émis sur", + fi: "lähetti taajuudella", + uk: "передав на", + }, + orbat: { + ru: "ОШС", + en: "ORBAT", + de: "Gliederung", + cs: "Struktura", + it: "ORBAT", + fr: "ORBAT", + fi: "Kokoonpano", + uk: "ОШС", + }, + orbat_groups: { + ru: "ГРУППЫ", + en: "GROUPS", + de: "GRUPPEN", + cs: "SKUPINY", + it: "GRUPPI", + fr: "GROUPES", + fi: "RYHMÄT", + uk: "ГРУПИ", + }, + orbat_vehicles: { + ru: "ЗАНЯТОСТЬ ТЕХНИКИ", + en: "VEHICLE OCCUPANCY", + de: "FAHRZEUGBELEGUNG", + cs: "OBSAZENÍ VOZIDEL", + it: "OCCUPAZIONE VEICOLI", + fr: "OCCUPATION DES VÉHICULES", + fi: "AJONEUVOJEN MIEHITYS", + uk: "ЗАЙНЯТІСТЬ ТЕХНІКИ", + }, + orbat_leader: { + ru: "КОМ", + en: "LEAD", + de: "FÜHR", + cs: "VEL", + it: "CAPO", + fr: "CHEF", + fi: "JOHT", + uk: "КОМ", + }, + orbat_mounted: { + ru: "в технике", + en: "mounted", + de: "aufgesessen", + cs: "ve vozidle", + it: "a bordo", + fr: "embarqué", + fi: "kyydissä", + uk: "у техніці", + }, + orbat_regrouped: { + ru: "Переведён в другую группу во время миссии", + en: "Reassigned to another group during the mission", + de: "Während der Mission einer anderen Gruppe zugeteilt", + cs: "Během mise přeřazen do jiné skupiny", + it: "Riassegnato a un altro gruppo durante la missione", + fr: "Réaffecté à un autre groupe pendant la mission", + fi: "Siirretty toiseen ryhmään tehtävän aikana", + uk: "Переведений до іншої групи під час місії", + }, + orbat_no_groups: { + ru: "На этой стороне сейчас нет юнитов", + en: "No units on this side at this moment", + de: "Zu diesem Zeitpunkt keine Einheiten auf dieser Seite", + cs: "Na této straně nyní nejsou žádné jednotky", + it: "Nessuna unità per questa fazione in questo momento", + fr: "Aucune unité de ce camp à cet instant", + fi: "Ei yksiköitä tällä puolella juuri nyt", + 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: "Загрузка всей миссии…", + en: "Loading the full mission…", + de: "Lade die gesamte Mission…", + cs: "Načítání celé mise…", + it: "Caricamento dell'intera missione…", + fr: "Chargement de la mission complète…", + fi: "Ladataan koko tehtävää…", + uk: "Завантаження всієї місії…", + }, + orbat_empty: { + ru: "пусто", + en: "empty", + de: "leer", + cs: "prázdné", + it: "vuoto", + fr: "vide", + fi: "tyhjä", + uk: "порожньо", + }, + movement: { + ru: "Пройденное расстояние", + en: "Distance covered", + de: "Zurückgelegte Strecke", + cs: "Uražená vzdálenost", + it: "Distanza percorsa", + fr: "Distance parcourue", + fi: "Kuljettu matka", + uk: "Пройдена відстань", + }, + movement_loading: { + ru: "Загрузка всей миссии…", + en: "Loading the full mission…", + de: "Lade die gesamte Mission…", + cs: "Načítání celé mise…", + it: "Caricamento dell'intera missione…", + fr: "Chargement de la mission complète…", + fi: "Ladataan koko tehtävää…", + uk: "Завантаження всієї місії…", + }, + movement_none: { + ru: "Движение не записано", + en: "No movement recorded", + de: "Keine Bewegung aufgezeichnet", + cs: "Nezaznamenán žádný pohyb", + it: "Nessun movimento registrato", + fr: "Aucun déplacement enregistré", + fi: "Liikettä ei tallennettu", + uk: "Рух не записано", + }, + movement_on_foot: { + ru: "Пешком", + en: "On foot", + de: "Zu Fuß", + cs: "Pěšky", + it: "A piedi", + fr: "À pied", + fi: "Jalan", + uk: "Пішки", + }, + movement_mounted: { + ru: "В технике", + en: "In vehicles", + de: "In Fahrzeugen", + cs: "Ve vozidlech", + it: "Nei veicoli", + fr: "En véhicule", + fi: "Ajoneuvoissa", + uk: "У техніці", + }, + movement_total: { + ru: "Всего", + en: "Total", + de: "Gesamt", + cs: "Celkem", + it: "Totale", + fr: "Total", + fi: "Yhteensä", + uk: "Разом", + }, + movement_foot_short: { + ru: "ПЕШ", + en: "FOOT", + de: "FUSS", + cs: "PĚŠ", + it: "PIEDI", + fr: "PIED", + fi: "JALAN", + uk: "ПІШ", + }, + movement_mounted_short: { + ru: "ТЕХ", + en: "VEH", + de: "FZG", + cs: "VOZ", + it: "VEIC", + fr: "VÉH", + fi: "AJON", + uk: "ТЕХ", + }, + movement_total_short: { + ru: "ВСЕГО", + en: "TOTAL", + de: "GESAMT", + cs: "CELKEM", + it: "TOT", + fr: "TOTAL", + fi: "YHT", + uk: "РАЗОМ", + }, + section_overlays: { + ru: "СЛОИ АНАЛИЗА", + en: "ANALYSIS OVERLAYS", + de: "ANALYSE-EBENEN", + cs: "ANALYTICKÉ VRSTVY", + it: "LIVELLI DI ANALISI", + fr: "CALQUES D'ANALYSE", + fi: "ANALYYSITASOT", + uk: "ШАРИ АНАЛІЗУ", + }, + overlay_death_heatmap: { + ru: "Тепловая карта смертей", + en: "Death heatmap", + de: "Todes-Heatmap", + cs: "Teplotní mapa úmrtí", + it: "Mappa di calore delle morti", + fr: "Carte thermique des morts", + fi: "Kuolemien lämpökartta", + uk: "Теплова карта смертей", + }, + trails_off: { + ru: "Без следов движения", + en: "No movement trails", + de: "Keine Bewegungsspuren", + cs: "Bez stop pohybu", + it: "Nessuna scia di movimento", + fr: "Aucune trace de déplacement", + fi: "Ei liikejälkiä", + uk: "Без слідів руху", + }, + trails_followed: { + ru: "След выбранного юнита", + en: "Trail for followed unit", + de: "Spur der verfolgten Einheit", + cs: "Stopa sledované jednotky", + it: "Scia dell'unità seguita", + fr: "Trace de l'unité suivie", + fi: "Seuratun yksikön jälki", + uk: "Слід обраного юніта", + }, + trails_players: { + ru: "Следы всех игроков", + en: "Trails for all players", + de: "Spuren aller Spieler", + cs: "Stopy všech hráčů", + it: "Scie di tutti i giocatori", + fr: "Traces de tous les joueurs", + fi: "Kaikkien pelaajien jäljet", + uk: "Сліди всіх гравців", + }, + bookmarks: { + ru: "Закладки", + en: "Bookmarks", + de: "Lesezeichen", + cs: "Záložky", + it: "Segnalibri", + fr: "Signets", + fi: "Kirjanmerkit", + uk: "Закладки", + }, + bookmarks_empty: { + ru: "Закладок пока нет", + en: "No bookmarks yet", + de: "Noch keine Lesezeichen", + cs: "Zatím žádné záložky", + it: "Ancora nessun segnalibro", + fr: "Aucun signet pour l'instant", + fi: "Ei vielä kirjanmerkkejä", + uk: "Закладок ще немає", + }, + bookmark_add: { + ru: "Добавить", + en: "Add", + de: "Hinzufügen", + cs: "Přidat", + it: "Aggiungi", + fr: "Ajouter", + fi: "Lisää", + uk: "Додати", + }, + bookmark_remove: { + ru: "Удалить закладку", + en: "Remove bookmark", + de: "Lesezeichen entfernen", + cs: "Odebrat záložku", + it: "Rimuovi segnalibro", + fr: "Supprimer le signet", + fi: "Poista kirjanmerkki", + uk: "Видалити закладку", + }, + bookmark_label_placeholder: { + ru: "Название момента", + en: "Label this moment", + de: "Diesen Moment benennen", + cs: "Pojmenovat okamžik", + it: "Etichetta questo momento", + fr: "Nommer ce moment", + fi: "Nimeä tämä hetki", + uk: "Назва моменту", + }, + copy_link: { + ru: "Скопируйте ссылку", + en: "Copy link", + de: "Link kopieren", + cs: "Kopírovat odkaz", + it: "Copia link", + fr: "Copier le lien", + fi: "Kopioi linkki", + uk: "Скопіюйте посилання", + }, + copy_moment_link: { + ru: "Скопировать ссылку на момент", + en: "Copy link to this moment", + de: "Link zu diesem Moment kopieren", + cs: "Kopírovat odkaz na tento okamžik", + it: "Copia il link a questo momento", + fr: "Copier le lien vers ce moment", + fi: "Kopioi linkki tähän hetkeen", + uk: "Скопіювати посилання на момент", + }, + link_copied: { + ru: "Ссылка скопирована!", + en: "Link copied!", + de: "Link kopiert!", + cs: "Odkaz zkopírován!", + it: "Link copiato!", + fr: "Lien copié !", + fi: "Linkki kopioitu!", + uk: "Посилання скопійовано!", + }, event_filters: { ru: "Фильтры событий", en: "Event filters", @@ -1307,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", @@ -1367,6 +1967,216 @@ 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: "Медіана", + }, + 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", @@ -2558,4 +3368,233 @@ 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: "Гравець" }, + zeus_controlling: { + en: "controlling {name}", + ru: "управляет {name}", + de: "steuert {name}", + cs: "ovládá {name}", + it: "controlla {name}", + fr: "contrôle {name}", + fi: "ohjaa {name}", + uk: "керує {name}", + }, + zeus_controlling_typed: { + en: "controlling {name} ({type})", + ru: "управляет {name} ({type})", + de: "steuert {name} ({type})", + cs: "ovládá {name} ({type})", + it: "controlla {name} ({type})", + fr: "contrôle {name} ({type})", + fi: "ohjaa {name} ({type})", + uk: "керує {name} ({type})", + }, + 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_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: "Витривалість" }, + profile_stance: { en: "Stance", ru: "Стойка", de: "Haltung", cs: "Postoj", it: "Postura", fr: "Posture", fi: "Asento", uk: "Стійка" }, + profile_stance_0: { en: "Unknown", ru: "Неизвестно", de: "Unbekannt", cs: "Neznámý", it: "Sconosciuta", fr: "Inconnue", fi: "Tuntematon", uk: "Невідомо" }, + 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" }, + 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: "Рації" }, + 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_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: "Ні" }, + 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_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: "Рюкзак" }, + 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 на рельєфі" }, + 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: "Код" }, + search_units: { en: "Search units...", ru: "Поиск юнитов...", de: "Einheiten suchen...", cs: "Hledat jednotky...", it: "Cerca unità...", fr: "Rechercher des unités...", fi: "Hae yksiköitä...", uk: "Пошук юнітів..." }, + search_orbat: { en: "Search ORBAT...", ru: "Поиск по составу...", de: "ORBAT durchsuchen...", cs: "Hledat v ORBAT...", it: "Cerca nell'ORBAT...", fr: "Rechercher dans l'ORBAT...", fi: "Hae ORBATista...", uk: "Пошук у складі..." }, + search_comms: { en: "Search radios...", ru: "Поиск раций...", de: "Funk durchsuchen...", cs: "Hledat rádia...", it: "Cerca radio...", fr: "Rechercher des radios...", fi: "Hae radioita...", uk: "Пошук рацій..." }, + no_units_match: { en: "No units match this search", ru: "Нет юнитов по этому запросу", de: "Keine Einheiten passen zur Suche", cs: "Hledání neodpovídají žádné jednotky", it: "Nessuna unità corrisponde alla ricerca", fr: "Aucune unité ne correspond à cette recherche", fi: "Haku ei vastaa yhtään yksikköä", uk: "Немає юнітів за цим запитом" }, + orbat_no_matches: { en: "No groups match this search", ru: "Нет групп по этому запросу", de: "Keine Gruppen passen zur Suche", cs: "Hledání neodpovídají žádné skupiny", it: "Nessun gruppo corrisponde alla ricerca", fr: "Aucun groupe ne correspond à cette recherche", fi: "Haku ei vastaa yhtään ryhmää", uk: "Немає груп за цим запитом" }, + orbat_no_vehicle_matches: { en: "No vehicles match this search", ru: "Нет техники по этому запросу", de: "Keine Fahrzeuge passen zur Suche", cs: "Hledání neodpovídají žádná vozidla", it: "Nessun veicolo corrisponde alla ricerca", fr: "Aucun véhicule ne correspond à cette recherche", fi: "Haku ei vastaa yhtään ajoneuvoa", uk: "Немає техніки за цим запитом" }, + 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/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/RecordingPlayback.tsx b/ui/src/pages/recording-playback/RecordingPlayback.tsx index 94cc5578d..d602eceaf 100644 --- a/ui/src/pages/recording-playback/RecordingPlayback.tsx +++ b/ui/src/pages/recording-playback/RecordingPlayback.tsx @@ -37,7 +37,16 @@ import { setLeftPanelVisible, setEditingFocusForShortcuts, setFocusShortcutCallbacks, + activeSide, + setActiveSide, } from "./shortcuts"; +import { loadBookmarks } from "./bookmarks"; +import { + asDeepLinkTab, + cancelThrottledDeepLink, + readDeepLink, + writeDeepLinkThrottled, +} from "./deepLink"; import { loadRecording } from "./loadRecording"; import { useRenderBridge } from "./useRenderBridge"; @@ -112,7 +121,7 @@ export function RecordingPlayback(): JSX.Element { } }; - useRenderBridge(engine, renderer, markerManager); + useRenderBridge(engine, renderer, markerManager, timeMode); // ─── Focus editing callbacks (defined before onMount so shortcuts can reference them) ─── @@ -165,6 +174,31 @@ export function RecordingPlayback(): JSX.Element { engine.seekTo(rec.focusStart); } + // Bookmarks are keyed on the recording file, so they follow the + // recording rather than the URL it was opened from. + loadBookmarks(result.recordingFilename ?? params.name ?? null); + + // A shared link is restored after the recording is in place, and after + // any focus range has already seeked, so the link wins over the default + // start position rather than racing it. + // readDeepLink has already dropped anything that is not a real tab or + // side, so nothing here needs a cast. + const link = readDeepLink(); + if (link.tab) setActivePanelTab(link.tab); + if (link.side) setActiveSide(link.side); + 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); + } + // Fetch marker blacklist (non-fatal) try { const ids = await api.getMarkerBlacklist(result.recordingId); @@ -184,8 +218,28 @@ export function RecordingPlayback(): JSX.Element { })(); }); + // Keep the URL describing what is on screen, so a copied address is a working + // link without anyone pressing anything. replaceState, so scrubbing does not + // fill the back button, and throttled, because the playhead is a + // requestAnimationFrame signal and browsers rate limit replaceState hard + // enough to throw. + createEffect(() => { + if (loading()) return; + const followed = engine.followTarget(); + writeDeepLinkThrottled({ + frame: engine.currentFrame(), + unit: followed === null ? undefined : followed, + tab: asDeepLinkTab(activePanelTab()), + side: activeSide(), + }); + }); + onCleanup(() => { unregisterShortcuts(); + // Stop the throttle before the page goes: a queued write firing after + // navigation would rewrite the URL of wherever the viewer landed next. + cancelThrottledDeepLink(); + loadBookmarks(null); markerManager.clear(); engine.dispose(); renderer.dispose(); diff --git a/ui/src/pages/recording-playback/__tests__/CommsTab.test.tsx b/ui/src/pages/recording-playback/__tests__/CommsTab.test.tsx new file mode 100644 index 000000000..f66125557 --- /dev/null +++ b/ui/src/pages/recording-playback/__tests__/CommsTab.test.tsx @@ -0,0 +1,133 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@solidjs/testing-library"; + +import { CommsTab } from "../components/CommsTab"; +import type { EventDef } from "../../../data/types"; +import { createTestEngine, TestProviders, unitDef, makeManifest } from "./testHelpers"; + +afterEach(cleanup); + +function radio(frequency: number) { + return { + class: "TFAR_anprc152_1", + mod: "TFAR", + type: "SW", + channel: 1, + frequency, + code: "", + rangeMeters: 5000, + additional: false, + }; +} + +function radioSnapshot(unitId: number, frequency: number): EventDef { + return { + type: "radioSnapshot", + frameNum: 0, + payload: { unitId, playerUid: `uid${unitId}`, radios: [radio(frequency)] }, + } as unknown as EventDef; +} + +/** + * Three players on two nets: Alpha and Bravo share 100.000, Charlie sits alone + * on 200.000. Two nets is the minimum that can show whether opening one closes + * the other. + */ +function renderComms() { + const { engine, renderer } = createTestEngine(); + const positions = [{ position: [100, 200] as [number, number], direction: 0, alive: 1 as const }]; + + engine.loadRecording( + makeManifest( + [ + unitDef({ id: 1, name: "Alpha", positions }), + unitDef({ id: 2, name: "Bravo", positions }), + unitDef({ id: 3, name: "Charlie", positions }), + ], + [radioSnapshot(1, 100), radioSnapshot(2, 100), radioSnapshot(3, 200)], + ), + ); + + render(() => ( + + + + )); + + return { engine }; +} + +const NET_A = "100 MHz"; +const NET_B = "200 MHz"; + +/** The net header is the clickable row; the members live under it. */ +function header(label: string): HTMLElement { + return screen.getByText(label).closest("button") as HTMLElement; +} + +describe("CommsTab net list", () => { + it("lists every net", () => { + renderComms(); + expect(screen.getByText(NET_A)).toBeTruthy(); + expect(screen.getByText(NET_B)).toBeTruthy(); + }); + + // A mission can run a dozen nets. Opening them all by default turns the tab + // into a wall of rosters and buries the index of which nets existed at all. + it("starts with every net collapsed", () => { + renderComms(); + + expect(screen.queryByText("Alpha")).toBeNull(); + expect(screen.queryByText("Bravo")).toBeNull(); + expect(screen.queryByText("Charlie")).toBeNull(); + }); + + it("expands a net when its header is clicked", () => { + renderComms(); + + fireEvent.click(header(NET_A)); + + expect(screen.getByText("Alpha")).toBeTruthy(); + expect(screen.getByText("Bravo")).toBeTruthy(); + }); + + it("collapses the same net again on a second click", () => { + renderComms(); + + fireEvent.click(header(NET_A)); + fireEvent.click(header(NET_A)); + + expect(screen.queryByText("Alpha")).toBeNull(); + }); + + // The regression this replaced: a single-open accordion closed one net the + // moment another was opened, so two nets could never be compared side by side. + it("keeps nets open independently of each other", () => { + renderComms(); + + fireEvent.click(header(NET_A)); + fireEvent.click(header(NET_B)); + + expect(screen.getByText("Alpha")).toBeTruthy(); + expect(screen.getByText("Charlie")).toBeTruthy(); + }); + + it("closing one net leaves the others open", () => { + renderComms(); + + fireEvent.click(header(NET_A)); + fireEvent.click(header(NET_B)); + fireEvent.click(header(NET_A)); + + expect(screen.queryByText("Alpha")).toBeNull(); + expect(screen.getByText("Charlie")).toBeTruthy(); + }); + + it("reports its expanded state to assistive tech", () => { + renderComms(); + + expect(header(NET_A).getAttribute("aria-expanded")).toBe("false"); + fireEvent.click(header(NET_A)); + expect(header(NET_A).getAttribute("aria-expanded")).toBe("true"); + }); +}); 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..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()])); @@ -811,4 +847,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: EventDef[] = [ + 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: EventDef[] = [ + { + 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/__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__/PlayerProfileCard.test.tsx b/ui/src/pages/recording-playback/__tests__/PlayerProfileCard.test.tsx new file mode 100644 index 000000000..9abec072d --- /dev/null +++ b/ui/src/pages/recording-playback/__tests__/PlayerProfileCard.test.tsx @@ -0,0 +1,832 @@ +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(); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined }); +}); + +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, + extra?: { framesFired?: Array<[number, [number, number]]> }, +) { + 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, + framesFired: extra?.framesFired, + }), + ], + events, + ), + ); + engine.seekTo(frame); + const unit = engine.entityManager.getEntity(1) as Unit; + + render(() => ( + + {}} + onToggleFollow={() => {}} + /> + + )); + return { engine, renderer }; +} + +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", { + configurable: true, + value: { writeText }, + }); + renderCard([ + snapshotEvent(5, "inventorySnapshot", { + uniform: { class: "U_B_CombatUniform_mcam", items: [] }, + vest: { class: "", items: [] }, + backpack: { class: "", items: [] }, + headgear: { class: "" }, + goggles: { class: "" }, + 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", { + 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", + 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", 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", + 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", + 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", + 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(); + }); + + it("lists magazines inside their containers instead of a duplicate magazines block", () => { + renderCard([ + snapshotEvent(5, "inventorySnapshot", { + weapons: [ + { + class: "hlc_pistol_P226R_Combat", + slot: "handgun", + attachments: [], + }, + ], + magazines: [ + { 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", + items: [ + { class: "ACE_MapTools", count: 1 }, + { class: "rhs_mag_30Rnd_556x45_Mk318_PMAG", cat: "magazines", count: 1 }, + ], + }, + vest: { + class: "UK3CB_BAF_V_Osprey_SL_A", + items: [ + { 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", + items: [ + { class: "ACE_packingBandage", cat: "medical", count: 17 }, + { class: "kat_Painkiller", cat: "medical", count: 3 }, + ], + }, + }), + ]); + + const weaponsBlock = screen.getByText("Weapons").closest("[class*='gearBlock']"); + expect(weaponsBlock?.nextElementSibling?.textContent).toContain("Uniform"); + expect(weaponsBlock?.textContent).toContain("Hlc 15Rnd 9x19 B P226"); + + 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("Mag 30Rnd 556x45 Mk318 PMAG"); + expect(uniformBlock?.textContent).toContain("Map Tools"); + + const vestBlock = screen.getByText("V Osprey SL A").closest("[class*='gearBlock']"); + expect(vestBlock?.textContent).toContain("Magazines"); + 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("B Bergen MTP SL L A").closest("[class*='gearBlock']"); + expect(backpackBlock?.textContent).toContain("Medical"); + expect(backpackBlock?.textContent).not.toContain("Magazines"); + 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); + }); + + it("re-splits container magazines when a later snapshot fills them in", () => { + const { engine } = renderCard( + [ + snapshotEvent(5, "inventorySnapshot", { + weapons: [], + magazines: [], + assignedItems: [], + vest: { class: "Osprey", items: [] }, + }), + snapshotEvent(20, "inventorySnapshot", { + weapons: [], + magazines: [{ class: "30Rnd", cat: "magazines", count: 4 }], + assignedItems: [], + vest: { + class: "Osprey", + items: [ + { class: "ACE_CableTie", count: 2 }, + { class: "30Rnd", cat: "magazines", count: 4 }, + ], + }, + }), + ], + 5, + ); + + expect(screen.queryByText("Magazines")).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 ×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", 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: "Osprey", + items: [ + { 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 }, + ], + }, + }), + ]); + + 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("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")); + }); + + it("shows carried magazine rounds after deaths, skipping grenades and medical", () => { + renderCard([ + snapshotEvent(5, "inventorySnapshot", { + weapons: [], + magazines: [ + { 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: [], + }), + ]); + + 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", 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"); + }); + + it("compares two times after Diff is pinned and the playhead moves", () => { + const { engine } = renderCard( + [ + snapshotEvent(5, "inventorySnapshot", { + weapons: [], + magazines: [{ class: "30Rnd", cat: "magazines", count: 5, totalRounds: 120 }], + assignedItems: [], + vest: { + class: "v", + items: [{ class: "30Rnd", cat: "magazines", count: 5 }], + }, + }), + snapshotEvent(20, "inventorySnapshot", { + weapons: [], + magazines: [{ class: "30Rnd", cat: "magazines", count: 2, totalRounds: 40 }], + assignedItems: [], + vest: { + class: "v", + items: [{ class: "30Rnd", cat: "magazines", 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 ×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", () => { + const { engine } = renderCard( + [ + snapshotEvent(5, "inventorySnapshot", { + weapons: [], + magazines: [], + assignedItems: [], + vest: { + class: "v", + items: [{ class: "ACE_MapTools", count: 1 }], + }, + backpack: { class: "b", items: [] }, + }), + snapshotEvent(20, "inventorySnapshot", { + weapons: [], + magazines: [], + assignedItems: [], + vest: { class: "v", items: [] }, + backpack: { + class: "b", + items: [{ class: "ACE_MapTools", 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__/RecordingPlayback.test.tsx b/ui/src/pages/recording-playback/__tests__/RecordingPlayback.test.tsx index 96a0eaee0..22ae7a40e 100644 --- a/ui/src/pages/recording-playback/__tests__/RecordingPlayback.test.tsx +++ b/ui/src/pages/recording-playback/__tests__/RecordingPlayback.test.tsx @@ -5,8 +5,12 @@ 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 { 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"; // ─── Mocks (must be before imports that use them) ─── @@ -940,6 +944,146 @@ 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; + + 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; diff --git a/ui/src/pages/recording-playback/__tests__/SidePanel.test.tsx b/ui/src/pages/recording-playback/__tests__/SidePanel.test.tsx index 172466f2c..ec12fcef7 100644 --- a/ui/src/pages/recording-playback/__tests__/SidePanel.test.tsx +++ b/ui/src/pages/recording-playback/__tests__/SidePanel.test.tsx @@ -15,7 +15,7 @@ afterEach(() => { }); describe("SidePanel", () => { - it("renders three tab buttons (Units, Events, Stats)", () => { + it("renders every tab button", () => { const { engine, renderer } = createTestEngine(); engine.loadRecording(makeManifest([unitDef()])); @@ -27,9 +27,29 @@ describe("SidePanel", () => { )); - expect(screen.getByText("Units")).toBeTruthy(); - expect(screen.getByText("Events")).toBeTruthy(); - expect(screen.getByText("Stats")).toBeTruthy(); + for (const label of ["Units", "Events", "ORBAT", "Comms", "Stats", "FPS"]) { + expect(screen.getByText(label)).toBeTruthy(); + } + }); + + // 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", () => { + const { engine, renderer } = createTestEngine(); + engine.loadRecording(makeManifest([unitDef()])); + + const [activeTab] = createSignal("units"); + + render(() => ( + + {}} /> + + )); + + for (const label of ["Units", "Events", "ORBAT", "Comms", "Stats", "FPS"]) { + expect(screen.getByTitle(label)).toBeTruthy(); + } }); it("calls onTabChange when clicking a tab", () => { @@ -51,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( @@ -104,4 +156,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/__tests__/StatsTab.test.tsx b/ui/src/pages/recording-playback/__tests__/StatsTab.test.tsx index 8ffc3be3e..10525fbbd 100644 --- a/ui/src/pages/recording-playback/__tests__/StatsTab.test.tsx +++ b/ui/src/pages/recording-playback/__tests__/StatsTab.test.tsx @@ -16,6 +16,120 @@ afterEach(() => { }); describe("StatsTab", () => { + // 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 }, + ], + 60, + )); + engine.seekTo(60); + + render(() => ( + + + + )); + + expect(screen.queryByText("Server FPS")).toBeNull(); + expect(screen.queryByText("Median")).toBeNull(); + expect(screen.getByText("Force Summary")).toBeTruthy(); + }); + + 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/__tests__/UnitsTab.test.tsx b/ui/src/pages/recording-playback/__tests__/UnitsTab.test.tsx index b894f4aff..6b2788799 100644 --- a/ui/src/pages/recording-playback/__tests__/UnitsTab.test.tsx +++ b/ui/src/pages/recording-playback/__tests__/UnitsTab.test.tsx @@ -39,6 +39,54 @@ describe("UnitsTab", () => { expect(screen.queryByText("IND")).toBeNull(); // CIV tab should NOT exist since there are no CIV units expect(screen.queryByText("CIV")).toBeNull(); + expect(screen.queryByText("VIRTUAL")).toBeNull(); + }); + + it("lists Zeus entities under VIRTUAL", () => { + const { engine, renderer } = createTestEngine(); + engine.loadRecording( + makeManifest( + [unitDef({ id: 7, name: "Possessed", side: "EAST", groupName: "AI", role: "Rifleman" })], + [ + { frameNum: 0, type: "zeusEntity", payload: { curatorId: 90, name: "Danny", playerUid: "7656", bodyUnitId: -1 } }, + { frameNum: 0, type: "zeusCamera", payload: { curatorId: 90, x: 100, y: 200, dir: 0, fov: 0.75 } }, + ], + ), + ); + + render(() => ( + + + + )); + + expect(screen.getByText("VIRTUAL")).toBeTruthy(); + fireEvent.click(screen.getByText("VIRTUAL")); + expect(screen.getByText("Danny")).toBeTruthy(); + }); + + it("shows Zeus occupancy as name controlling unit (type)", () => { + const { engine, renderer } = createTestEngine(); + engine.loadRecording( + makeManifest( + [unitDef({ id: 7, name: "Bakunin", side: "EAST", groupName: "AI", role: "Man" })], + [ + { frameNum: 0, type: "zeusEntity", payload: { curatorId: 90, name: "Danny", playerUid: "7656", bodyUnitId: -1 } }, + { frameNum: 0, type: "zeusCamera", payload: { curatorId: 90, x: 100, y: 200, dir: 0, fov: 0.75 } }, + { frameNum: 0, type: "zeusRemoteControl", payload: { curatorId: 90, unitId: 7, active: true, playerName: "Danny" } }, + ], + ), + ); + engine.seekTo(0); + + render(() => ( + + + + )); + + fireEvent.click(screen.getByText("VIRTUAL")); + expect(screen.getByText("Danny controlling Bakunin (Rifleman)")).toBeTruthy(); }); it("shows unit names in the list", () => { @@ -108,9 +156,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", () => { @@ -436,6 +487,36 @@ describe("UnitsTab", () => { // CIV should NOT be rendered (no CIV units) expect(screen.queryByText("CIV")).toBeNull(); + expect(screen.queryByText("VIRTUAL")).toBeNull(); + }); + + it("keeps VIRTUAL visible when three combat sides are populated", () => { + const { engine, renderer } = createTestEngine(); + engine.loadRecording( + makeManifest( + [ + unitDef({ id: 1, name: "NATO Soldier", side: "WEST", groupName: "Alpha", role: "Trooper" }), + unitDef({ id: 2, name: "CSAT Soldier", side: "EAST", groupName: "Bravo", role: "Trooper" }), + unitDef({ id: 3, name: "Guerrilla", side: "GUER", groupName: "Charlie", role: "Fighter" }), + unitDef({ id: 4, name: "Civilian", side: "CIV", groupName: "Civilians", role: "Civilian" }), + ], + [ + { frameNum: 0, type: "zeusEntity", payload: { curatorId: 90, name: "Danny", playerUid: "7656", bodyUnitId: -1 } }, + ], + ), + ); + + render(() => ( + + + + )); + + expect(screen.getByText("BLUFOR")).toBeTruthy(); + expect(screen.getByText("OPFOR")).toBeTruthy(); + expect(screen.getByText("IND")).toBeTruthy(); + expect(screen.getByText("CIV")).toBeTruthy(); + expect(screen.getByText("VIRTUAL")).toBeTruthy(); }); it("auto-selects first populated side when activeSide is not populated", () => { @@ -545,7 +626,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 +691,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 +751,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 +785,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__/arsenalExport.test.ts b/ui/src/pages/recording-playback/__tests__/arsenalExport.test.ts new file mode 100644 index 000000000..73743cd0b --- /dev/null +++ b/ui/src/pages/recording-playback/__tests__/arsenalExport.test.ts @@ -0,0 +1,207 @@ +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", + items: [ + { class: "FirstAidKit", count: 2 }, + { class: "30Rnd_65x39_caseless_mag", count: 3 }, + ], + }, + vest: { class: "V_PlateCarrier1_rgr", items: [] }, + backpack: { class: "", items: [] }, + headgear: { class: "H_HelmetB" }, + goggles: { class: "G_Tactical_Clear" }, + weapons: [{ + class: "arifle_MX_F", + slot: "primary", + attachments: [ + { class: "muzzle_snds_H" }, + { class: "optic_Hamr" }, + ], + }], + magazines: [{ + class: "30Rnd_65x39_caseless_mag", + count: 4, + totalRounds: 107, + loadedCount: 1, + }], + assignedItems: [{ class: "ItemMap" }], +}; + +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"' }, + }); + 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", slot: "handgun", attachments: [] }], + magazines: [{ class: "16Rnd_9x21_Mag", 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", count: 1 }], + }, + magazines: [{ class: "HandGrenade", 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", slot: "primary", attachments: [] }], + magazines: [ + { 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( + '["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" }, + { class: "ItemRadio" }, + { class: "NVGoggles" }, + { class: "ItemMap" }, + { class: "ItemCompass" }, + { class: "B_UavTerminal" }, + ], + }); + 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", slot: "binocular", attachments: [] }, + ], + assignedItems: [ + { class: "ItemMap" }, + { class: "ItemCompass" }, + { class: "ItemWatch" }, + { class: "TFAR_anprc152_1" }, + { class: "ItemAndroid" }, + { class: "Simc_PVS7" }, + { class: "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", 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" }, + { class: "mod_thing_a" }, + { class: "mod_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/__tests__/bookmarks.test.ts b/ui/src/pages/recording-playback/__tests__/bookmarks.test.ts new file mode 100644 index 000000000..5a97fa670 --- /dev/null +++ b/ui/src/pages/recording-playback/__tests__/bookmarks.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeEach } from "vitest"; + +import { + addBookmark, + bookmarks, + loadBookmarks, + removeBookmark, + renameBookmark, +} from "../bookmarks"; + +describe("bookmarks", () => { + beforeEach(() => { + window.localStorage.clear(); + loadBookmarks("mission-a"); + }); + + it("starts empty for a recording with nothing saved", () => { + expect(bookmarks()).toEqual([]); + }); + + it("keeps bookmarks in frame order regardless of insertion order", () => { + addBookmark(500, "later"); + addBookmark(100, "earlier"); + expect(bookmarks().map((b) => b.frame)).toEqual([100, 500]); + }); + + it("persists across a reload of the same recording", () => { + addBookmark(250, "contact"); + loadBookmarks("mission-a"); + expect(bookmarks()).toHaveLength(1); + expect(bookmarks()[0].label).toBe("contact"); + }); + + it("keeps recordings separate", () => { + addBookmark(250, "contact"); + loadBookmarks("mission-b"); + expect(bookmarks()).toEqual([]); + loadBookmarks("mission-a"); + expect(bookmarks()).toHaveLength(1); + }); + + it("clears without touching storage when no recording is open", () => { + addBookmark(250, "contact"); + loadBookmarks(null); + expect(bookmarks()).toEqual([]); + loadBookmarks("mission-a"); + expect(bookmarks()).toHaveLength(1); + }); + + it("removes and renames", () => { + const first = addBookmark(10, "one"); + addBookmark(20, "two"); + + renameBookmark(first.id, "renamed"); + expect(bookmarks().find((b) => b.id === first.id)?.label).toBe("renamed"); + + removeBookmark(first.id); + expect(bookmarks().map((b) => b.label)).toEqual(["two"]); + }); + + it("survives junk in storage rather than breaking playback", () => { + window.localStorage.setItem("ocap.bookmarks.mission-c", "{not json"); + loadBookmarks("mission-c"); + expect(bookmarks()).toEqual([]); + + window.localStorage.setItem( + "ocap.bookmarks.mission-d", + JSON.stringify([{ id: "a", frame: 5, label: "ok" }, { nonsense: true }, 42]), + ); + loadBookmarks("mission-d"); + expect(bookmarks()).toHaveLength(1); + }); +}); 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..619ee1d5c --- /dev/null +++ b/ui/src/pages/recording-playback/__tests__/convertedCombat.integration.test.ts @@ -0,0 +1,227 @@ +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"; +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"; +import { eventInvolvedSides } from "../components/EventsTab"; + +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("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); + 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("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"); + 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); + 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) { + 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("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)).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 () => { + 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__/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__/deepLink.test.ts b/ui/src/pages/recording-playback/__tests__/deepLink.test.ts new file mode 100644 index 000000000..8f09e1a66 --- /dev/null +++ b/ui/src/pages/recording-playback/__tests__/deepLink.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { + DEEP_LINK_WRITE_INTERVAL_MS, + asDeepLinkTab, + buildDeepLinkSearch, + cancelThrottledDeepLink, + readDeepLink, + writeDeepLinkThrottled, +} from "../deepLink"; + +describe("readDeepLink", () => { + it("reads frame, unit, tab and side", () => { + expect(readDeepLink("?t=1200&u=7&tab=comms&side=EAST")).toEqual({ + frame: 1200, + unit: 7, + tab: "comms", + side: "EAST", + }); + }); + + it("ignores parameters that are absent", () => { + expect(readDeepLink("?tab=units")).toEqual({ tab: "units" }); + }); + + it("rejects a frame that is not a usable number", () => { + expect(readDeepLink("?t=abc")).toEqual({}); + expect(readDeepLink("?t=-5")).toEqual({}); + }); + + it("floors a fractional frame rather than seeking between frames", () => { + expect(readDeepLink("?t=12.7").frame).toBe(12); + }); + + it("accepts frame zero, which is the start of the recording", () => { + expect(readDeepLink("?t=0").frame).toBe(0); + }); + + // ── Untrusted input ── + // + // A link is typed, truncated by chat clients and edited by hand. Anything that + // is not a value the app actually has has to be dropped here, because the + // consumers cast rather than check: an unknown tab id matches no panel and + // renders an empty sidebar, and a bogus side reaches a colour lookup typed as + // if it were a real one. + + it("drops a tab that is not a real panel", () => { + expect(readDeepLink("?tab=nonsense")).toEqual({}); + expect(readDeepLink("?tab=")).toEqual({}); + }); + + it("keeps every tab the side panel actually has", () => { + for (const tab of ["units", "events", "orbat", "comms", "stats", "fps"]) { + expect(readDeepLink(`?tab=${tab}`).tab).toBe(tab); + } + }); + + it("drops a side that is not a real side, including a lowercase one", () => { + expect(readDeepLink("?side=nonsense")).toEqual({}); + expect(readDeepLink("?side=west")).toEqual({}); + expect(readDeepLink("?side=")).toEqual({}); + }); + + it("keeps every side the filter actually has", () => { + for (const side of ["WEST", "EAST", "GUER", "CIV", "VIRTUAL"]) { + expect(readDeepLink(`?side=${side}`).side).toBe(side); + } + }); + + it("keeps the readable half of a link whose other half is junk", () => { + // Losing the whole link because one parameter was mangled would be worse + // than landing on the right frame with the default panel open. + expect(readDeepLink("?t=900&tab=nonsense&side=WEST")).toEqual({ + frame: 900, + side: "WEST", + }); + }); + + it("does not read a blank parameter as frame zero", () => { + // Number("") is 0, so a truncated "?t=" would otherwise yank the viewer + // back to the start of the recording. + expect(readDeepLink("?t=&u=").frame).toBeUndefined(); + expect(readDeepLink("?t=&u=").unit).toBeUndefined(); + }); + + it("ignores parameters it does not own", () => { + expect(readDeepLink("?renderer=dom&t=5")).toEqual({ frame: 5 }); + }); +}); + +describe("asDeepLinkTab", () => { + it("passes a real tab through and rejects anything else", () => { + expect(asDeepLinkTab("stats")).toBe("stats"); + expect(asDeepLinkTab("nonsense")).toBeUndefined(); + expect(asDeepLinkTab(null)).toBeUndefined(); + expect(asDeepLinkTab(undefined)).toBeUndefined(); + }); +}); + +describe("buildDeepLinkSearch", () => { + it("round-trips through readDeepLink", () => { + const state = { frame: 940, unit: 3, tab: "orbat", side: "WEST" } as const; + expect(readDeepLink(buildDeepLinkSearch(state, ""))).toEqual(state); + }); + + it("keeps parameters the playback page does not own", () => { + // `renderer` selects the map renderer and has to survive a shared link. + const search = buildDeepLinkSearch({ frame: 10 }, "?renderer=canvas"); + expect(search).toContain("renderer=canvas"); + expect(search).toContain("t=10"); + }); + + it("drops a parameter whose value is gone, so an unfollowed unit clears", () => { + const search = buildDeepLinkSearch({ frame: 10 }, "?t=5&u=7"); + expect(search).not.toContain("u="); + expect(search).toContain("t=10"); + }); + + it("returns an empty string rather than a bare question mark", () => { + expect(buildDeepLinkSearch({}, "")).toBe(""); + }); + + it("scrubs a junk value out of a key it owns, so the URL self-heals", () => { + // readDeepLink rejected the bogus tab, so the app's state carries none, and + // the next write clears it from the address rather than leaving a link that + // keeps handing the same junk to whoever it is pasted to. + const search = buildDeepLinkSearch({ frame: 1 }, "?tab=nonsense"); + expect(search).not.toContain("tab="); + expect(search).toContain("t=1"); + }); + + it("leaves keys it does not own untouched, junk or not", () => { + const search = buildDeepLinkSearch({ frame: 1 }, "?renderer=nonsense"); + expect(search).toContain("renderer=nonsense"); + }); +}); + +// ── Write throttling ── +// +// replaceState is rate limited by the browser, not merely slow: Safari throws a +// SecurityError past roughly 100 calls in 30 seconds. The playhead is a signal +// driven by requestAnimationFrame and a scrub drag moves it per pointer event, +// so the write has to coalesce or the page breaks rather than just costing time. +describe("writeDeepLinkThrottled", () => { + let replaceState: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + cancelThrottledDeepLink(); + replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {}); + }); + + afterEach(() => { + cancelThrottledDeepLink(); + replaceState.mockRestore(); + vi.useRealTimers(); + }); + + it("writes the first call straight through", () => { + writeDeepLinkThrottled({ frame: 1 }); + expect(replaceState).toHaveBeenCalledTimes(1); + }); + + it("collapses a burst of frames into far fewer writes than calls", () => { + // Roughly what a scrub drag looks like: one call per pointer event. + for (let frame = 0; frame < 300; frame++) writeDeepLinkThrottled({ frame }); + vi.advanceTimersByTime(DEEP_LINK_WRITE_INTERVAL_MS); + + expect(replaceState.mock.calls.length).toBeLessThanOrEqual(2); + }); + + it("stays under the browser's rate limit across a long scrub", () => { + // Thirty seconds of a 60fps playhead is 1800 calls. Safari's ceiling is + // about 100 replaceState calls in that window. + for (let tick = 0; tick < 1800; tick++) { + writeDeepLinkThrottled({ frame: tick }); + vi.advanceTimersByTime(1000 / 60); + } + + expect(replaceState.mock.calls.length).toBeLessThan(100); + }); + + it("eventually writes the newest state, not a stale one", () => { + writeDeepLinkThrottled({ frame: 1 }); + writeDeepLinkThrottled({ frame: 2 }); + writeDeepLinkThrottled({ frame: 3 }); + vi.advanceTimersByTime(DEEP_LINK_WRITE_INTERVAL_MS); + + const lastUrl = String(replaceState.mock.calls.at(-1)?.[2]); + expect(lastUrl).toContain("t=3"); + }); + + it("lands the final position of a drag rather than dropping it", () => { + // Trailing, not leading-only: the frame the viewer stopped on is the one + // worth sharing, and it arrives inside the quiet window. + writeDeepLinkThrottled({ frame: 10 }); + writeDeepLinkThrottled({ frame: 999 }); + expect(String(replaceState.mock.calls.at(-1)?.[2])).not.toContain("t=999"); + + vi.advanceTimersByTime(DEEP_LINK_WRITE_INTERVAL_MS); + expect(String(replaceState.mock.calls.at(-1)?.[2])).toContain("t=999"); + }); + + it("never writes a queued state after teardown", () => { + // A timer firing after the viewer navigated away would rewrite the URL of + // whatever page they landed on. + writeDeepLinkThrottled({ frame: 1 }); + replaceState.mockClear(); + + writeDeepLinkThrottled({ frame: 2 }); + cancelThrottledDeepLink(); + vi.advanceTimersByTime(DEEP_LINK_WRITE_INTERVAL_MS * 10); + + expect(replaceState).not.toHaveBeenCalled(); + }); + + it("writes immediately again once the quiet window has passed", () => { + writeDeepLinkThrottled({ frame: 1 }); + vi.advanceTimersByTime(DEEP_LINK_WRITE_INTERVAL_MS); + replaceState.mockClear(); + + writeDeepLinkThrottled({ frame: 2 }); + expect(replaceState).toHaveBeenCalledTimes(1); + }); + + it("uses replaceState, so scrubbing never fills the back button", () => { + const pushState = vi.spyOn(window.history, "pushState").mockImplementation(() => {}); + writeDeepLinkThrottled({ frame: 1 }); + expect(pushState).not.toHaveBeenCalled(); + pushState.mockRestore(); + }); +}); 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/__tests__/gearCategories.test.ts b/ui/src/pages/recording-playback/__tests__/gearCategories.test.ts new file mode 100644 index 000000000..42f055e30 --- /dev/null +++ b/ui/src/pages/recording-playback/__tests__/gearCategories.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import type { GearItem } from "../../../data/types"; +import { + categorizeGearItems, + classifyGearItem, + carriedMagazineRounds, + filledGearCategories, + isGrenadeItem, + isMedicalItem, + shouldLabelGearCategory, + sortGearItems, + weaponsInSlotOrder, +} from "../gearCategories"; + +describe("gearCategories", () => { + 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("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[] = [ + { 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); + + expect(filledGearCategories(groups)).toEqual(["magazines", "grenades", "medical", "items"]); + expect(groups.magazines.map((item) => item.class)).toEqual([ + "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 the label shown, recorded name included", () => { + expect( + sortGearItems([ + { 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"]); + }); + + 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", 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", 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__/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/__tests__/orbat.test.ts b/ui/src/pages/recording-playback/__tests__/orbat.test.ts new file mode 100644 index 000000000..fc4c6add4 --- /dev/null +++ b/ui/src/pages/recording-playback/__tests__/orbat.test.ts @@ -0,0 +1,463 @@ +import { describe, it, expect } from "vitest"; + +import { buildOrbat, buildVehicleOccupancy, isLeader } from "../orbat"; +import { buildMovementStats, formatDistance } from "../movement"; +import { Unit } from "../../../playback/entities/unit"; +import { Vehicle } from "../../../playback/entities/vehicle"; +import type { PlaybackEngine } from "../../../playback/engine"; +import type { EntityState, Side } from "../../../data/types"; + +function state(over: Partial = {}): EntityState { + return { + position: [0, 0, 0], + direction: 0, + alive: 1, + ...over, + } as EntityState; +} + +/** + * 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])); + const manager = { + getAll: () => entities, + getEntity: (id: number) => byId.get(id) ?? null, + }; + + return { + 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; + if (frame < entity.startFrame || frame > entity.endFrame) return null; + return entity.positions?.[frame - entity.startFrame] ?? null; + }, + } as unknown as PlaybackEngine; +} + +function unit( + id: number, + name: string, + side: Side, + groupName: string, + states: EntityState[], + opts: { role?: string; isPlayer?: boolean } = {}, +): Unit { + return new Unit( + id, name, "man", 0, states.length - 1, side, + opts.isPlayer ?? true, groupName, opts.role ?? "", states, + ); +} + +describe("isLeader", () => { + it("recognises the roles the recorder writes for a leader", () => { + expect(isLeader("Leader")).toBe(true); + expect(isLeader("Squad Leader")).toBe(true); + expect(isLeader("team_leader")).toBe(true); + expect(isLeader("Rifleman")).toBe(false); + expect(isLeader("")).toBe(false); + }); +}); + +describe("buildOrbat", () => { + it("groups by the group at that frame, not the one the unit started in", () => { + // A survivor folded into Bravo halfway through must appear under Bravo. + const survivor = unit(1, "Survivor", "WEST", "Alpha", [ + state({ groupName: "Alpha" }), + state({ groupName: "Bravo" }), + ]); + const bravo = unit(2, "Local", "WEST", "Bravo", [ + state({ groupName: "Bravo" }), + state({ groupName: "Bravo" }), + ]); + const engine = fakeEngine([survivor, bravo], 1); + + const atStart = buildOrbat(engine, "WEST", 0); + expect(atStart.map((g) => g.name)).toEqual(["Alpha", "Bravo"]); + + const later = buildOrbat(engine, "WEST", 1); + expect(later.map((g) => g.name)).toEqual(["Bravo"]); + expect(later[0].members.map((m) => m.unitId).sort()).toEqual([1, 2]); + }); + + it("flags a unit that changed group", () => { + const survivor = unit(1, "Survivor", "WEST", "Alpha", [ + state({ groupName: "Alpha" }), + state({ groupName: "Bravo" }), + ]); + const engine = fakeEngine([survivor], 1); + + expect(buildOrbat(engine, "WEST", 0)[0].members[0].regrouped).toBe(false); + expect(buildOrbat(engine, "WEST", 1)[0].members[0].regrouped).toBe(true); + }); + + it("sorts leaders first, then players, then by name", () => { + const states = [state({ groupName: "Alpha" })]; + const engine = fakeEngine( + [ + unit(1, "Mike", "WEST", "Alpha", states, { role: "Rifleman" }), + unit(2, "Alpha AI", "WEST", "Alpha", states, { role: "Rifleman", isPlayer: false }), + unit(3, "Zulu", "WEST", "Alpha", states, { role: "Squad Leader" }), + ], + 0, + ); + + // 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(["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 ── + // + // An ORBAT is read to find out who was where and under whom. On a populated + // mission the AI outnumbers the players enough to bury that, so the roster is + // the players by default. + + it("lists only players by default", () => { + const states = [state({ groupName: "Alpha" })]; + const engine = fakeEngine( + [ + unit(1, "Player", "WEST", "Alpha", states), + unit(2, "Alpha AI", "WEST", "Alpha", states, { isPlayer: false }), + ], + 0, + ); + + expect(buildOrbat(engine, "WEST", 0)[0].members.map((m) => m.name)).toEqual(["Player"]); + }); + + it("drops a group that is all AI rather than showing it empty", () => { + const states = [state({ groupName: "Alpha" })]; + const engine = fakeEngine( + [ + unit(1, "Player", "WEST", "Alpha", states), + unit(2, "Filler One", "WEST", "Bravo", states, { isPlayer: false }), + unit(3, "Filler Two", "WEST", "Bravo", states, { isPlayer: false }), + ], + 0, + ); + + expect(buildOrbat(engine, "WEST", 0).map((g) => g.name)).toEqual(["Alpha"]); + }); + + it("counts only players as alive, so the roster and the count agree", () => { + const states = [state({ groupName: "Alpha" })]; + const dead = [state({ groupName: "Alpha", alive: 0 })]; + const engine = fakeEngine( + [ + unit(1, "Player", "WEST", "Alpha", states), + unit(2, "Dead AI", "WEST", "Alpha", dead, { isPlayer: false }), + unit(3, "Live AI", "WEST", "Alpha", states, { isPlayer: false }), + ], + 0, + ); + + const group = buildOrbat(engine, "WEST", 0)[0]; + expect(group.members).toHaveLength(1); + 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); + // 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( + [ + 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( + [ + unit(1, "Player", "WEST", "Alpha", states), + unit(2, "Alpha AI", "WEST", "Alpha", states, { isPlayer: false }), + ], + 0, + ); + + expect(buildOrbat(engine, "WEST", 0, { playersOnly: false })[0].members).toHaveLength(2); + }); + + it("keeps someone who is a player at this frame but not at the start", () => { + // Taking over an AI slot mid-mission makes them a player from that point, + // and the per-frame flag is what has to decide it. + const takenOver = unit(1, "Slot", "WEST", "Alpha", [ + state({ groupName: "Alpha", isPlayer: false }), + state({ groupName: "Alpha", isPlayer: true }), + ], { isPlayer: false }); + const engine = fakeEngine([takenOver], 1); + + expect(buildOrbat(engine, "WEST", 0)).toEqual([]); + expect(buildOrbat(engine, "WEST", 1)[0].members[0].name).toBe("Slot"); + }); + + it("counts the living separately from the roster", () => { + const states = [state({ groupName: "Alpha" })]; + const dead = [state({ groupName: "Alpha", alive: 0 })]; + const engine = fakeEngine( + [unit(1, "Alive", "WEST", "Alpha", states), unit(2, "Dead", "WEST", "Alpha", dead)], + 0, + ); + + const group = buildOrbat(engine, "WEST", 0)[0]; + expect(group.members).toHaveLength(2); + expect(group.alive).toBe(1); + }); + + it("uses the per-frame side, so a side switch moves the unit", () => { + const defector = unit(1, "Defector", "WEST", "Alpha", [ + state({ groupName: "Alpha", side: "WEST" }), + state({ groupName: "Alpha", side: "EAST" }), + ]); + const engine = fakeEngine([defector], 1); + + expect(buildOrbat(engine, "WEST", 1)).toEqual([]); + expect(buildOrbat(engine, "EAST", 1)[0].members[0].name).toBe("Defector"); + }); +}); + +describe("buildVehicleOccupancy", () => { + 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(await buildVehicleOccupancy(fakeEngine([parked], 3, 1000), 0, 4)).toEqual([]); + }); + + 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] = 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", async () => { + 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 = (await 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", async () => { + const truck = new Vehicle(9, "Truck", "truck", 0, 3, "truck", [ + state({ crewIds: [] }), + state({ crewIds: [1] }), + state({ crewIds: [1] }), + state({ crewIds: [] }), + ]); + const engine = fakeEngine([truck], 3, 1000); + + 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", 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] = await buildMovementStats(fakeEngine([walker], 2, 1000), { step: 1 }); + + expect(Math.round(stats.onFoot)).toBe(100); + expect(Math.round(stats.mounted)).toBe(100); + expect(Math.round(stats.total)).toBe(200); + expect(stats.mountedFrames).toBe(1); + }); + + 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] = 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", 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", [ + state({ position: [0, 0, 0] }), + null as unknown as EntityState, + state({ position: [300, 0, 0] }), + state({ position: [310, 0, 0] }), + ]); + const [stats] = await buildMovementStats(fakeEngine([gapped], 3, 1000), { step: 1 }); + + expect(Math.round(stats.total)).toBe(10); + }); + + 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(await buildMovementStats(fakeEngine([still], 1, 1000), { step: 1 })).toEqual([]); + }); + + 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] }), + ]); + const far = unit(2, "Far", "WEST", "Alpha", [ + state({ position: [0, 0, 0] }), + state({ position: [300, 0, 0] }), + ]); + 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", 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(await buildMovementStats(engine, { step: 1 })).toEqual([]); + expect(await buildMovementStats(engine, { step: 1, playersOnly: false })).toHaveLength(1); + }); +}); + +describe("formatDistance", () => { + it("switches to kilometres past a thousand metres", () => { + expect(formatDistance(840)).toBe("840 m"); + expect(formatDistance(1240)).toBe("1.2 km"); + expect(formatDistance(Number.NaN)).toBe("-"); + }); +}); 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..ecc49d20b --- /dev/null +++ b/ui/src/pages/recording-playback/__tests__/profileDiff.test.ts @@ -0,0 +1,191 @@ +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", items: [] }, + vest: { class: "v", items: [] }, + backpack: { class: "b", items: [] }, + headgear: { class: "" }, + goggles: { class: "" }, + ...overrides, + }; +} + +describe("diffGear", () => { + it("summarizes ammo spent as a net magazine change with round counts", () => { + const from = inventory({ + magazines: [{ class: "30Rnd", cat: "magazines", count: 5, totalRounds: 120 }], + vest: { + class: "v", + items: [{ class: "30Rnd", count: 4 }], + }, + }); + const to = inventory({ + magazines: [{ class: "30Rnd", cat: "magazines", count: 3, totalRounds: 47 }], + vest: { + class: "v", + items: [{ class: "30Rnd", 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", + items: [{ class: "ACE_MapTools", count: 1 }], + }, + }); + const to = inventory({ + backpack: { + class: "b", + items: [{ class: "ACE_MapTools", 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", slot: "primary", attachments: [] }], + }), + inventory({ + weapons: [{ class: "arifle_AK12", slot: "primary", attachments: [] }], + }), + ); + + expect(diff.weapons).toEqual([ + expect.objectContaining({ + slot: "primary", + fromName: "Arifle MX", + toName: "Arifle AK12", + polarity: "changed", + }), + ]); + }); + + it("is empty when the reconstructed kit did not change", () => { + const kit = inventory({ + magazines: [{ class: "30Rnd", cat: "magazines", count: 2, totalRounds: 60 }], + vest: { class: "v", items: [{ class: "30Rnd", 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", + 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/__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/__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/__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/arsenalExport.ts b/ui/src/pages/recording-playback/arsenalExport.ts new file mode 100644 index 000000000..3587be36a --- /dev/null +++ b/ui/src/pages/recording-playback/arsenalExport.ts @@ -0,0 +1,403 @@ +import type { GearContainer, GearItem, InventorySnapshot } from "../../data/types"; +import { gearItemName } from "../../data/gearDisplayName"; + +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.replace(/"/g, '""')}"`; + +/** + * 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} ${gearItemName(attachment)}`.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} ${gearItemName(magazine)}`.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, 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/], + ["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} ${gearItemName(item)}`.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/bookmarks.ts b/ui/src/pages/recording-playback/bookmarks.ts new file mode 100644 index 000000000..713033843 --- /dev/null +++ b/ui/src/pages/recording-playback/bookmarks.ts @@ -0,0 +1,98 @@ +import { createSignal } from "solid-js"; + +/** + * Playback bookmarks. + * + * A bookmark is a frame plus a label, kept per recording in localStorage. They + * never leave the browser: a recording is served as one immutable file and there + * is no API to write anything back, so this is deliberately a local convenience + * rather than shared state. Sharing a moment is what the deep link is for. + */ + +export interface Bookmark { + id: string; + frame: number; + label: string; + createdAt: number; +} + +const STORAGE_PREFIX = "ocap.bookmarks."; + +/** Bookmarks for the recording currently open, frame-ordered. */ +export const [bookmarks, setBookmarks] = createSignal([]); + +let currentKey: string | null = null; + +function storageKey(recording: string): string { + return STORAGE_PREFIX + recording; +} + +function persist(list: Bookmark[]): void { + if (!currentKey) return; + try { + window.localStorage.setItem(currentKey, JSON.stringify(list)); + } catch { + // Private windows and blocked site data both throw here. Losing bookmarks + // is not worth breaking playback over. + } +} + +/** + * Point the store at a recording, loading whatever was saved for it. Passing + * null (no recording open) empties the list without touching storage. + */ +export function loadBookmarks(recording: string | null): void { + if (!recording) { + currentKey = null; + setBookmarks([]); + return; + } + + currentKey = storageKey(recording); + let stored: Bookmark[] = []; + try { + const raw = window.localStorage.getItem(currentKey); + if (raw) { + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) { + stored = parsed.filter( + (b): b is Bookmark => + !!b && + typeof b === "object" && + typeof (b as Bookmark).id === "string" && + typeof (b as Bookmark).frame === "number" && + typeof (b as Bookmark).label === "string", + ); + } + } + } catch { + stored = []; + } + + setBookmarks(stored.sort((a, b) => a.frame - b.frame)); +} + +export function addBookmark(frame: number, label: string): Bookmark { + const bookmark: Bookmark = { + id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, + frame, + label: label.trim(), + createdAt: Date.now(), + }; + const next = [...bookmarks(), bookmark].sort((a, b) => a.frame - b.frame); + setBookmarks(next); + persist(next); + return bookmark; +} + +export function removeBookmark(id: string): void { + const next = bookmarks().filter((b) => b.id !== id); + setBookmarks(next); + persist(next); +} + +export function renameBookmark(id: string, label: string): void { + const next = bookmarks().map((b) => (b.id === id ? { ...b, label: label.trim() } : b)); + setBookmarks(next); + persist(next); +} diff --git a/ui/src/pages/recording-playback/components/BookmarkMenu.module.css b/ui/src/pages/recording-playback/components/BookmarkMenu.module.css new file mode 100644 index 000000000..719876fa8 --- /dev/null +++ b/ui/src/pages/recording-playback/components/BookmarkMenu.module.css @@ -0,0 +1,171 @@ +.wrapper { + position: relative; + display: flex; + align-items: center; +} + +.button { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.03); + color: var(--text-dimmer); + font-family: var(--font-mono); + font-size: var(--font-size-xs); + cursor: pointer; +} + +.button:hover { + background: rgba(255, 255, 255, 0.08); + color: var(--text-primary); +} + +.buttonActive { + color: var(--text-primary); + border-color: rgba(255, 255, 255, 0.2); +} + +.count { + font-variant-numeric: tabular-nums; +} + +.panel { + position: absolute; + bottom: calc(100% + 8px); + right: 0; + z-index: 1200; + width: 280px; + padding: 8px; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: var(--bg-elevated, #1b1f24); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); +} + +.addRow { + display: flex; + gap: 6px; +} + +.input { + flex: 1; + min-width: 0; + padding: 5px 8px; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(0, 0, 0, 0.25); + color: var(--text-primary); + font-family: inherit; + font-size: var(--font-size-sm); +} + +.input:focus { + outline: none; + border-color: rgba(255, 255, 255, 0.3); +} + +.addBtn { + padding: 5px 10px; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.06); + color: var(--text-primary); + font-family: inherit; + font-size: var(--font-size-sm); + cursor: pointer; + white-space: nowrap; +} + +.addBtn:hover { + background: rgba(255, 255, 255, 0.12); +} + +.linkRow { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + margin-top: 6px; + padding: 6px 8px; + border-radius: 4px; + border: none; + background: none; + color: var(--text-secondary); + font-family: inherit; + font-size: var(--font-size-sm); + cursor: pointer; + text-align: left; +} + +.linkRow:hover { + background: rgba(255, 255, 255, 0.06); + color: var(--text-primary); +} + +.empty { + padding: 10px 8px 4px; + font-size: var(--font-size-xs); + color: var(--text-dimmer); +} + +.list { + margin-top: 6px; + max-height: 220px; + overflow-y: auto; + border-top: 1px solid rgba(255, 255, 255, 0.08); + padding-top: 4px; +} + +.item { + display: flex; + align-items: center; +} + +.itemJump { + display: flex; + align-items: baseline; + gap: 8px; + flex: 1; + min-width: 0; + padding: 5px 8px; + border: none; + background: none; + color: inherit; + font-family: inherit; + font-size: var(--font-size-sm); + cursor: pointer; + text-align: left; +} + +.itemJump:hover { + background: rgba(255, 255, 255, 0.06); +} + +.itemTime { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + color: var(--accent-primary); + white-space: nowrap; +} + +.itemLabel { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-secondary); +} + +.itemDelete { + padding: 5px 6px; + border: none; + background: none; + color: var(--text-dimmer); + cursor: pointer; +} + +.itemDelete:hover { + color: var(--accent-danger); +} diff --git a/ui/src/pages/recording-playback/components/BookmarkMenu.tsx b/ui/src/pages/recording-playback/components/BookmarkMenu.tsx new file mode 100644 index 000000000..28eeeda32 --- /dev/null +++ b/ui/src/pages/recording-playback/components/BookmarkMenu.tsx @@ -0,0 +1,129 @@ +import { createSignal, For, Show } from "solid-js"; +import type { JSX } from "solid-js"; + +import { useEngine } from "../../../hooks/useEngine"; +import { useI18n } from "../../../hooks/useLocale"; +import { useClickOutside } from "../../../hooks/useClickOutside"; +import { BookmarkIcon, LinkIcon, TrashIcon } from "../../../components/Icons"; +import { formatTime, type TimeMode } from "../../../playback/time"; +import { addBookmark, bookmarks, removeBookmark } from "../bookmarks"; +import { asDeepLinkTab, deepLinkUrl } from "../deepLink"; +import { activePanelTab, activeSide } from "../shortcuts"; +import styles from "./BookmarkMenu.module.css"; + +export interface BookmarkMenuProps { + timeMode: () => TimeMode; +} + +export function BookmarkMenu(props: BookmarkMenuProps): JSX.Element { + const engine = useEngine(); + const { t } = useI18n(); + const [open, setOpen] = createSignal(false); + const [draft, setDraft] = createSignal(""); + const [copied, setCopied] = createSignal(false); + let panelRef: HTMLDivElement | undefined; + useClickOutside(() => panelRef, setOpen); + + const label = (frame: number) => formatTime(frame, props.timeMode(), engine.timeConfig); + + const save = () => { + const text = draft().trim(); + addBookmark(engine.currentFrame(), text || label(engine.currentFrame())); + setDraft(""); + }; + + /** + * The link describes what is on screen now: frame, followed unit, open tab and + * side filter. Clipboard writes need a user gesture and can still be refused, + * so failure is swallowed rather than surfaced as an error the viewer cannot + * act on. + */ + const copyLink = () => { + const followed = engine.followTarget(); + const url = deepLinkUrl({ + frame: engine.currentFrame(), + unit: followed === null ? undefined : followed, + tab: asDeepLinkTab(activePanelTab()), + side: activeSide(), + }); + void navigator.clipboard?.writeText(url).then( + () => { + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + }, + () => {}, + ); + }; + + return ( +
+ + + +
+
+ setDraft(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") save(); + }} + /> + +
+ + + + 0} + fallback={
{t("bookmarks_empty")}
} + > +
+ + {(bookmark) => ( +
+ + +
+ )} +
+
+
+
+
+
+ ); +} diff --git a/ui/src/pages/recording-playback/components/BottomBar.module.css b/ui/src/pages/recording-playback/components/BottomBar.module.css index c0d41b7d5..07cfea424 100644 --- a/ui/src/pages/recording-playback/components/BottomBar.module.css +++ b/ui/src/pages/recording-playback/components/BottomBar.module.css @@ -205,6 +205,14 @@ background: var(--accent-warning); } +.heatmapExplosion { + background: #ff8a3d; +} + +.heatmapComms { + background: var(--accent-purple); +} + .heatmapOther { background: #445566; } @@ -576,3 +584,37 @@ border-color: color-mix(in srgb, var(--accent-focus) 15%, transparent); color: var(--accent-focus); } + +/* Viewer-placed bookmarks on the scrub track. Drawn above the kill ticks and + deliberately a different shape, so a mark you made is never mistaken for a + death the recording captured. */ +.bookmarkMarker { + position: absolute; + bottom: 0; + width: 2px; + height: 14px; + margin-left: -1px; + background: var(--accent-primary); + border-radius: 1px; + cursor: pointer; + z-index: 6; +} + +.bookmarkMarker::before { + content: ""; + position: absolute; + top: -4px; + left: -2px; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent-primary); +} + +.bookmarkMarker:hover { + background: var(--text-primary); +} + +.bookmarkMarker:hover::before { + background: var(--text-primary); +} diff --git a/ui/src/pages/recording-playback/components/BottomBar.tsx b/ui/src/pages/recording-playback/components/BottomBar.tsx index abb218df0..84dda84de 100644 --- a/ui/src/pages/recording-playback/components/BottomBar.tsx +++ b/ui/src/pages/recording-playback/components/BottomBar.tsx @@ -23,6 +23,7 @@ import { import { TimelineScrubber } from "./TimelineScrubber"; import type { FocusRange } from "./FocusToolbar"; import { FocusToolbar } from "./FocusToolbar"; +import { BookmarkMenu } from "./BookmarkMenu"; import styles from "./BottomBar.module.css"; export interface BottomBarProps { @@ -179,6 +180,8 @@ export function BottomBar(props: BottomBarProps): JSX.Element { + + + ); + }} + + + + +
{t("comms_nets")}
+ 0} + fallback={ +
+ {query() ? t("comms_no_matches") : t("comms_no_nets")} +
+ } + > + + {(net) => { + const open = () => isNetExpanded(net); + const live = () => netReachability(net); + return ( +
+ + + +
+ + {(member) => { + const status = () => live()?.byUnit.get(member.unitId); + const isSpeaker = () => live()?.speakerId === member.unitId; + return ( + + ); + }} + +
+
+
+ ); + }} +
+
+ +
+ {demGrid() ? t("comms_note_terrain") : t("comms_note_line_of_sight")} +
+ + + + ); +} diff --git a/ui/src/pages/recording-playback/components/EventFilters.tsx b/ui/src/pages/recording-playback/components/EventFilters.tsx index a7c9b758b..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; @@ -14,7 +34,14 @@ export interface EventFilterState { showCaptures: boolean; showTerminalHacks: boolean; showMissionEvents: boolean; + showZeusPings: boolean; + showZeusModules: boolean; + showExplosions: boolean; + showServicing: boolean; + showConstructions: boolean; + showTransmissions: boolean; sideFilter: SideFilter; + factions: FactionFilter; } export const DEFAULT_EVENT_FILTERS: EventFilterState = { @@ -24,10 +51,21 @@ export const DEFAULT_EVENT_FILTERS: EventFilterState = { showCaptures: true, 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, + 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", + factions: { ...DEFAULT_FACTION_FILTER }, }; -type EventTypeKey = Exclude; +type EventTypeKey = Exclude; interface EventTypeItem { key: EventTypeKey; @@ -41,6 +79,12 @@ const EVENT_TYPE_ITEMS: EventTypeItem[] = [ { key: "showCaptures", labelKey: "captures" }, { 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" }, + { key: "showTransmissions", labelKey: "transmissions" }, ]; const SIDE_FILTER_OPTIONS: { key: SideFilter; labelKey: string }[] = [ @@ -57,10 +101,21 @@ function isNonDefault(state: EventFilterState): boolean { state.showCaptures !== DEFAULT_EVENT_FILTERS.showCaptures || state.showTerminalHacks !== DEFAULT_EVENT_FILTERS.showTerminalHacks || state.showMissionEvents !== DEFAULT_EVENT_FILTERS.showMissionEvents || - state.sideFilter !== DEFAULT_EVENT_FILTERS.sideFilter + 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 || + state.showTransmissions !== DEFAULT_EVENT_FILTERS.showTransmissions || + 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; @@ -80,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 9cf6815d1..0f05391fc 100644 --- a/ui/src/pages/recording-playback/components/EventsTab.tsx +++ b/ui/src/pages/recording-playback/components/EventsTab.tsx @@ -8,12 +8,23 @@ 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 { ZeusModuleEvent, 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 } from "../../../components/Icons"; -import { EventFilters, DEFAULT_EVENT_FILTERS } from "./EventFilters"; +import { SkullIcon, BulletIcon, LinkIcon, ClockIcon, DoorExitIcon, ActivityIcon, FlagIcon, AlertTriangleIcon, TerminalIcon, TargetIcon, ExplosionIcon, WrenchIcon, StaticWeaponIcon, RadioIcon, ZapIcon } from "../../../components/Icons"; +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 { @@ -22,6 +33,7 @@ function sideColor(side?: string): string { case "EAST": return SIDE_COLORS_UI.EAST; case "GUER": return SIDE_COLORS_UI.GUER; case "CIV": return SIDE_COLORS_UI.CIV; + case "VIRTUAL": return SIDE_COLORS_UI.VIRTUAL; default: return "#888"; } } @@ -46,22 +58,73 @@ function eventStyle(event: GameEvent): { icon: JSX.Element; color: string } { if (event instanceof TerminalHackEvent) { return { icon: , color: "var(--accent-warning)" }; } + 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)" }; + } + 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" }; } +/** 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.getEventSideAt(event.unitId, event.frameNum); + 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 + // 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; } if (event instanceof ConnectEvent) return f.showConnections; 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; + // 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; } @@ -78,7 +141,15 @@ 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(() => { + engine.eventReferencesVersion(); const all = engine.activeEvents(); const text = filterText().toLowerCase(); const f = filters(); @@ -86,6 +157,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) { @@ -106,6 +178,33 @@ export function EventsTab(): JSX.Element { if (!event.unitName.toLowerCase().includes(text)) return false; } else if (event instanceof TerminalHackEvent) { 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) { + 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; } } @@ -122,6 +221,20 @@ export function EventsTab(): JSX.Element { engine.panToEntity(event.victimId); } else if (event instanceof CapturedEvent && event.position) { 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) { + // 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); } }; @@ -129,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 */} @@ -165,7 +283,7 @@ export function EventsTab(): JSX.Element { {event instanceof HitKilledEvent ? ( <> - + {event.victimName ?? "Unknown"} {event.victimId === event.causedById ? ( @@ -180,7 +298,7 @@ export function EventsTab(): JSX.Element { {"\u2190"} {" "} - + {event.causerName ?? "Unknown"} @@ -259,6 +377,116 @@ export function EventsTab(): JSX.Element { + ) : event instanceof ZeusPingEvent ? ( + <> + + + {event.payload.name} + + {" "}{t("pinged_zeus")} + + + + + {timeStr(event.frameNum)} + + + + ) : 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 ? ( + <> + + {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/FollowIndicator.tsx b/ui/src/pages/recording-playback/components/FollowIndicator.tsx index 613a24f8b..45a7e592c 100644 --- a/ui/src/pages/recording-playback/components/FollowIndicator.tsx +++ b/ui/src/pages/recording-playback/components/FollowIndicator.tsx @@ -15,7 +15,7 @@ export function FollowIndicator(): JSX.Element { if (!entity) return null; const snap = engine.entitySnapshots().get(id); const side = snap?.side ?? null; - return { name: entity.name, side }; + return { name: snap?.name || entity.name, side }; }; return ( 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/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/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..dd24b126a --- /dev/null +++ b/ui/src/pages/recording-playback/components/OrbatTab.tsx @@ -0,0 +1,324 @@ +import { createEffect, createMemo, createSignal, For, onCleanup, 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, + SIDE_BG_COLORS, + SIDES, + SIDE_LABELS, +} from "../../../config/sideColors"; +import { formatElapsedTime } from "../../../playback/time"; +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"; + +export function OrbatTab(): JSX.Element { + const engine = useEngine(); + const { t } = useI18n(); + + 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. */ + 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]); + } + }); + + // Crew samples survive chunk eviction and can be reused as the playhead moves. + createEffect(() => { + 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(() => { + // 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 + * 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 }]; + }); + }); + + /** Only what the players of this faction rode; the AI motor pool is noise here. */ + const allVehicles = createMemo(() => { + return selectVehicleOccupancy(engine, vehicleHistory(), engine.currentFrame(), { + side: activeSide(), + playersOnly: true, + }); + }); + + /** 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[] => { + engine.entitySnapshots(); + const state = engine.getStateAt(vehicleId, engine.currentFrame()); + return state?.crewIds ?? []; + }; + + return ( + <> + {/* Faction tabs: every side in the recording, AI-only ones included */} +
+ + {(side) => { + const isActive = () => activeSide() === side; + return ( + + ); + }} + +
+ + {/* Search: group, player or vehicle name */} +
+ setSearch(e.currentTarget.value)} + /> +
+ +
+
+ {t("orbat_groups")} ·{" "} + {SIDE_LABELS[activeSide()]} +
+ + 0} + fallback={ +
+ {query() + ? t("orbat_no_matches") + : playerCount(activeSide()) === 0 + ? t("orbat_no_players") + : 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_player_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/PlayerProfileCard.module.css b/ui/src/pages/recording-playback/components/PlayerProfileCard.module.css new file mode 100644 index 000000000..e56ed4c98 --- /dev/null +++ b/ui/src/pages/recording-playback/components/PlayerProfileCard.module.css @@ -0,0 +1,605 @@ +.card { + position: fixed; + top: 56px; + 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, 420px) - 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 > div > span { + display: block; +} + +.metrics strong { + color: var(--text-primary); + font-size: 15px; +} + +.metrics > div > span { + margin-top: 2px; + color: var(--text-dimmest); + font-size: 9px; + 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; + color: var(--text-dimmest); + font-size: 11px; + font-style: italic; + text-align: center; +} + +.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; +} + +.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; + 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 + .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; +} + +.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; +} + +.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; + right: 8px; + top: 64px; + 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 new file mode 100644 index 000000000..e50e730cd --- /dev/null +++ b/ui/src/pages/recording-playback/components/PlayerProfileCard.tsx @@ -0,0 +1,1436 @@ +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, + 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 { Unit } from "../../../playback/entities/unit"; +import { PlayerSnapshotEvent } from "../../../playback/events/playerSnapshotEvent"; +import { formatTime } from "../../../playback/time"; +import { formatUnitTypeLabel } from "../../../playback/zeus"; +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 { showHint } from "./Hint"; +import { + DiffEmpty, + formatSigned, + GearDiffView, + MedicalDiffView, + RadioDiffView, + StaminaDiffView, +} from "./PlayerProfileDiff"; +import { + categorizeGearItems, + filledGearCategories, + GEAR_CATEGORY_LABEL_KEYS, + magazinesNotInContainers, + carriedMagazineRounds, + shouldLabelGearCategory, + weaponsInSlotOrder, + type GearCategory, +} from "../gearCategories"; +import { gearItemName } from "../../../data/gearDisplayName"; +import { + diffGear, + diffMedical, + diffRadios, + diffStamina, + gearDiffIsEmpty, + medicalDiffIsEmpty, + radioDiffIsEmpty, + staminaReserve as reserveAt, + stanceLabelKey, +} 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"; + +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" }, + // 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"]; + +/** 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) => ( + + {gearItemName(item)} + 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 ContainerSection(props: { label?: string; items: GearItem[] }): JSX.Element { + return ( +
+ +
{props.label}
+
+ +
+ ); +} + +function CategorizedItemSections(props: { + items?: GearItem[]; + alwaysLabel?: boolean; +}): JSX.Element { + const { t } = useI18n(); + const groups = () => categorizeGearItems(props.items); + const filled = () => filledGearCategories(groups()); + return ( + + {(key: GearCategory) => ( + + )} + + ); +} + +function Container(props: { + label: string; + container?: GearContainer; +}): JSX.Element { + const { t } = useI18n(); + const groups = () => categorizeGearItems(props.container?.items); + return ( +
+
+ {props.label} + {gearItemName(props.container) || t("profile_none")} +
+ {t("profile_empty")}} + > + + +
+ ); +} + +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 [diffing, setDiffing] = createSignal(false); + const [fromFrame, setFromFrame] = createSignal(0); + const [toPinned, setToPinned] = 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); + }; + + // 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); + + const snapshotUnitId = createMemo(() => { + engine.currentFrame(); + if (props.unit.type !== "zeus") return props.unit.id; + return ( + engine.eventManager.getZeusState(props.unit.id, engine.currentFrame()) + ?.controllingUnitId ?? props.unit.id + ); + }); + const controllingHost = createMemo(() => { + const unitId = engine.entitySnapshots().get(props.unit.id)?.controllingUnitId; + if (unitId == null) return null; + const host = engine.entityManager.getEntity(unitId); + if (!(host instanceof Unit)) return null; + return { + name: host.name || String(unitId), + type: formatUnitTypeLabel(host.roleAtFrame(engine.currentFrame())), + }; + }); + + createEffect(() => { + const unitId = snapshotUnitId(); + 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 (snapshotUnitId() === unitId) setSnapshotsLoading(false); + }); + }); + + const trackedKinds = createMemo(() => { + engine.endFrame(); + engine.playerSnapshotsVersion(); + const kinds = new Set(); + for (const entry of TAB_DEFS) { + if (engine.eventManager.getFirstPlayerSnapshotFrame(snapshotUnitId(), entry.type) !== undefined) { + kinds.add(entry.type); + } + } + return kinds; + }); + const snapshots = createMemo(() => { + engine.currentFrame(); + engine.playerSnapshotsVersion(); + return engine.eventManager.getPlayerSnapshots(snapshotUnitId(), engine.currentFrame()); + }); + const toFrame = () => toPinned() ?? engine.currentFrame(); + const snapshotsAt = (frame: number) => engine.eventManager.getPlayerSnapshots(snapshotUnitId(), 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"); + // 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"); + 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 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()) { + 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}`; + + 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(snapshotUnitId()) + ?? 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(snapshotUnitId(), 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 }); + 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) => + 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 ( +
+ } + > + +
+ 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)} + +
+ + )} +
+ +
+ + +
+
+ + + + + + + 0} + fallback={} + > + + + + + {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())} + + + {(key) => ( + + {t("profile_stance")} + {t(key())} + + )} + +
+ + {(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 ( +
+
+ {gearItemName(entry)} + + {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")} + +
+ ); + }} +
+
+
+
+ + + + 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/components/PlayerProfileDiff.tsx b/ui/src/pages/recording-playback/components/PlayerProfileDiff.tsx new file mode 100644 index 000000000..688029013 --- /dev/null +++ b/ui/src/pages/recording-playback/components/PlayerProfileDiff.tsx @@ -0,0 +1,364 @@ +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, + stanceLabelKey, + 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); + } + case "stance": { + const key = stanceLabelKey(Number(value)); + return key ? t(key) : 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/components/SidePanel.module.css b/ui/src/pages/recording-playback/components/SidePanel.module.css index 199c9ce50..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,13 +39,26 @@ border-bottom: var(--border-subtle); padding: 0 4px; flex-shrink: 0; + /* 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; } .tab { display: flex; align-items: center; - gap: 6px; - padding: 8px 14px; + justify-content: center; + gap: 5px; + /* 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; border-bottom: 2px solid transparent; background: transparent; @@ -57,6 +71,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 +84,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 { @@ -90,8 +119,9 @@ .sideTabs { display: flex; - gap: 4px; - padding: 8px 10px; + flex-wrap: wrap; + gap: 3px; + padding: 6px; border-bottom: 1px solid rgba(255, 255, 255, 0.04); flex-shrink: 0; } @@ -99,16 +129,19 @@ .sideTab { display: flex; align-items: center; - gap: 6px; - padding: 6px 12px; + justify-content: center; + gap: 4px; + padding: 5px 5px; + flex: 0 0 auto; + white-space: nowrap; border: none; border-radius: 6px; background: transparent; cursor: pointer; - font-size: var(--font-size-md); + font-size: var(--font-size-sm); font-family: var(--font-mono); font-weight: 600; - letter-spacing: 0.05em; + letter-spacing: 0.03em; transition: all 0.2s; } @@ -580,6 +613,64 @@ text-transform: uppercase; } + +.serverFpsTitle { + color: var(--text-on-accent); +} + + + + +.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; @@ -758,4 +849,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/SidePanel.tsx b/ui/src/pages/recording-playback/components/SidePanel.tsx index ed806ff2c..0a6900619 100644 --- a/ui/src/pages/recording-playback/components/SidePanel.tsx +++ b/ui/src/pages/recording-playback/components/SidePanel.tsx @@ -1,12 +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 } 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; @@ -22,9 +33,19 @@ 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 }, + { 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 (
@@ -34,6 +55,9 @@ export function SidePanel(props: SidePanelProps): JSX.Element { class={styles.tab} classList={{ [styles.tabActive]: props.activeTab() === tab.id }} onClick={() => props.onTabChange(tab.id)} + // 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)} > {t(tab.labelKey)} @@ -54,9 +78,18 @@ 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 60256fb20..6e6d8449b 100644 --- a/ui/src/pages/recording-playback/components/StatsTab.tsx +++ b/ui/src/pages/recording-playback/components/StatsTab.tsx @@ -1,19 +1,29 @@ -import { createMemo, 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"; 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, type MovementStats } 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"]; +const SIDES: Side[] = ["WEST", "EAST", "GUER", "CIV", "VIRTUAL"]; const SIDE_LABELS: Record = { WEST: "BLUFOR", EAST: "OPFOR", GUER: "IND", CIV: "CIV", + VIRTUAL: "VIRTUAL", }; interface SideStats { @@ -40,11 +50,84 @@ export function StatsTab(): JSX.Element { const { t } = useI18n(); const showPlayerKillCount = (): boolean => !customize().disableKillCount; - // Frame-aware kill/death counts - const killDeathCounts = createMemo(() => - engine.eventManager.getKillDeathCounts(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; + }); + + // Accumulate totals while each chunk is held; retain only the resulting rows. + const [movement, setMovement] = createSignal([]); + const [movementReady, setMovementReady] = createSignal(false); + createEffect(() => { + engine.endFrame(); + 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 + const killDeathCounts = createMemo(() => { + engine.eventReferencesVersion(); + return engine.eventManager.getKillDeathCounts(engine.currentFrame()); + }); + const sideStats = createMemo((): SideStats[] => { const snaps = engine.entitySnapshots(); const units = engine.entityManager.getUnits(); @@ -214,6 +297,81 @@ 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")}
+ {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 */}
void; } -const SIDES: Side[] = ["WEST", "EAST", "GUER", "CIV"]; +const SIDES: Side[] = ["WEST", "EAST", "GUER", "CIV", "VIRTUAL"]; const SIDE_LABEL: Record = { WEST: "BLUFOR", EAST: "OPFOR", GUER: "IND", CIV: "CIV", + VIRTUAL: "VIRTUAL", }; /** diff --git a/ui/src/pages/recording-playback/components/UnitsTab.tsx b/ui/src/pages/recording-playback/components/UnitsTab.tsx index cc3909676..d50d7a1b7 100644 --- a/ui/src/pages/recording-playback/components/UnitsTab.tsx +++ b/ui/src/pages/recording-playback/components/UnitsTab.tsx @@ -1,23 +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 { SIDE_COLORS_UI, SIDE_BG_COLORS } from "../../../config/sideColors"; +import { Unit } from "../../../playback/entities/unit"; +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"; 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"; - -const SIDES: Side[] = ["WEST", "EAST", "GUER", "CIV"]; - -const SIDE_LABELS: Record = { - WEST: "BLUFOR", - EAST: "OPFOR", - GUER: "IND", - CIV: "CIV", -}; +import { PlayerProfileCard } from "./PlayerProfileCard"; interface GroupData { name: string; @@ -37,7 +30,15 @@ export function UnitsTab(props: UnitsTabProps): JSX.Element { const { t } = useI18n(); const showKillCount = (): boolean => !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(); + 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 @@ -81,11 +82,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); @@ -98,6 +118,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); @@ -158,11 +182,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 ( <> @@ -190,6 +228,14 @@ export function UnitsTab(props: UnitsTabProps): JSX.Element { {(unit) => { const status = () => getUnitStatus(unit.id); const selected = () => selectedUnit() === unit.id; + const snap = () => engine.entitySnapshots().get(unit.id); + const displayName = () => unitLabel(unit); + const displayRole = () => { + if (unit.type === "zeus" && snap()?.controllingUnitId != null) { + return ""; + } + return unit.role; + }; return ( <> - - - ); }} @@ -260,99 +295,27 @@ 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/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..1acd9b462 --- /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", "fps"] 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/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/pages/recording-playback/gearCategories.ts b/ui/src/pages/recording-playback/gearCategories.ts new file mode 100644 index 000000000..90997ba2e --- /dev/null +++ b/ui/src/pages/recording-playback/gearCategories.ts @@ -0,0 +1,123 @@ +import type { GearItem } from "../../data/types"; +import { gearItemName } from "../../data/gearDisplayName"; + +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[]; +} + +/** + * 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 { + return classifyGearItem(item) === "medical"; +} + +export function isGrenadeItem(item: GearItem): boolean { + return classifyGearItem(item) === "grenades"; +} + +/** + * 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 = gearItemName(a).localeCompare(gearItemName(b), undefined, { + sensitivity: "base", + }); + if (byName !== 0) return byName; + return (a.class || "").localeCompare(b.class || ""); + }); +} + +export function categorizeGearItems(items: GearItem[] | undefined): CategorizedGear { + const groups: CategorizedGear = { magazines: [], grenades: [], medical: [], items: [] }; + for (const item of items ?? []) { + groups[classifyGearItem(item)].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 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 { + let total = 0; + let have = false; + for (const mag of magazines ?? []) { + if (classifyGearItem(mag) !== "magazines") continue; + if (typeof mag.totalRounds !== "number") continue; + total += mag.totalRounds; + have = true; + } + return have ? total : undefined; +} 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..fefaaa7ef --- /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/pages/recording-playback/movement.ts b/ui/src/pages/recording-playback/movement.ts new file mode 100644 index 000000000..f55aee803 --- /dev/null +++ b/ui/src/pages/recording-playback/movement.ts @@ -0,0 +1,114 @@ +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 async function buildMovementStats( + engine: PlaybackEngine, + options: { step?: number; playersOnly?: boolean } = {}, +): Promise { + 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 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; + } + + 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); + } + } + }); + + 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". */ +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..31b342343 --- /dev/null +++ b/ui/src/pages/recording-playback/orbat.ts @@ -0,0 +1,256 @@ +import type { PlaybackEngine } from "../../playback/engine"; +import { Unit } from "../../playback/entities/unit"; +import { Vehicle } from "../../playback/entities/vehicle"; +import type { EntityState, 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)); +} + +/** + * 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. + * + * 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. A side with no players at all comes back 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(); + + /** + * One row per named slot, so a respawn or a reconnect does not list the same + * 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 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; + + const state = engine.getStateAt(entity.id, frame); + if (!state) continue; + if ((state.side ?? entity.side) !== side) continue; + + 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, + // Protobuf chunks omit roles; the manifest retains the recorded role. + role: state.role ?? entity.role, + isPlayer, + alive: !!state.alive, + mounted: state.isInVehicle === true, + regrouped: !!entity.groupName && !!groupName && groupName !== entity.groupName, + }; + + if (!name) { + unnamed.push({ group: groupName, member }); + continue; + } + 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 || 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]) { + const list = groups.get(group); + if (list) list.push(member); + else groups.set(group, [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; +} + +/** + * 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. + * + * 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. + * + * `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 async function buildVehicleOccupancy( + engine: PlaybackEngine, + frame: number, + sampleCount = 160, + options: { side?: Side; playersOnly?: boolean } = {}, +): Promise { + const endFrame = engine.endFrame(); + if (endFrame <= 0) return []; + + const step = Math.max(1, Math.floor(endFrame / sampleCount)); + 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); + } + } + }); + + return selectVehicleOccupancy(engine, result, frame, options); +} + +/** 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/profileDiff.ts b/ui/src/pages/recording-playback/profileDiff.ts new file mode 100644 index 000000000..6805864b2 --- /dev/null +++ b/ui/src/pages/recording-playback/profileDiff.ts @@ -0,0 +1,709 @@ +import type { + GearContainer, + GearItem, + InventorySnapshot, + MedicalBodyPart, + MedicalLogEntry, + MedicalSnapshot, + RadioSnapshot, + RadioSnapshotEntry, + StaminaSnapshot, + TreatmentItem, +} from "../../data/types"; +import { BODY_PART_IDS } from "./medical/bodyImage"; +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"; + +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" | "stance"; + 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 gearItemName(item) || 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); + } 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; +} + +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, + stats: Map, + magClasses: Set, +): number | undefined { + if (!magClasses.has(cls)) return undefined; + const stat = stats.get(cls); + if (stat?.category !== "magazines") return undefined; + return stat.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, + category: classifyGearItem(mag), + }); + } + 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, fromMags, magClasses), + ammoRounds(cls, 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 + ); +} + +/** + * Stance travels in the stamina snapshot as a small integer enum so the recording + * stores a number per sample instead of a string. Index = the recorded code; + * anything outside the table (an older recording has no code at all) reads as unknown. + */ +export const STANCE_LABEL_KEYS = [ + "profile_stance_0", + "profile_stance_1", + "profile_stance_2", + "profile_stance_3", +] as const; + +export function stanceLabelKey(code: unknown): string | undefined { + if (typeof code !== "number") return undefined; + return STANCE_LABEL_KEYS[code] ?? STANCE_LABEL_KEYS[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_stance", "stance", from?.vanilla?.stance, to?.vanilla?.stance), + 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: gearItemName(toEntry), + 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: gearItemName(toEntry), fields }); + } + } + + for (const [key, fromEntry] of fromMap) { + if (toMap.has(key)) continue; + radios.push({ + polarity: "removed", + name: gearItemName(fromEntry), + 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 }; 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; +} diff --git a/ui/src/pages/recording-playback/useRenderBridge.ts b/ui/src/pages/recording-playback/useRenderBridge.ts index 1ed374f22..d8a749a8b 100644 --- a/ui/src/pages/recording-playback/useRenderBridge.ts +++ b/ui/src/pages/recording-playback/useRenderBridge.ts @@ -1,13 +1,61 @@ -import { createEffect } from "solid-js"; -import type { MarkerHandle, LineHandle, CrewInfo } from "../../renderers/renderer.types"; -import { SIDE_COLORS_DARK } from "../../config/sideColors"; +import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"; +import type { Accessor } from "solid-js"; +import type { + MarkerHandle, + LineHandle, + CrewInfo, + BriefingMarkerHandle, +} from "../../renderers/renderer.types"; +import { SIDE_COLORS_BRIGHT, SIDE_COLORS_DARK } from "../../config/sideColors"; import type { PlaybackEngine } from "../../playback/engine"; import type { MarkerManager } from "../../playback/markerManager"; import { Vehicle } from "../../playback/entities/vehicle"; 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, + explosionColor, + explosionLifetimeFrames, +} from "../../playback/explosions"; +import { + binDeathSites, + buildDeathSites, + 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. @@ -27,6 +75,69 @@ function getCrewInfo( return { count: vehicle.crew.length, names }; } +/** The label is interpolated into a divIcon's HTML, and `name` comes from the game. */ +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +/** Marker colour for a ping, as the bare hex the marker image route expects. */ +function pingColor(side: string): string { + const color = SIDE_COLORS_BRIGHT[side as Side] ?? "#ffcc00"; + return color.replace("#", ""); +} + +/** + * The text drawn beside a ping icon: one `[Zeus ping] - Name - Time` line per + * ping, newest first, so repeat pings from the same player are distinguishable + * at a glance. The name is always shown so a ping is attributable on its own. + */ +function pingLabel( + group: ZeusPingGroup, + format: (frame: number) => string, +): string { + const name = escapeHtml(group.name || `Unit ${group.unitId}`); + return group.frames + .map((frame) => `[Zeus ping] - ${name} - ${escapeHtml(format(frame))}`) + .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; + 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. @@ -35,6 +146,7 @@ export function useRenderBridge( engine: PlaybackEngine, renderer: MapRenderer, markerManager: MarkerManager, + timeMode: Accessor = () => "elapsed", ): void { const markerHandles = new Map(); let firelineHandles: LineHandle[] = []; @@ -85,6 +197,8 @@ export function useRenderBridge( name: snap.name, isPlayer, crew, + fov: snap.fov, + pitch: snap.pitch, }); markerHandles.set(id, handle); } @@ -99,6 +213,8 @@ export function useRenderBridge( isInVehicle: snap.isInVehicle, hit: hitEntityIds.has(id), crew, + fov: snap.fov, + pitch: snap.pitch, }); if (snap.firedTargets) { @@ -116,6 +232,531 @@ export function useRenderBridge( } }); + // Zeus pings → transient map overlay. + // A ping is drawn for ZEUS_PING_MAP_SECONDS of playback time and then removed + // from the map; the event itself stays in the event log forever. Recomputing + // the active set from the frame means scrubbing backwards works for free. + const pingRenders = new Map(); + + createEffect(() => { + const frame = engine.currentFrame(); + const mode = timeMode(); + const lifetime = zeusPingLifetimeFrames(engine.captureDelayMs()); + const groups = groupZeusPings( + engine.eventManager.getActivePings(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 = pingLabel(group, format); + const color = pingColor(group.side); + + let render = pingRenders.get(group.key); + if (render && render.labelText !== labelText) { + // The label is baked into the divIcon at creation, so a changed line + // count (a second ping, or a different clock) needs a fresh marker. + renderer.removeBriefingMarker(render.label); + render.label = renderer.createBriefingMarker({ + shape: "ICON", + type: "Empty", + color, + text: labelText, + side: "GLOBAL", + layer: "systemMarkers", + textClass: "zeus-ping-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-ping-label", + }), + labelText, + }; + pingRenders.set(group.key, render); + } + + const alpha = zeusPingAlpha(frame, group.latestFrame, lifetime); + const state = { position: group.position, direction: 0, alpha }; + renderer.updateBriefingMarker(render.icon, state); + renderer.updateBriefingMarker(render.label, state); + } + + for (const [key, render] of pingRenders) { + if (live.has(key)) continue; + renderer.removeBriefingMarker(render.icon); + renderer.removeBriefingMarker(render.label); + pingRenders.delete(key); + } + }); + + // 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 + // 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(() => { + const world = engine.worldConfig; + setCommsDem(null); + if (!world) return; + let active = true; + onCleanup(() => { active = false; }); + void loadWorldDem({ + tileBaseUrl: world.tileBaseUrl, + worldName: world.worldName, + worldSize: world.worldSize, + hasDem: world.hasDem, + hasHeightmap: world.hasHeightmap, + }).then((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 + // 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. The recording version also changes for missions of equal length. + const pairedTransmissions = createMemo(() => { + engine.recordingVersion(); + return pairTransmissions( + engine.eventManager.getRadioTransmissions().map((event) => ({ + frameNum: event.frameNum, + payload: event.payload, + })), + ); + }); + + 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(); + + for (const handle of commsLines) renderer.removeLine(handle); + commsLines = []; + + 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 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"); + 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(); + + // Resolve positions during traversal, then keep only the small site list. + const [deathSites, setDeathSites] = createSignal([]); + let deathSitesLoad: Promise | undefined; + + createEffect(() => { + engine.endFrame(); + deathSitesLoad = undefined; + setDeathSites([]); + }); + + createEffect(() => { + if (!deathHeatmapVisible()) return; + engine.endFrame(); + 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(() => { + 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/pages/recording-selector/DetailSidebar.tsx b/ui/src/pages/recording-selector/DetailSidebar.tsx index a9c5c5361..4bd532b22 100644 --- a/ui/src/pages/recording-selector/DetailSidebar.tsx +++ b/ui/src/pages/recording-selector/DetailSidebar.tsx @@ -93,9 +93,10 @@ export function DetailSidebar(props: { {/* Force Composition — per-side stat cards */} 0}> {(_) => { - const SIDE_ORDER: Record = { EAST: 0, WEST: 1, GUER: 2, CIV: 3 }; + const SIDE_ORDER: Record = { EAST: 0, WEST: 1, GUER: 2, CIV: 3, VIRTUAL: 4 }; const entries = () => Object.entries(props.rec.sideComposition!) + .filter(([, count]) => count.units > 0) .sort(([a], [b]) => (SIDE_ORDER[a] ?? 99) - (SIDE_ORDER[b] ?? 99)); return (
diff --git a/ui/src/pages/recording-selector/RecordingSelector.module.css b/ui/src/pages/recording-selector/RecordingSelector.module.css index 45b0b31f8..66697901d 100644 --- a/ui/src/pages/recording-selector/RecordingSelector.module.css +++ b/ui/src/pages/recording-selector/RecordingSelector.module.css @@ -996,6 +996,7 @@ .sidebarContent { flex: 1; + min-height: 0; padding: 20px; display: flex; flex-direction: column; @@ -1054,11 +1055,11 @@ .sideCardStack { display: flex; flex-direction: column; - gap: 6px; + gap: 4px; } .sideCard { - padding: 10px 12px; + padding: 8px 10px; border-radius: 8px; border: 1px solid; } @@ -1067,7 +1068,7 @@ display: flex; align-items: center; justify-content: space-between; - margin-bottom: 10px; + margin-bottom: 6px; } .sideCardName { diff --git a/ui/src/pages/recording-selector/__tests__/DetailSidebar.test.tsx b/ui/src/pages/recording-selector/__tests__/DetailSidebar.test.tsx index 2b1bcfe62..363bcdd1e 100644 --- a/ui/src/pages/recording-selector/__tests__/DetailSidebar.test.tsx +++ b/ui/src/pages/recording-selector/__tests__/DetailSidebar.test.tsx @@ -208,11 +208,12 @@ describe("DetailSidebar force composition", () => { expect(container.textContent).not.toContain("Force Composition"); }); - it("sorts sides in canonical order (EAST, WEST, GUER, CIV)", () => { + it("sorts sides in canonical order (EAST, WEST, GUER, CIV, VIRTUAL)", () => { // Pass sides in non-standard order — component should sort them const rec: Recording = { ...baseRec, sideComposition: { + VIRTUAL: { units: 2, dead: 0, kills: 0, players: 2 }, CIV: { units: 1, dead: 0, kills: 0, players: 0 }, WEST: { units: 2, dead: 0, kills: 0, players: 1 }, EAST: { units: 3, dead: 0, kills: 0, players: 2 }, @@ -223,8 +224,23 @@ describe("DetailSidebar force composition", () => { const eastIdx = text.indexOf("EAST"); const westIdx = text.indexOf("WEST"); const civIdx = text.indexOf("CIV"); + const virtualIdx = text.indexOf("VIRTUAL"); expect(eastIdx).toBeLessThan(westIdx); expect(westIdx).toBeLessThan(civIdx); + expect(civIdx).toBeLessThan(virtualIdx); + }); + + it("hides sides with zero units so empty VIRTUAL is omitted", () => { + const rec: Recording = { + ...baseRec, + sideComposition: { + EAST: { units: 3, dead: 0, kills: 0, players: 2 }, + VIRTUAL: { units: 0, dead: 0, kills: 0, players: 0 }, + }, + }; + const { container } = renderSidebarWithAdmin(rec); + expect(container.textContent).toContain("EAST"); + expect(container.textContent).not.toContain("VIRTUAL"); }); }); diff --git a/ui/src/pages/recording-selector/constants.ts b/ui/src/pages/recording-selector/constants.ts index 3fc9668a1..f6923f816 100644 --- a/ui/src/pages/recording-selector/constants.ts +++ b/ui/src/pages/recording-selector/constants.ts @@ -25,6 +25,7 @@ export const SIDE_COLORS: Record = { OPFOR: "var(--side-opfor)", EAST: "var(--side-opfor)", IND: "var(--side-ind)", GUER: "var(--side-ind)", CIV: "var(--side-civ)", + VIRTUAL: "var(--side-virtual)", }; /** Raw hex side colors for inline style interpolation (hex alpha, rgba). */ @@ -33,6 +34,7 @@ export const SIDE_HEX: Record = { OPFOR: "#FF4A4A", EAST: "#FF4A4A", IND: "#2DD4A0", GUER: "#2DD4A0", CIV: "#A78BFA", + VIRTUAL: "#C9A227", }; diff --git a/ui/src/playback/__tests__/comms.test.ts b/ui/src/playback/__tests__/comms.test.ts new file mode 100644 index 000000000..cac6b4130 --- /dev/null +++ b/ui/src/playback/__tests__/comms.test.ts @@ -0,0 +1,401 @@ +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.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({ + 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); + 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", () => { + 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 1bfc78d92..9db0d2067 100644 --- a/ui/src/playback/__tests__/engine.test.ts +++ b/ui/src/playback/__tests__/engine.test.ts @@ -57,6 +57,7 @@ function makeMockChunkManager(chunkData: ChunkData | null = null): ChunkManager clear: vi.fn(), getManifest: vi.fn().mockReturnValue(null), setCallbacks: vi.fn(), + loadPlayerSnapshots: vi.fn().mockResolvedValue([]), } as unknown as ChunkManager; } @@ -89,6 +90,214 @@ 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(); + }); + }); + + // ─── 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([ + { frameNum: 10, type: "medicalSnapshot", payload: { unitId: 7, ace: { heartRate: 80 } } }, + { + frameNum: 20, + type: "medicalSnapshot", + payload: { unitId: 7, diffOf: 10, set: { ace: { heartRate: 130 } } }, + }, + ]); + engine.loadRecording(makeManifest({ snapshotUnitIds: [7] }), cm); + + expect(engine.eventManager.hasPlayerSnapshots(7)).toBe(false); + await engine.ensurePlayerSnapshots(7); + + expect(cm.loadPlayerSnapshots).toHaveBeenCalledWith(7); + expect(engine.eventManager.getPlayerSnapshots(7, 20).get("medicalSnapshot")!.payload) + .toEqual({ unitId: 7, ace: { heartRate: 130 } }); + expect(engine.playerSnapshotsVersion()).toBe(1); + }); + + it("fetches a unit at most once", async () => { + const cm = makeMockChunkManager(); + engine.loadRecording(makeManifest({ snapshotUnitIds: [7] }), cm); + + await Promise.all([engine.ensurePlayerSnapshots(7), engine.ensurePlayerSnapshots(7)]); + await engine.ensurePlayerSnapshots(7); + + expect(cm.loadPlayerSnapshots).toHaveBeenCalledTimes(1); + }); + + // A JSON recording, and a chunked one converted before the sidecars existed, + // both keep their snapshots in the manifest — already loaded, nothing to fetch. + it("does not fetch when the snapshots are already in the manifest", async () => { + const cm = makeMockChunkManager(); + engine.loadRecording( + makeManifest({ + events: [ + { frameNum: 10, type: "medicalSnapshot", payload: { unitId: 7, ace: { heartRate: 80 } } }, + ] as any, + }), + cm, + ); + + await engine.ensurePlayerSnapshots(7); + + expect(cm.loadPlayerSnapshots).not.toHaveBeenCalled(); + expect(engine.eventManager.hasPlayerSnapshots(7)).toBe(true); + expect(engine.hasPlayerSnapshots(7)).toBe(true); + }); + + it("resolves without a chunk manager at all", async () => { + engine.loadRecording(makeManifest({ snapshotUnitIds: [7] })); + await expect(engine.ensurePlayerSnapshots(7)).resolves.toBeUndefined(); + }); + + // A missing or corrupt sidecar must not break playback. + it("survives a failed sidecar fetch", async () => { + const cm = makeMockChunkManager(); + (cm.loadPlayerSnapshots as ReturnType).mockRejectedValue(new Error("404")); + vi.spyOn(console, "error").mockImplementation(() => {}); + engine.loadRecording(makeManifest({ snapshotUnitIds: [7] }), cm); + + await expect(engine.ensurePlayerSnapshots(7)).resolves.toBeUndefined(); + expect(engine.eventManager.hasPlayerSnapshots(7)).toBe(false); + }); + }); + // ─── loadRecording ─── describe("loadRecording", () => { @@ -124,6 +333,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__/entityManager.test.ts b/ui/src/playback/__tests__/entityManager.test.ts index 8452e663c..f06a1f7eb 100644 --- a/ui/src/playback/__tests__/entityManager.test.ts +++ b/ui/src/playback/__tests__/entityManager.test.ts @@ -4,7 +4,7 @@ import { Unit } from "../entities/unit"; import { Vehicle } from "../entities/vehicle"; import { Group } from "../entities/group"; import { EntityManager } from "../entityManager"; -import type { EntityDef, EntityState } from "../../data/types"; +import type { EntityDef, EntityState, Side } from "../../data/types"; // --------------------------------------------------------------------------- // Helpers @@ -17,12 +17,14 @@ function makeState( dir: number, alive: 0 | 1 | 2 = 1, isInVehicle = false, + side?: Side, ): EntityState { return { position: [x, y], direction: dir, alive, isInVehicle, + ...(side ? { side } : {}), }; } @@ -202,6 +204,11 @@ describe("Unit", () => { const u = new Unit(1, "Test", "man", 0, 10, "CIV", true, "G1"); expect(u.sideClass).toBe("civ"); }); + + it("VIRTUAL -> virtual", () => { + const u = new Unit(1, "Zeus", "zeus", 0, 10, "VIRTUAL", true, "Zeus"); + expect(u.sideClass).toBe("virtual"); + }); }); describe("sideColour", () => { @@ -257,6 +264,60 @@ describe("Unit", () => { }); }); + describe("sideAtFrame", () => { + it("returns the spawn-time side when positions are missing", () => { + const u = new Unit(1, "Test", "man", 0, 10, "WEST", true, "G1"); + expect(u.sideAtFrame(0)).toBe("WEST"); + expect(u.sideAtFrame(5)).toBe("WEST"); + }); + + it("returns the spawn-time side when the frame has no per-frame side", () => { + const positions: EntityState[] = [makeState(100, 200, 90, 1)]; + const u = new Unit(1, "Test", "man", 0, 10, "WEST", true, "G1", "", positions); + expect(u.sideAtFrame(0)).toBe("WEST"); + }); + + it("returns the per-frame side when it differs from spawn-time side", () => { + const positions: EntityState[] = [ + makeState(100, 200, 90, 1, false, "WEST"), + makeState(110, 210, 95, 1, false, "EAST"), + ]; + const u = new Unit(1, "Test", "man", 0, 10, "WEST", true, "G1", "", positions); + expect(u.sideAtFrame(0)).toBe("WEST"); + expect(u.sideAtFrame(1)).toBe("EAST"); + }); + + it("falls back to spawn-time side for frames outside the positions array", () => { + const positions: EntityState[] = [makeState(100, 200, 90, 1, false, "EAST")]; + const u = new Unit(1, "Test", "man", 10, 20, "WEST", true, "G1", "", positions); + expect(u.sideAtFrame(0)).toBe("WEST"); + expect(u.sideAtFrame(10)).toBe("EAST"); + }); + }); + + describe("roleAtFrame", () => { + it("returns the CREATE role when positions are missing", () => { + const u = new Unit(1, "Test", "man", 0, 10, "WEST", true, "G1", "Medic"); + expect(u.roleAtFrame(0)).toBe("Medic"); + }); + + it("prefers the per-frame getUnitType over CREATE roleDescription", () => { + const positions: EntityState[] = [ + { ...makeState(100, 200, 90, 1), role: "MG" }, + { ...makeState(110, 210, 95, 1), role: "AT" }, + ]; + const u = new Unit(1, "Test", "man", 0, 10, "WEST", true, "G1", "Spotter", positions); + expect(u.roleAtFrame(0)).toBe("MG"); + expect(u.roleAtFrame(1)).toBe("AT"); + }); + + it("falls back to CREATE role when the frame has no per-frame role", () => { + const positions: EntityState[] = [makeState(100, 200, 90, 1)]; + const u = new Unit(1, "Test", "man", 0, 10, "WEST", true, "G1", "Leader", positions); + expect(u.roleAtFrame(0)).toBe("Leader"); + }); + }); + describe("getStateAtFrame", () => { it("returns snapshot with correct side from the unit", () => { const positions: EntityState[] = [ @@ -315,6 +376,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); + }); + }); }); // --------------------------------------------------------------------------- @@ -472,6 +552,20 @@ describe("EntityManager", () => { expect(entity.name).toBe("Rifleman"); }); + it("creates a Unit for type 'zeus'", () => { + const entity = mgr.addEntity(unitDef({ + id: 90, + type: "zeus", + name: "Danny", + side: "VIRTUAL", + groupName: "Zeus", + role: "Zeus", + })); + expect(entity).toBeInstanceOf(Unit); + expect(entity.iconType).toBe("zeus"); + expect((entity as Unit).side).toBe("VIRTUAL"); + }); + it("creates a Vehicle for type 'car'", () => { const entity = mgr.addEntity(vehicleDef({ id: 10, type: "car" })); expect(entity).toBeInstanceOf(Vehicle); @@ -521,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__/eventManager.test.ts b/ui/src/playback/__tests__/eventManager.test.ts index 0d2d7b362..11cfa334f 100644 --- a/ui/src/playback/__tests__/eventManager.test.ts +++ b/ui/src/playback/__tests__/eventManager.test.ts @@ -2,13 +2,16 @@ 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, } from "../events/counterEvent"; +import { ExplosionEvent, RadioTransmissionEvent } from "../events/supportEvents"; import { EventManager } from "../eventManager"; import { EntityManager } from "../entityManager"; -import type { EntityDef } from "../../data/types"; +import { Unit } from "../entities/unit"; +import type { EntityDef, EntityState } from "../../data/types"; // --------------------------------------------------------------------------- // Helpers @@ -364,6 +367,179 @@ 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", + massUnits: 120, + load: 0.2, + 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, + massUnits: 180, + }); + + 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([]); + }); + + // Snapshots that arrive from a sidecar land after the initial load, so the + // fold has to be runnable for one unit without disturbing the others. + 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, { + unitId: 7, + diffOf: 10, + set: { ace: { heartRate: 130 } }, + })); + + mgr.reconstructPlayerSnapshotsFor(7); + + 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 }, + }); + }); + + it("reports whether a unit has any snapshots", () => { + expect(mgr.hasPlayerSnapshots(7)).toBe(false); + mgr.addEvent(new PlayerSnapshotEvent(10, "radioSnapshot", 1, { unitId: 7, radios: [] })); + expect(mgr.hasPlayerSnapshots(7)).toBe(true); + expect(mgr.hasPlayerSnapshots(99)).toBe(false); + }); + + it("is a no-op for a unit with no snapshots", () => { + expect(() => mgr.reconstructPlayerSnapshotsFor(99)).not.toThrow(); + }); + + 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" }] }, + }); + + mgr.addEvent(orphan); + mgr.reconstructPlayerSnapshots(); + + expect(orphan.payload).toEqual({ + unitId: 7, + radios: [{ class: "TFAR_anprc152_1" }], + }); + }); + + 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)); @@ -494,6 +670,76 @@ describe("EventManager", () => { expect(e2.victimName).toBe("Charlie"); expect(e2.causerName).toBe("Bravo"); }); + + it("uses per-frame side so a Zeus-switched unit is not friendly fire", () => { + const westThenEast: EntityState[] = [ + { position: [0, 0], direction: 0, alive: 1, side: "WEST" }, + { position: [1, 1], direction: 0, alive: 1, side: "EAST" }, + ]; + const entityMgr = new EntityManager(); + entityMgr.addEntity(unitDef({ id: 1, name: "Blufor", side: "WEST" })); + entityMgr.addEntity(unitDef({ + id: 2, + name: "Switched", + side: "WEST", + startFrame: 0, + endFrame: 1, + positions: westThenEast, + })); + + const event = new HitKilledEvent(1, "killed", 1, 2, 1, 40, "MX"); + mgr.addEvent(event); + mgr.resolveReferences(entityMgr); + + expect(event.victimSide).toBe("EAST"); + expect(event.causerSide).toBe("WEST"); + expect(event.isFriendlyFire()).toBe(false); + expect((entityMgr.getEntity(1) as Unit).teamKillCount).toBe(0); + expect(mgr.getKillDeathCounts(1).teamKills.get(1)).toBeUndefined(); + }); + + it("still treats same-side kills as friendly fire at the event frame", () => { + const stayWest: EntityState[] = [ + { position: [0, 0], direction: 0, alive: 1, side: "WEST" }, + { position: [1, 1], direction: 0, alive: 1, side: "WEST" }, + ]; + const entityMgr = new EntityManager(); + entityMgr.addEntity(unitDef({ id: 1, name: "Blufor", side: "WEST" })); + entityMgr.addEntity(unitDef({ + id: 2, + name: "Teammate", + side: "WEST", + startFrame: 0, + endFrame: 1, + positions: stayWest, + })); + + const event = new HitKilledEvent(1, "killed", 1, 2, 1, 10, "MX"); + mgr.addEvent(event); + mgr.resolveReferences(entityMgr); + + expect(event.isFriendlyFire()).toBe(true); + expect((entityMgr.getEntity(1) as Unit).teamKillCount).toBe(1); + expect(mgr.getKillDeathCounts(1).teamKills.get(1)).toBe(1); + }); + + it("falls back to spawn-time side when positions have no per-frame side", () => { + const entityMgr = new EntityManager(); + entityMgr.addEntity(unitDef({ id: 1, name: "Alpha", side: "WEST" })); + entityMgr.addEntity(unitDef({ + id: 2, + name: "Bravo", + side: "WEST", + positions: [{ position: [0, 0], direction: 0, alive: 1 }], + })); + + const event = new HitKilledEvent(0, "killed", 1, 2, 1, 5, "MX"); + mgr.addEvent(event); + mgr.resolveReferences(entityMgr); + + expect(event.victimSide).toBe("WEST"); + expect(event.isFriendlyFire()).toBe(true); + }); }); describe("getKillDeathCounts", () => { @@ -635,4 +881,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/__tests__/serverFpsStats.test.ts b/ui/src/playback/__tests__/serverFpsStats.test.ts new file mode 100644 index 000000000..1ecacdfce --- /dev/null +++ b/ui/src/playback/__tests__/serverFpsStats.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { EventManager } from "../eventManager"; +import { ServerFpsEvent } from "../events/serverFpsEvent"; +import { PlayerSnapshotEvent } from "../events/playerSnapshotEvent"; +import { GameEvent } from "../events/gameEvent"; + +describe("server FPS samples", () => { + let mgr: EventManager; + + beforeEach(() => { + mgr = new EventManager(); + }); + + const addSamples = (samples: [number, number][]) => { + samples.forEach(([frame, fps], index) => mgr.addEvent(new ServerFpsEvent(frame, index, fps))); + }; + + // A recording made before the sampler existed has no samples at all, and the + // Stats panel hides the card on `undefined` rather than showing zeros. + it("returns undefined when the recording carries no samples", () => { + expect(mgr.getServerFpsStats(0)).toBeUndefined(); + expect(mgr.getServerFpsStats(9999)).toBeUndefined(); + }); + + // The first sample is clamped to frame 0 by the recorder, so scrubbing to the + // very start of a recording already has one value to show. + it("reports the clamped first sample at frame 0", () => { + addSamples([[0, 51.2]]); + expect(mgr.getServerFpsStats(0)).toEqual({ current: 51.2, average: 51.2, median: 51.2 }); + }); + + it("stays undefined while the playhead sits before the first sample", () => { + addSamples([[10, 48]]); + expect(mgr.getServerFpsStats(9)).toBeUndefined(); + expect(mgr.getServerFpsStats(10)).toBeDefined(); + }); + + // Everything is scoped to the playhead: a sample from later in the recording + // must not leak into the average the viewer sees now. + it("aggregates only the samples up to the playhead", () => { + addSamples([ + [0, 40], + [10, 20], + [20, 30], + [30, 50], + ]); + + expect(mgr.getServerFpsStats(10)).toEqual({ current: 20, average: 30, median: 30 }); + + const atTwenty = mgr.getServerFpsStats(20)!; + expect(atTwenty.current).toBe(30); + expect(atTwenty.average).toBe(30); + expect(atTwenty.median).toBe(30); // odd count: the middle of 20, 30, 40 + }); + + it("averages the two middle values for an even sample count", () => { + addSamples([ + [0, 10], + [10, 20], + [20, 30], + [30, 100], + ]); + + const stats = mgr.getServerFpsStats(30)!; + expect(stats.current).toBe(100); + expect(stats.average).toBe(40); + expect(stats.median).toBe(25); // (20 + 30) / 2, unmoved by the outlier + }); + + // "current" is the newest sample by frame, not the newest one added: chunked + // recordings hand events over in fetch order, not recording order. + it("orders samples by frame regardless of insertion order", () => { + addSamples([ + [30, 50], + [0, 40], + [20, 30], + [10, 20], + ]); + + expect(mgr.getServerFpsStats(10)!.current).toBe(20); + 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(); + expect(mgr.getServerFpsStats(9999)).toBeUndefined(); + }); + + // Samples and per-player snapshots are read through their own lookups. They + // must stay out of the event log, which would otherwise be one FPS line every + // ten seconds plus a snapshot per player per interval. + it("keeps samples and snapshots out of the events feed", () => { + const killed = new GameEvent(15, "killed", 99); + mgr.addEvent(new ServerFpsEvent(0, 1, 40)); + mgr.addEvent(new PlayerSnapshotEvent(10, "staminaSnapshot", 2, { + unitId: 7, + vanilla: { stamina: 12.5, staminaMax: 30, load: 0.61, massUnits: 695, stance: 3 }, + })); + mgr.addEvent(killed); + + expect(mgr.getAll()).toEqual([killed]); + expect(mgr.getActiveEvents(9999)).toEqual([killed]); + expect(mgr.getEventsAtFrame(0)).toEqual([]); + expect(mgr.getEventsAtFrame(10)).toEqual([]); + + // ...while both are still reachable through their own lookups. + expect(mgr.getServerFpsStats(0)).toBeDefined(); + expect(mgr.hasPlayerSnapshots(7)).toBe(true); + }); +}); diff --git a/ui/src/playback/__tests__/weatherStats.test.ts b/ui/src/playback/__tests__/weatherStats.test.ts new file mode 100644 index 000000000..8396356a7 --- /dev/null +++ b/ui/src/playback/__tests__/weatherStats.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { EventManager } from "../eventManager"; +import { WeatherEvent } from "../events/weatherEvent"; +import { ServerFpsEvent } from "../events/serverFpsEvent"; +import { GameEvent } from "../events/gameEvent"; +import type { WeatherPayload } from "../../data/types"; + +describe("weather samples", () => { + 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/__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 new file mode 100644 index 000000000..dd4f72d99 --- /dev/null +++ b/ui/src/playback/__tests__/zeus.test.ts @@ -0,0 +1,570 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { EventManager } from "../eventManager"; +import { EntityManager } from "../entityManager"; +import { PlaybackEngine } from "../engine"; +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"; +import { MockRenderer } from "../../renderers/mockRenderer"; +import type { EntityDef, Manifest } from "../../data/types"; + +function unitDef(overrides: Partial = {}): EntityDef { + return { + id: 1, + type: "man", + name: "Rifleman", + side: "WEST", + groupName: "Alpha", + isPlayer: false, + startFrame: 0, + endFrame: 200, + ...overrides, + }; +} + +function makeManifest(overrides: Partial = {}): Manifest { + return { + version: 1, + worldName: "Altis", + missionName: "Zeus", + endFrame: 200, + chunkSize: 300, + captureDelayMs: 1000, + chunkCount: 1, + entities: [], + events: [], + markers: [], + times: [], + ...overrides, + }; +} + +describe("formatUnitTypeLabel", () => { + it("maps recorded getUnitType codes to infantry labels", () => { + expect(formatUnitTypeLabel("Man")).toBe("Rifleman"); + expect(formatUnitTypeLabel("MG")).toBe("Autorifleman"); + expect(formatUnitTypeLabel("Sniper")).toBe("Marksman"); + expect(formatUnitTypeLabel("ExplosiveSpecialist")).toBe("Explosive Specialist"); + }); + + it("strips mission slot suffixes and passes unknown roles through", () => { + expect(formatUnitTypeLabel("SL@Vova")).toBe("SL"); + expect(formatUnitTypeLabel("Spotter")).toBe("Spotter"); + expect(formatUnitTypeLabel("")).toBe(""); + expect(formatUnitTypeLabel(undefined)).toBe(""); + }); +}); + +describe("formatZeusOccupancyName", () => { + it("appends unit type when the recording has one", () => { + expect(formatZeusOccupancyName("Danny", "Bakunin", "Man")).toBe( + "Danny controlling Bakunin (Rifleman)", + ); + }); + + it("omits parentheses when no unit type was recorded", () => { + expect(formatZeusOccupancyName("Danny", "Bakunin", "")).toBe( + "Danny controlling Bakunin", + ); + }); +}); + +describe("armaFovToDegrees", () => { + it("converts the default Arma FOV of 0.75 to about 74 degrees", () => { + expect(armaFovToDegrees(0.75)).toBeCloseTo(73.74, 1); + }); + + it("falls back when the value is unusable", () => { + expect(armaFovToDegrees(0)).toBe(75); + expect(armaFovToDegrees(Number.NaN)).toBe(75); + }); +}); + +describe("EventManager Zeus occupancy", () => { + let mgr: EventManager; + + beforeEach(() => { + mgr = new EventManager(); + mgr.addEvent(new ZeusEntityEvent(2, 1, { + curatorId: 90, + name: "Danny", + playerUid: "7656", + bodyUnitId: -1, + })); + mgr.addEvent(new ZeusRemoteControlEvent(10, 2, { + curatorId: 90, + unitId: 7, + active: true, + playerName: "Danny", + })); + mgr.addEvent(new ZeusRemoteControlEvent(40, 3, { + curatorId: 90, + unitId: 7, + active: false, + })); + mgr.addEvent(new ZeusCameraEvent(5, 4, { + curatorId: 90, + x: 1000, + y: 2000, + dir: 90, + fov: 0.75, + pitch: 0.1, + })); + }); + + it("lists discovered Zeus identities", () => { + expect(mgr.getZeusEntities()).toEqual([ + expect.objectContaining({ curatorId: 90, name: "Danny", startFrame: 2 }), + ]); + }); + + it("is hidden before the first entity event", () => { + expect(mgr.getZeusState(90, 1)).toBeNull(); + }); + + it("returns the camera while flying and not possessing", () => { + const state = mgr.getZeusState(90, 8); + expect(state?.camera).toEqual({ x: 1000, y: 2000, dir: 90, fov: 0.75, pitch: 0.1 }); + expect(state?.controllingUnitId).toBeNull(); + }); + + it("tracks remote-control occupancy by frame", () => { + expect(mgr.getZeusState(90, 10)?.controllingUnitId).toBe(7); + expect(mgr.getZeusControllingUnit(7, 10)).toBe(90); + expect(mgr.getZeusState(90, 40)?.controllingUnitId).toBeNull(); + expect(mgr.getZeusControllingUnit(7, 40)).toBeNull(); + }); + + it("attributes possessed-unit kills to the Zeus entity", () => { + const entities = new EntityManager(); + entities.addEntity(unitDef({ id: 7, name: "AI", side: "EAST" })); + entities.addEntity(unitDef({ id: 8, name: "Victim", side: "WEST" })); + entities.addEntity(unitDef({ + id: 90, + type: "zeus", + name: "Danny", + side: "VIRTUAL", + groupName: "Zeus", + role: "Zeus", + })); + + mgr.addEvent(new HitKilledEvent(20, "killed", 1, 8, 7, 40, "AK")); + mgr.resolveReferences(entities); + + const zeus = entities.getEntity(90) as Unit; + expect(zeus.killCount).toBe(1); + expect(mgr.getKillDeathCounts(20).kills.get(90)).toBe(1); + expect(mgr.getKillDeathCounts(9).kills.get(90)).toBeUndefined(); + }); + + it("attributes a possessed-unit kill shortly after remote-control stop", () => { + const entities = new EntityManager(); + entities.addEntity(unitDef({ id: 5, name: "Marek", side: "WEST" })); + entities.addEntity(unitDef({ id: 6, name: "Tung", side: "EAST" })); + entities.addEntity(unitDef({ + id: 90, + type: "zeus", + name: "YetheSamartaka", + side: "VIRTUAL", + groupName: "Zeus", + role: "Zeus", + isPlayer: true, + })); + + mgr.addEvent(new ZeusRemoteControlEvent(123, 5, { + curatorId: 90, + unitId: 5, + active: true, + playerName: "YetheSamartaka", + })); + mgr.addEvent(new ZeusRemoteControlEvent(149, 6, { + curatorId: 90, + unitId: 5, + active: false, + })); + mgr.addEvent(new HitKilledEvent(166, "killed", 1, 6, 5, 43, "CZ BREN 2")); + mgr.resolveReferences(entities); + + expect((entities.getEntity(90) as Unit).killCount).toBe(1); + expect(mgr.getKillDeathCounts(166).kills.get(90)).toBe(1); + expect(mgr.getZeusControllingUnit(5, 166)).toBeNull(); + expect(mgr.getKillDeathCounts(200).kills.get(90)).toBe(1); + + mgr.addEvent(new HitKilledEvent(200, "killed", 2, 6, 5, 10, "CZ BREN 2")); + expect(mgr.getKillDeathCounts(200).kills.get(90)).toBe(1); + expect(mgr.getKillDeathCounts(200).kills.get(5)).toBe(2); + }); +}); + +describe("EventManager Zeus body kill attribution", () => { + it("attributes the Zeus player's body kills to the VIRTUAL curator", () => { + const mgr = new EventManager(); + const entities = new EntityManager(); + entities.addEntity(unitDef({ + id: 0, + name: "YetheSamartaka", + side: "CIV", + isPlayer: true, + })); + entities.addEntity(unitDef({ id: 2, name: "Horacek", side: "WEST" })); + entities.addEntity(unitDef({ id: 3, name: "Hall", side: "WEST" })); + entities.addEntity(unitDef({ + id: 1, + type: "zeus", + name: "YetheSamartaka", + side: "VIRTUAL", + groupName: "Zeus", + role: "Zeus", + isPlayer: true, + })); + + mgr.addEvent(new ZeusEntityEvent(1, 1, { + curatorId: 1, + name: "YetheSamartaka", + playerUid: "7656", + bodyUnitId: 0, + })); + mgr.addEvent(new HitKilledEvent(91, "killed", 1, 2, 0, 2, "L85A2")); + mgr.addEvent(new HitKilledEvent(120, "killed", 2, 3, 0, 26, "L85A2")); + mgr.resolveReferences(entities); + + const zeus = entities.getEntity(1) as Unit; + const body = entities.getEntity(0) as Unit; + expect(zeus.killCount).toBe(2); + expect(body.killCount).toBe(2); + expect(mgr.getKillDeathCounts(91).kills.get(1)).toBe(1); + expect(mgr.getKillDeathCounts(120).kills.get(1)).toBe(2); + expect(mgr.getKillDeathCounts(90).kills.get(1)).toBeUndefined(); + }); + + it("does not double-count when curatorId and bodyUnitId are the same", () => { + const mgr = new EventManager(); + const entities = new EntityManager(); + entities.addEntity(unitDef({ + id: 0, + type: "zeus", + name: "Danny", + side: "VIRTUAL", + isPlayer: true, + })); + entities.addEntity(unitDef({ id: 2, name: "Victim", side: "EAST" })); + + mgr.addEvent(new ZeusEntityEvent(1, 1, { + curatorId: 0, + name: "Danny", + playerUid: "7656", + bodyUnitId: 0, + })); + mgr.addEvent(new HitKilledEvent(10, "killed", 1, 2, 0, 5, "L85A2")); + mgr.resolveReferences(entities); + + expect((entities.getEntity(0) as Unit).killCount).toBe(1); + expect(mgr.getKillDeathCounts(10).kills.get(0)).toBe(1); + }); +}); + +describe("PlaybackEngine Zeus synthesis", () => { + let engine: PlaybackEngine; + + beforeEach(() => { + vi.useFakeTimers(); + engine = new PlaybackEngine(new MockRenderer()); + }); + + it("creates a VIRTUAL Zeus unit and follows camera then the possessed unit", () => { + engine.loadRecording(makeManifest({ + entities: [ + unitDef({ + id: 7, + name: "Bakunin", + side: "EAST", + role: "Man", + positions: [ + { position: [0, 0], direction: 0, alive: 1, role: "Man" }, + { position: [10, 20], direction: 45, alive: 1, role: "MG" }, + ], + }), + ], + events: [ + { + frameNum: 0, + type: "zeusEntity", + payload: { curatorId: 90, name: "Danny", playerUid: "7656", bodyUnitId: -1 }, + }, + { + frameNum: 0, + type: "zeusCamera", + payload: { curatorId: 90, x: 500, y: 600, dir: 180, fov: 0.75, pitch: 0 }, + }, + { + frameNum: 1, + type: "zeusRemoteControl", + payload: { curatorId: 90, unitId: 7, active: true, playerName: "Danny" }, + }, + ], + })); + + const zeus = engine.entityManager.getEntity(90); + expect(zeus).toBeInstanceOf(Unit); + expect((zeus as Unit).side).toBe("VIRTUAL"); + expect(engine.entityManager.getBySide("VIRTUAL")).toHaveLength(1); + + engine.seekTo(0); + const flying = engine.entitySnapshots().get(90); + expect(flying?.position).toEqual([500, 600]); + expect(flying?.iconType).toBe("zeus"); + expect(flying?.fov).toBe(0.75); + expect(flying?.side).toBe("VIRTUAL"); + + engine.seekTo(1); + const possessing = engine.entitySnapshots().get(90); + expect(possessing?.position).toEqual([10, 20]); + expect(possessing?.side).toBe("EAST"); + expect(possessing?.controllingUnitId).toBe(7); + expect(possessing?.name).toBe("Danny controlling Bakunin (Autorifleman)"); + expect(possessing?.fov).toBeUndefined(); + }); + + 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({ + id: 0, + name: "", + type: "unknown", + side: "CIV", + }), + unitDef({ + id: 1, + name: "Horacek", + side: "WEST", + }), + ], + events: [ + { + frameNum: startFrame, + type: "zeusEntity", + payload: { curatorId: 0, name: "YetheSamartaka", playerUid: "7656", bodyUnitId: -1 }, + }, + { + frameNum: startFrame, + type: "zeusCamera", + payload: { curatorId: 0, x: 8504, y: -902, dir: 0, fov: 1 }, + }, + ], + })); + + const zeus = engine.entityManager.getEntity(0); + expect(zeus).toBeInstanceOf(Unit); + 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", () => { + const ping = (frameNum: number, overrides: Record = {}) => + new ZeusPingEvent(frameNum, frameNum, { + curatorId: 90, + unitId: 7, + name: "Danny", + side: "WEST", + x: 3411, + y: 9002, + ...overrides, + }); + + describe("zeusPingLifetimeFrames", () => { + it("is 180 frames at the default 1 s capture delay", () => { + expect(zeusPingLifetimeFrames(1000)).toBe(ZEUS_PING_MAP_SECONDS); + }); + + it("scales with a non-default capture delay", () => { + expect(zeusPingLifetimeFrames(500)).toBe(360); + expect(zeusPingLifetimeFrames(2000)).toBe(90); + }); + + it("falls back rather than dividing by zero", () => { + expect(zeusPingLifetimeFrames(0)).toBe(ZEUS_PING_MAP_SECONDS); + }); + }); + + describe("getActivePings", () => { + let events: EventManager; + + beforeEach(() => { + events = new EventManager(); + events.addEvent(ping(100)); + }); + + it("shows the ping on its own frame and until the lifetime elapses", () => { + expect(events.getActivePings(100, 180)).toHaveLength(1); + expect(events.getActivePings(279, 180)).toHaveLength(1); + }); + + it("drops the ping exactly at the end of its lifetime", () => { + expect(events.getActivePings(280, 180)).toEqual([]); + }); + + it("does not show a ping before it happened", () => { + expect(events.getActivePings(99, 180)).toEqual([]); + }); + + it("keeps the ping in the event log after it leaves the map", () => { + expect(events.getActiveEvents(5000)).toHaveLength(1); + expect(events.getActivePings(5000, 180)).toEqual([]); + }); + }); + + describe("groupZeusPings", () => { + it("collapses co-located pings from one player and lists times newest first", () => { + const groups = groupZeusPings([ping(100), ping(160, { x: 3420, y: 9010 })]); + expect(groups).toHaveLength(1); + expect(groups[0].frames).toEqual([160, 100]); + expect(groups[0].latestFrame).toBe(160); + expect(groups[0].position).toEqual([3420, 9010]); + }); + + it("keeps pings from the same player far apart separate", () => { + const groups = groupZeusPings([ping(100), ping(160, { x: 5000, y: 9002 })]); + expect(groups).toHaveLength(2); + }); + + it("keeps different players separate even at the same spot", () => { + const groups = groupZeusPings([ping(100), ping(100, { unitId: 8, name: "Horacek" })]); + expect(groups).toHaveLength(2); + }); + }); + + describe("zeusPingAlpha", () => { + it("is solid for most of the lifetime and fades at the end", () => { + expect(zeusPingAlpha(100, 100, 180)).toBe(1); + expect(zeusPingAlpha(240, 100, 180)).toBe(1); + expect(zeusPingAlpha(275, 100, 180)).toBeLessThan(1); + }); + + it("is zero outside the lifetime", () => { + expect(zeusPingAlpha(280, 100, 180)).toBe(0); + expect(zeusPingAlpha(99, 100, 180)).toBe(0); + }); + }); +}); + +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/comms.ts b/ui/src/playback/comms.ts new file mode 100644 index 000000000..96dd7126d --- /dev/null +++ b/ui/src/playback/comms.ts @@ -0,0 +1,342 @@ +import type { RadioSnapshotEntry, RadioTransmissionPayload } from "../data/types"; +import type { TfarRadioPropagation, AcreRadioPropagation } from "../data/radioPropagation"; +import type { DemGrid } from "./radioRange/demGrid"; +import { effectiveDistance, 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 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. + */ +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) { + 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, + 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 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 = Math.min(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..09281ea40 --- /dev/null +++ b/ui/src/playback/deathHeatmap.ts @@ -0,0 +1,115 @@ +/** + * 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. + */ + +import type { PlaybackEngine } from "./engine"; +import { HitKilledEvent } from "./events/hitKilledEvent"; + +/** One death, already resolved to a map position. */ +export interface DeathSite { + frameNum: number; + x: number; + 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; + 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 edb485da3..a8db8caa5 100644 --- a/ui/src/playback/engine.ts +++ b/ui/src/playback/engine.ts @@ -1,6 +1,13 @@ import { createSignal, type Accessor } from "solid-js"; -import type { Manifest, EventDef } from "../data/types"; +import type { EntityState, Manifest, EventDef, Side, WorldConfig } from "../data/types"; +import type { ArmaCoord } from "../utils/coordinates"; +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"; @@ -9,14 +16,32 @@ import type { GameEvent } from "./events/gameEvent"; import type { CounterState } from "./events/counterEvent"; import { EntityManager } from "./entityManager"; import { EventManager } from "./eventManager"; +import { EventSideIndex } from "./eventSides"; import { HitKilledEvent } from "./events/hitKilledEvent"; import { ConnectEvent } from "./events/connectEvent"; 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 { ServerFpsEvent } from "./events/serverFpsEvent"; +import { WeatherEvent } from "./events/weatherEvent"; +import { + ZeusCameraEvent, + ZeusEntityEvent, + ZeusModuleEvent, + 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"; // ─── Event factory ─── @@ -55,6 +80,36 @@ 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; + 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": + return new ZeusRemoteControlEvent(def.frameNum, id, def.payload); + case "zeusCamera": + 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": + 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; } @@ -79,6 +134,9 @@ export class PlaybackEngine { private renderer: MapRenderer; private chunkManager: ChunkManager | null = null; private manifest: Manifest | null = null; + private eventSides: EventSideIndex | null = null; + private _worldConfig: Accessor; + private _setWorldConfig: (world: WorldConfig | undefined) => void; // ─── Signals (reactive state) ─── private _currentFrame: Accessor; @@ -107,6 +165,14 @@ export class PlaybackEngine { private _captureDelayMs: Accessor; 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>(); // ─── Playback loop state ─── /** Max delta (ms) before treating a gap as a background-tab resume. */ @@ -118,6 +184,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; @@ -157,6 +227,12 @@ export class PlaybackEngine { const [captureDelayMs, setCaptureDelayMs] = createSignal(1000); this._captureDelayMs = captureDelayMs; this._setCaptureDelayMs = setCaptureDelayMs; + + 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 ─── @@ -188,6 +264,26 @@ export class PlaybackEngine { get captureDelayMs(): Accessor { return this._captureDelayMs; } + /** Bumped whenever a player's snapshots finish loading, so the card re-reads. */ + get playerSnapshotsVersion(): Accessor { + return this._playerSnapshotsVersion; + } + /** Changes even when two recordings have the same duration. */ + get recordingVersion(): Accessor { return this._recordingVersion; } + /** 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(); + } + setWorldConfig(world: WorldConfig | undefined): void { + this._setWorldConfig(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 @@ -234,12 +330,18 @@ 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)); + // 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); }); } } @@ -280,8 +382,13 @@ export class PlaybackEngine { /** * Populate entities and events from a manifest, and wire up the chunk manager. + * 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): void { + loadRecording( + manifest: Manifest, + chunkManager?: ChunkManager | null, + ): void { // Reset state this.clearTimer(); this._setIsPlaying(false); @@ -289,16 +396,22 @@ export class PlaybackEngine { this._setFollowTarget(null); this.entityManager.clear(); this.eventManager.clear(); + this.playerSnapshotLoads.clear(); this.manifest = manifest; this.chunkManager = chunkManager ?? 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 // 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.eventSides?.sample(chunkIndex, chunk)) this.resolveCombatReferences(); const frame = this._currentFrame(); const chunkSize = this.manifest?.chunkSize || 300; const currentChunkIndex = Math.floor(frame / chunkSize); @@ -325,8 +438,38 @@ export class PlaybackEngine { } } - // Resolve entity references on hit/killed events - this.eventManager.resolveReferences(this.entityManager); + // Fold diff-encoded player snapshots back into full snapshots + this.eventManager.reconstructPlayerSnapshots(); + + for (const zeus of this.eventManager.getZeusEntities()) { + const existing = this.entityManager.getEntity(zeus.curatorId); + // Zeus borrows nextId without :SOLDIER:CREATE:, so the dense entities[] + // 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 || existing.type !== "unknown")) continue; + this.entityManager.replaceEntity({ + id: zeus.curatorId, + type: "zeus", + name: zeus.name, + side: "VIRTUAL", + groupName: "Zeus", + isPlayer: true, + startFrame: zeus.startFrame, + endFrame: manifest.endFrame, + role: "Zeus", + }); + } + + // 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.eventSides.sample(index, chunk); + } + } + this.resolveCombatReferences(); // Build counter state from counter events this.buildCounterState(manifest.events); @@ -338,6 +481,181 @@ 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.getEventSideAt(id, frame), + ); + this._setEventReferencesVersion((version) => version + 1); + } + + /** + * Make sure a player's detail snapshots are loaded before the profile card + * reads them. + * + * A JSON recording, and a chunked recording converted before the sidecars + * existed, both carry their snapshots in the manifest — those are already in + * the event manager and this resolves immediately. Otherwise the unit's + * sidecar is fetched once and folded into the same index, so every reader + * downstream sees one shape regardless of where the data came from. + */ + ensurePlayerSnapshots(unitId: number): Promise { + const existing = this.playerSnapshotLoads.get(unitId); + 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); + return resolved; + } + + 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); + } + this.eventManager.reconstructPlayerSnapshotsFor(unitId); + 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); + this.playerSnapshotLoads.delete(unitId); + }); + + this.playerSnapshotLoads.set(unitId, load); + 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, as does an active map transmission. + * 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; + } + + /** 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; + } + + /** + * 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; + 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); + } + } + } + } + + /** Whether a player has snapshots at all, before any sidecar is fetched. */ + hasPlayerSnapshots(unitId: number): boolean { + return ( + this.eventManager.hasPlayerSnapshots(unitId) || + (this.manifest?.snapshotUnitIds?.includes(unitId) ?? false) + ); } dispose(): void { @@ -345,8 +663,12 @@ export class PlaybackEngine { this._setIsPlaying(false); this.entityManager.clear(); this.eventManager.clear(); + this.playerSnapshotLoads.clear(); + this.eventSides = null; this.chunkManager = null; this.manifest = null; + this._setWorldConfig(undefined); + this._setRecordingVersion((version) => version + 1); } // ─── Playback loop ─── @@ -402,7 +724,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 @@ -517,6 +842,62 @@ export class PlaybackEngine { } } + for (const entity of this.entityManager.getAll()) { + if (!(entity instanceof Unit) || entity.type !== "zeus") continue; + if (frame < entity.startFrame || frame > entity.endFrame) continue; + + const zeus = this.eventManager.getZeusState(entity.id, frame); + if (!zeus) continue; + + let position = snapshots.get(entity.id)?.position; + let direction = snapshots.get(entity.id)?.direction ?? 0; + let side = zeus.side; + let name = entity.name; + let fov: number | undefined; + let pitch: number | undefined; + let controllingUnitId: number | undefined; + + if (zeus.controllingUnitId !== null) { + const host = snapshots.get(zeus.controllingUnitId); + const hostEntity = this.entityManager.getEntity(zeus.controllingUnitId); + if (!host && !zeus.camera) continue; + if (host) { + position = host.position; + direction = host.direction; + if (host.side) side = host.side; + } else if (zeus.camera) { + position = [zeus.camera.x, zeus.camera.y]; + direction = zeus.camera.dir; + } + const hostName = host?.name ?? (hostEntity instanceof Unit ? hostEntity.name : null); + const hostType = hostEntity instanceof Unit ? hostEntity.roleAtFrame(frame) : ""; + name = hostName ? formatZeusOccupancyName(entity.name, hostName, hostType) : entity.name; + controllingUnitId = zeus.controllingUnitId; + } else if (zeus.camera) { + position = [zeus.camera.x, zeus.camera.y]; + direction = zeus.camera.dir; + fov = zeus.camera.fov; + pitch = zeus.camera.pitch; + } + + if (!position) continue; + + snapshots.set(entity.id, { + id: entity.id, + position, + direction, + alive: 1, + side, + name, + iconType: "zeus", + isPlayer: true, + isInVehicle: false, + fov, + pitch, + controllingUnitId, + }); + } + this._setEntitySnapshots(snapshots); } diff --git a/ui/src/playback/entities/unit.ts b/ui/src/playback/entities/unit.ts index 9cd160571..c3bd76918 100644 --- a/ui/src/playback/entities/unit.ts +++ b/ui/src/playback/entities/unit.ts @@ -57,6 +57,45 @@ 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; + } + + /** + * Combat side at an absolute playback frame. + * Prefers the per-frame position side (Zeus group-side switches, etc.) + * and falls back to the entity's spawn-time side so recordings without + * that field keep working. + */ + sideAtFrame(absoluteFrame: number): Side { + const relative = this.getRelativeFrameIndex(absoluteFrame); + if (!this.isFrameOutOfBounds(relative)) { + const state = this.positions![relative]; + if (state?.side) return state.side; + } + return this.side; + } + + /** + * Unit type at an absolute playback frame. + * Prefers the per-frame getUnitType (Man, MG, AT, …) and falls back to + * the CREATE roleDescription so recordings without that field keep working. + */ + roleAtFrame(absoluteFrame: number): string { + const relative = this.getRelativeFrameIndex(absoluteFrame); + if (!this.isFrameOutOfBounds(relative)) { + const state = this.positions![relative]; + if (state?.role) return state.role; + } + return this.role; + } + /** CSS class for the unit's side: WEST->'blufor', EAST->'opfor', etc. */ get sideClass(): string { return SIDE_CLASS[this.side] ?? "unknown"; diff --git a/ui/src/playback/entityManager.ts b/ui/src/playback/entityManager.ts index c06fc7565..5123a6cd5 100644 --- a/ui/src/playback/entityManager.ts +++ b/ui/src/playback/entityManager.ts @@ -14,7 +14,7 @@ export class EntityManager { /** * Create a Unit or Vehicle from an EntityDef and register it. - * Type 'man' produces a Unit; everything else produces a Vehicle. + * Type 'man' or 'zeus' produces a Unit; everything else produces a Vehicle. */ addEntity(def: EntityDef): Entity { // Duplicate ID (e.g. empty placeholder entries): keep the entity with the most frames. @@ -28,7 +28,7 @@ export class EntityManager { let entity: Entity; - if (def.type === "man") { + if (def.type === "man" || def.type === "zeus") { entity = new Unit( def.id, def.name, @@ -40,7 +40,7 @@ export class EntityManager { def.groupName, def.role ?? "", def.positions ?? null, - "man", + def.type === "zeus" ? "zeus" : "man", def.framesFired ?? null, ); } else { @@ -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/eventManager.ts b/ui/src/playback/eventManager.ts index 84a011b04..8e61dc3dd 100644 --- a/ui/src/playback/eventManager.ts +++ b/ui/src/playback/eventManager.ts @@ -3,6 +3,20 @@ 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 { ServerFpsEvent } from "./events/serverFpsEvent"; +import { WeatherEvent } from "./events/weatherEvent"; +import { + ZeusCameraEvent, + ZeusEntityEvent, + ZeusModuleEvent, + ZeusPingEvent, + ZeusRemoteControlEvent, +} from "./events/zeusEvents"; +import { ExplosionEvent, RadioTransmissionEvent } from "./events/supportEvents"; +import { applySnapshotDiff, isSnapshotDiff } from "../data/snapshotDiff"; +import type { PlayerSnapshotPayload, PlayerSnapshotType, Side, WeatherPayload } from "../data/types"; +import { ZEUS_RC_KILL_GRACE_FRAMES, type ZeusCameraSample, type ZeusEntityInfo, type ZeusFrameState } from "./zeus"; /** * Manages all mission events for a playback session. @@ -12,9 +26,105 @@ import { HitKilledEvent } from "./events/hitKilledEvent"; export class EventManager { private events: GameEvent[] = []; 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[] = []; + private zeusCameras = new Map(); + private zeusPings: ZeusPingEvent[] = []; + private zeusModules: ZeusModuleEvent[] = []; + 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 { + 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; + } + if (event instanceof ServerFpsEvent) { + // 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; + } + 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, + name: event.payload.name, + playerUid: event.payload.playerUid, + bodyUnitId: event.payload.bodyUnitId, + startFrame: event.frameNum, + }); + return; + } + if (event instanceof ZeusRemoteControlEvent) { + this.zeusRemoteControl.push(event); + this.zeusRemoteControl.sort((a, b) => a.frameNum - b.frameNum); + return; + } + if (event instanceof ZeusCameraEvent) { + const series = this.zeusCameras.get(event.payload.curatorId) ?? []; + series.push(event); + series.sort((a, b) => a.frameNum - b.frameNum); + 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. + 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); @@ -42,15 +152,287 @@ 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 unitId of this.playerSnapshots.keys()) { + this.reconstructPlayerSnapshotsFor(unitId); + } + } + + /** + * Rebuild one player's diff-encoded snapshots. Snapshots that arrive from a + * sidecar land after load, so the fold has to be runnable per unit; a series + * is self-contained, which is why sharding by unit keeps the chain intact. + */ + reconstructPlayerSnapshotsFor(unitId: number): void { + const byType = this.playerSnapshots.get(unitId); + if (!byType) return; + 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; + } + } + } + + /** 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; + } + + /** 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; + } + + /** 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 }; + } + + /** 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--) { + 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`. + */ + getActivePings(frame: number, lifetimeFrames: number): ZeusPingEvent[] { + return this.zeusPings.filter( + (ping) => ping.frameNum <= frame && frame < ping.frameNum + lifetimeFrames, + ); + } + + /** + * 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`. + */ + 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; + } + + /** Zeus identities discovered in this recording, in curatorId order. */ + getZeusEntities(): ZeusEntityInfo[] { + return [...this.zeusEntities.values()].sort((a, b) => a.curatorId - b.curatorId); + } + + /** Occupancy + last camera for one Zeus at or before `frame`. */ + getZeusState(curatorId: number, frame: number): ZeusFrameState | null { + const info = this.zeusEntities.get(curatorId); + if (!info || frame < info.startFrame) return null; + + let controllingUnitId: number | null = null; + let controllingName: string | null = null; + for (const event of this.zeusRemoteControl) { + if (event.frameNum > frame) break; + if (event.payload.curatorId !== curatorId) continue; + if (event.payload.active) { + controllingUnitId = event.payload.unitId; + controllingName = event.payload.playerName ?? info.name; + } else if (event.payload.unitId === controllingUnitId) { + controllingUnitId = null; + controllingName = null; + } + } + + let camera: ZeusCameraSample | null = null; + const series = this.zeusCameras.get(curatorId); + if (series) { + for (let i = series.length - 1; i >= 0; i--) { + const sample = series[i]; + if (sample.frameNum <= frame) { + camera = { + x: sample.payload.x, + y: sample.payload.y, + dir: sample.payload.dir, + fov: sample.payload.fov, + pitch: sample.payload.pitch ?? 0, + }; + break; + } + } + } + + if (controllingUnitId === null && camera === null) return null; + + return { + curatorId, + name: info.name, + controllingUnitId, + controllingName, + camera, + side: "VIRTUAL", + }; + } + + /** Curator who was remote-controlling `unitId` at `frame`, if any. */ + getZeusControllingUnit(unitId: number, frame: number): number | null { + let curatorId: number | null = null; + for (const event of this.zeusRemoteControl) { + if (event.frameNum > frame) break; + if (event.payload.unitId !== unitId) continue; + curatorId = event.payload.active ? event.payload.curatorId : null; + } + return curatorId; + } + + /** + * Curator who should also receive a kill recorded as `causedById`. + * + * Vanilla/ACE instigator is still the Zeus player's body (`player`) while + * flying or after leaving a possessed unit, so those kills land on + * `bodyUnitId` rather than the synthesized VIRTUAL curator id. Occupancy + * covers a live possess; a short grace after `active: false` covers the + * kill EH firing after the poll already recorded a leave (pure-virtual + * Zeus has no body to fall back on). + */ + getZeusAttributedKiller(causedById: number, frame: number): number | null { + const fromRc = this.getZeusControllingUnit(causedById, frame) + ?? this.getZeusRecentlyControllingUnit(causedById, frame); + if (fromRc !== null && fromRc !== causedById) return fromRc; + + for (const info of this.zeusEntities.values()) { + if (frame < info.startFrame) continue; + if (info.bodyUnitId < 0 || info.bodyUnitId !== causedById) continue; + if (info.curatorId === causedById) continue; + return info.curatorId; + } + return null; + } + + /** Last curator who released `unitId`, if `frame` is still inside the kill grace. */ + private getZeusRecentlyControllingUnit(unitId: number, frame: number): number | null { + let stopCuratorId: number | null = null; + let stopFrame = -1; + for (const event of this.zeusRemoteControl) { + if (event.frameNum > frame) break; + if (event.payload.unitId !== unitId) continue; + if (event.payload.active) { + stopCuratorId = null; + stopFrame = -1; + } else { + stopCuratorId = event.payload.curatorId; + stopFrame = event.frameNum; + } + } + if (stopCuratorId === null || stopFrame < 0) return null; + if (frame - stopFrame > ZEUS_RC_KILL_GRACE_FRAMES) return null; + return stopCuratorId; + } + /** * Resolve entity references on HitKilledEvent instances. - * Populates names, sides, and computes kill counts. + * 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 { + // 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) { @@ -59,7 +441,7 @@ export class EventManager { event.victimName = victim.name; event.victimIsVehicle = victim instanceof Vehicle; if (victim instanceof Unit) { - event.victimSide = victim.side; + event.victimSide = sideAt?.(victim.id, event.frameNum) ?? victim.sideAtFrame(event.frameNum); } } @@ -67,7 +449,7 @@ export class EventManager { if (causer) { event.causerName = causer.name; if (causer instanceof Unit) { - event.causerSide = causer.side; + event.causerSide = sideAt?.(causer.id, event.frameNum) ?? causer.sideAtFrame(event.frameNum); } } } @@ -91,6 +473,14 @@ export class EventManager { if (event.isFriendlyFire()) { causer.teamKillCount++; } + const curatorId = this.getZeusAttributedKiller(event.causedById, event.frameNum); + if (curatorId !== null) { + const zeus = entityManager.getEntity(curatorId); + if (zeus instanceof Unit) { + zeus.killCount++; + if (event.isFriendlyFire()) zeus.teamKillCount++; + } + } } // Attach current score to the event (even for self-kills) event.causerKillScore = causer.killCount - causer.teamKillCount * 2; @@ -127,6 +517,10 @@ export class EventManager { // Vehicle kill for causer (non-self kills only) if (event.causedById !== event.victimId) { vehicleKills.set(event.causedById, (vehicleKills.get(event.causedById) ?? 0) + 1); + const curatorId = this.getZeusAttributedKiller(event.causedById, event.frameNum); + if (curatorId !== null) { + vehicleKills.set(curatorId, (vehicleKills.get(curatorId) ?? 0) + 1); + } } continue; } @@ -140,6 +534,13 @@ export class EventManager { if (event.isFriendlyFire()) { teamKills.set(event.causedById, (teamKills.get(event.causedById) ?? 0) + 1); } + const curatorId = this.getZeusAttributedKiller(event.causedById, event.frameNum); + if (curatorId !== null) { + kills.set(curatorId, (kills.get(curatorId) ?? 0) + 1); + if (event.isFriendlyFire()) { + teamKills.set(curatorId, (teamKills.get(curatorId) ?? 0) + 1); + } + } } } @@ -150,5 +551,18 @@ export class EventManager { clear(): void { this.events = []; this.frameIndex = new Map(); + this.playerSnapshots = new Map(); + this.serverFpsEvents = []; + this.serverFpsFrames.clear(); + this.weatherEvents = []; + this.zeusEntities = new Map(); + this.zeusRemoteControl = []; + this.zeusCameras = new Map(); + this.zeusPings = []; + this.zeusModules = []; + this.explosions = []; + this.transmissions = []; + this.explosionsSorted = true; + this.transmissionsSorted = true; } } diff --git a/ui/src/playback/eventSides.ts b/ui/src/playback/eventSides.ts new file mode 100644 index 000000000..4845641d4 --- /dev/null +++ b/ui/src/playback/eventSides.ts @@ -0,0 +1,50 @@ +import type { ChunkData, EventDef, Manifest, Side } from "../data/types"; + +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; + private readonly chunkSize: number; + + 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" && 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) ?? []; + events.push(event); + this.byChunk.set(index, events); + } + } + + 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 = this.sides.get(event.frameNum) ?? new Map(); + 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; + atFrame.set(id, side ?? entity.side); + } + this.sides.set(event.frameNum, atFrame); + } + this.sampled.add(index); + return true; + } +} diff --git a/ui/src/playback/events/hitKilledEvent.ts b/ui/src/playback/events/hitKilledEvent.ts index 10b82a6dd..83acb4043 100644 --- a/ui/src/playback/events/hitKilledEvent.ts +++ b/ui/src/playback/events/hitKilledEvent.ts @@ -38,9 +38,10 @@ export class HitKilledEvent extends GameEvent { /** * True if this is a friendly-fire (team kill / team hit) event: - * causer and victim share a side, the event is not a suicide, and - * the victim is a unit (not a vehicle). Requires resolveReferences() - * to have populated victimSide, causerSide, and victimIsVehicle. + * causer and victim share a side at this event's frame, the event is + * not a suicide, and the victim is a unit (not a vehicle). Requires + * resolveReferences() to have populated victimSide, causerSide, and + * victimIsVehicle from per-frame state when available. */ isFriendlyFire(): boolean { return ( 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/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); + } +} 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/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); + } +} diff --git a/ui/src/playback/events/zeusEvents.ts b/ui/src/playback/events/zeusEvents.ts new file mode 100644 index 000000000..b77684370 --- /dev/null +++ b/ui/src/playback/events/zeusEvents.ts @@ -0,0 +1,86 @@ +import { GameEvent } from "./gameEvent"; +import type { + ZeusCameraPayload, + ZeusEntityPayload, + ZeusModulePayload, + ZeusPingPayload, + ZeusRemoteControlPayload, +} from "../../data/types"; + +export class ZeusEntityEvent extends GameEvent { + constructor( + frameNum: number, + id: number, + public readonly payload: ZeusEntityPayload, + ) { + super(frameNum, "zeusEntity", id); + } + + get curatorId(): number { + return this.payload.curatorId; + } +} + +export class ZeusRemoteControlEvent extends GameEvent { + constructor( + frameNum: number, + id: number, + public readonly payload: ZeusRemoteControlPayload, + ) { + super(frameNum, "zeusRemoteControl", id); + } +} + +export class ZeusCameraEvent extends GameEvent { + constructor( + frameNum: number, + id: number, + public readonly payload: ZeusCameraPayload, + ) { + super(frameNum, "zeusCamera", id); + } +} + +/** + * A player pinged Zeus. Unlike the other Zeus events this one is also a + * human-readable mission event, so it stays in the normal event log. + */ +export class ZeusPingEvent extends GameEvent { + constructor( + frameNum: number, + id: number, + public readonly payload: ZeusPingPayload, + ) { + super(frameNum, "zeusPing", id); + } + + get unitId(): number { + return this.payload.unitId; + } + + get position(): [number, number] { + 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/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"; +} 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__/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/__tests__/tfarCoverage.test.ts b/ui/src/playback/radioRange/__tests__/tfarCoverage.test.ts new file mode 100644 index 000000000..e151322e9 --- /dev/null +++ b/ui/src/playback/radioRange/__tests__/tfarCoverage.test.ts @@ -0,0 +1,185 @@ +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.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], { + 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..4b5b07564 --- /dev/null +++ b/ui/src/playback/radioRange/demGrid.ts @@ -0,0 +1,415 @@ +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. + */ +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: Uint8Array = 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 { + const tiles: { x: number; y: number; bitmap: ImageBitmap }[] = []; + 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 / 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); + // 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, { 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 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, 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 r = 0; r < rows; r++) { + 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; + } + } + return { + cols, + rows, + originX: 0, + originY: 0, + cellSize, + worldSize, + 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, ((lng + 180) / 360) * n)); +} + +function latToTile(lat: number, n: number): number { + const rad = (lat * Math.PI) / 180; + const y = ((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n; + return Math.max(0, Math.min(n, y)); +} diff --git a/ui/src/playback/radioRange/tfarCoverage.ts b/ui/src/playback/radioRange/tfarCoverage.ts new file mode 100644 index 000000000..7893f27ff --- /dev/null +++ b/ui/src/playback/radioRange/tfarCoverage.ts @@ -0,0 +1,164 @@ +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 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); + 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 = maxDistance; + 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/playback/types.ts b/ui/src/playback/types.ts index d80d55961..a52a23165 100644 --- a/ui/src/playback/types.ts +++ b/ui/src/playback/types.ts @@ -14,4 +14,10 @@ export interface EntitySnapshot { isInVehicle: boolean; /** If the unit fired this frame, all projectile target positions. */ firedTargets?: ArmaCoord[]; + /** Arma getObjectFOV (tan of half-angle) when this is a flying Zeus camera. */ + fov?: number; + /** Look-vector Z, used to shorten the FOV cone when looking down. */ + pitch?: number; + /** Unit being remote-controlled by this Zeus entity, if any. */ + controllingUnitId?: number; } diff --git a/ui/src/playback/zeus.ts b/ui/src/playback/zeus.ts new file mode 100644 index 000000000..3988bf0ec --- /dev/null +++ b/ui/src/playback/zeus.ts @@ -0,0 +1,298 @@ +import type { Side, ZeusModulePayload, ZeusPingPayload } from "../data/types"; + +/** + * Frames after a remote-control stop during which that unit's kills still + * count for Zeus. Capture is typically 1 s, and `eh_killed` may wait on ACE + * `lastDamageSource` after occupancy has already cleared. + */ +export const ZEUS_RC_KILL_GRACE_FRAMES = 30; + +/** + * How long a Zeus ping 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_PING_MAP_SECONDS = 180; + +/** + * The ping map lifetime in frames. Derived, never hardcoded: captureDelayMs + * comes from the manifest and is not always 1000. + */ +export function zeusPingLifetimeFrames(captureDelayMs: number): number { + if (!Number.isFinite(captureDelayMs) || captureDelayMs <= 0) { + return ZEUS_PING_MAP_SECONDS; + } + return Math.max(1, Math.round((ZEUS_PING_MAP_SECONDS * 1000) / captureDelayMs)); +} + +/** + * Metres within which two pings from the same player collapse into one map + * icon carrying both timestamps. A player who pings twice without moving would + * otherwise draw two icons and two labels on top of each other. + */ +export const ZEUS_PING_GROUP_RADIUS_M = 25; + +/** Frames over which a ping fades out at the end of its map lifetime. */ +export const ZEUS_PING_FADE_FRAMES = 30; + +/** The minimal shape `groupZeusPings` needs; satisfied by ZeusPingEvent. */ +export interface ZeusPingLike { + frameNum: number; + payload: ZeusPingPayload; +} + +export interface ZeusPingGroup { + /** Stable identity across frames, so the overlay can reuse marker handles. */ + key: string; + unitId: number; + name: string; + side: string; + position: [number, number]; + /** Frames of every ping in the group, newest first. */ + frames: number[]; + /** Newest ping in the group; drives the fade. */ + latestFrame: number; +} + +/** + * Collapse co-located pings from the same player into one group. + * + * Greedy clustering against the group's anchor, not a quantised grid: two + * pings 9 m apart must always group, and a grid would split them whenever they + * straddle a cell boundary. Groups come back oldest-first by their newest ping. + * + * The key is anchored on the group's oldest ping so it stays stable frame to + * frame while the group's newest ping and position keep changing. + */ +export function groupZeusPings(pings: ZeusPingLike[]): ZeusPingGroup[] { + const groups: ZeusPingGroup[] = []; + const anchors: Array<[number, number]> = []; + + for (const ping of pings) { + const { unitId, x, y, name, side } = ping.payload; + + let matched = false; + for (let i = 0; i < groups.length; i++) { + const group = groups[i]; + if (group.unitId !== unitId) continue; + const [ax, ay] = anchors[i]; + if (Math.hypot(x - ax, y - ay) > ZEUS_PING_GROUP_RADIUS_M) continue; + + group.frames.push(ping.frameNum); + if (ping.frameNum > group.latestFrame) { + group.latestFrame = ping.frameNum; + group.position = [x, y]; + } + matched = true; + break; + } + if (matched) continue; + + groups.push({ + key: `${unitId}:${ping.frameNum}`, + unitId, + name, + side, + position: [x, y], + frames: [ping.frameNum], + latestFrame: ping.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); +} + +/** + * Opacity for a ping group at `frame`. Solid for most of its life, then a + * short ramp so it does not vanish abruptly at the 180 s mark. + */ +export function zeusPingAlpha( + frame: number, + latestFrame: number, + lifetimeFrames: number, +): number { + const age = frame - latestFrame; + if (age < 0 || age >= lifetimeFrames) return 0; + const fade = Math.min(ZEUS_PING_FADE_FRAMES, lifetimeFrames); + const remaining = lifetimeFrames - age; + if (remaining >= fade) return 1; + 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: 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; + return (2 * Math.atan(fov) * 180) / Math.PI; +} + +export interface ZeusEntityInfo { + curatorId: number; + name: string; + playerUid: string; + bodyUnitId: number; + startFrame: number; +} + +export interface ZeusCameraSample { + x: number; + y: number; + dir: number; + fov: number; + pitch: number; +} + +export interface ZeusFrameState { + curatorId: number; + name: string; + controllingUnitId: number | null; + controllingName: string | null; + camera: ZeusCameraSample | null; + side: Side; +} + +export function isZeusSide(side: Side | string | null | undefined): boolean { + return side === "VIRTUAL"; +} + +/** Recorded getUnitType / roleDescription → label shown next to a possessed unit. */ +const UNIT_TYPE_LABELS: Record = { + Man: "Rifleman", + MG: "Autorifleman", + GL: "Grenadier", + AT: "AT", + AA: "AA", + Sniper: "Marksman", + Medic: "Medic", + Engineer: "Engineer", + ExplosiveSpecialist: "Explosive Specialist", + Leader: "Leader", + Officer: "Officer", +}; + +/** + * Readable unit type for occupancy labels. + * Empty when the recording has no role / getUnitType for that unit. + */ +export function formatUnitTypeLabel(role: string | null | undefined): string { + const raw = (role ?? "").trim(); + if (!raw) return ""; + const slot = raw.split("@")[0]?.trim() ?? ""; + if (!slot) return ""; + return UNIT_TYPE_LABELS[slot] ?? slot; +} + +/** Map / list label while Zeus is remote-controlling a unit. */ +export function formatZeusOccupancyName( + zeusName: string, + hostName: string, + hostType?: string | null, +): string { + const type = formatUnitTypeLabel(hostType); + if (type) return `${zeusName} controlling ${hostName} (${type})`; + return `${zeusName} controlling ${hostName}`; +} diff --git a/ui/src/renderers/leaflet/__tests__/canvasIcons.test.ts b/ui/src/renderers/leaflet/__tests__/canvasIcons.test.ts index 34849bca3..a6693441b 100644 --- a/ui/src/renderers/leaflet/__tests__/canvasIcons.test.ts +++ b/ui/src/renderers/leaflet/__tests__/canvasIcons.test.ts @@ -241,16 +241,17 @@ describe("CanvasIconCache.preloadAll", () => { const cache = new CanvasIconCache(); await cache.preloadAll(); - // 12 types x 9 variants = 108 URLs attempted + // 13 types x 10 variants = 130 URLs attempted const expectedTypes = [ "man", "ship", "parachute", "heli", "plane", "truck", - "car", "apc", "tank", "staticMortar", "staticWeapon", "unknown", + "car", "apc", "tank", "staticMortar", "staticWeapon", "zeus", "unknown", ]; const expectedVariants = [ - "blufor", "opfor", "ind", "civ", "logic", + "blufor", "opfor", "ind", "civ", "virtual", "logic", "unknown", "dead", "hit", "unconscious", ]; expect(loadedUrls.length).toBe(expectedTypes.length * expectedVariants.length); + expect(loadedUrls.some((u) => u.includes("markers/zeus/virtual.svg"))).toBe(true); // Verify a sample of URLs contain expected path fragments expect(loadedUrls.some((u) => u.includes("markers/man/blufor.svg"))).toBe(true); 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__/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/__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/__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/canvasIcons.ts b/ui/src/renderers/leaflet/canvasIcons.ts index ffd8b0bd0..bde37ac72 100644 --- a/ui/src/renderers/leaflet/canvasIcons.ts +++ b/ui/src/renderers/leaflet/canvasIcons.ts @@ -16,6 +16,7 @@ const ICON_SIZES: Record = { tank: [28, 28], staticMortar: [20, 20], staticWeapon: [20, 20], + zeus: [22, 22], unknown: [28, 28], }; @@ -33,11 +34,12 @@ const ICON_PATHS: Record = { tank: `${basePath}images/markers/tank/`, staticMortar: `${basePath}images/markers/static-mortar/`, staticWeapon: `${basePath}images/markers/static-weapon/`, + zeus: `${basePath}images/markers/zeus/`, unknown: `${basePath}images/markers/unknown/`, }; const ICON_VARIANTS = [ - "blufor", "opfor", "ind", "civ", "logic", + "blufor", "opfor", "ind", "civ", "virtual", "logic", "unknown", "dead", "hit", "unconscious", ] as const; diff --git a/ui/src/renderers/leaflet/entityCanvasLayer.ts b/ui/src/renderers/leaflet/entityCanvasLayer.ts index d28fdb26e..3b3e0438b 100644 --- a/ui/src/renderers/leaflet/entityCanvasLayer.ts +++ b/ui/src/renderers/leaflet/entityCanvasLayer.ts @@ -3,6 +3,7 @@ import type { ArmaCoord } from "../../utils/coordinates"; import type { AliveState, Side } from "../../data/types"; import type { EntityMarkerOpts, EntityMarkerState, CrewInfo } from "../renderer.types"; import { closestEquivalentAngle, SKIP_ANIMATION_DISTANCE } from "../../utils/math"; +import { armaFovToDegrees } from "../../playback/zeus"; import { CanvasIconCache, resolveVariant } from "./canvasIcons"; import { getGridLevels, computeGridLines, formatCoordLabel } from "./gridUtils"; @@ -12,6 +13,7 @@ const SIDE_COLORS: Record = { EAST: "#ff0000", GUER: "#00cc00", CIV: "#c900ff", + VIRTUAL: "#c9a227", }; /** Duration of the hit flash color tint in milliseconds. */ @@ -60,6 +62,9 @@ interface CanvasEntity { // Cached label measurement (invalidated on entity update or font size change) cachedLabelMaxW: number; cachedLabelFontSize: number; + + fov?: number; + pitch?: number; } interface CanvasProjectile { @@ -244,6 +249,8 @@ export class EntityCanvasLayer { hitStartTime: 0, cachedLabelMaxW: 0, cachedLabelFontSize: 0, + fov: opts.fov, + pitch: opts.pitch, }); } @@ -295,6 +302,8 @@ export class EntityCanvasLayer { e.isInVehicle = state.isInVehicle; e.alive = state.alive; e.cachedLabelFontSize = 0; // invalidate label measurement cache + e.fov = state.fov; + e.pitch = state.pitch; } removeEntity(id: number): void { @@ -819,6 +828,40 @@ export class EntityCanvasLayer { continue; } + if (e.fov && e.fov > 0 && e.iconType === "zeus") { + const t = e.interpProgress; + const armaX = e.prevX + (e.targetX - e.prevX) * t; + const armaY = e.prevY + (e.targetY - e.prevY) * t; + const armaDir = e.prevDir + (e.targetDir - e.prevDir) * t; + const fovDeg = armaFovToDegrees(e.fov); + const range = 250 * Math.max(0.2, 1 - Math.abs(e.pitch ?? 0)); + const half = (fovDeg / 2) * (Math.PI / 180); + const heading = (armaDir * Math.PI) / 180; + const leftX = armaX + Math.sin(heading - half) * range; + const leftY = armaY + Math.cos(heading - half) * range; + const rightX = armaX + Math.sin(heading + half) * range; + const rightY = armaY + Math.cos(heading + half) * range; + const lpx = this.projAx * leftX + this.projBx * leftY + this.projCx; + const lpy = this.projAy * leftX + this.projBy * leftY + this.projCy; + const rpx = this.projAx * rightX + this.projBx * rightY + this.projCx; + const rpy = this.projAy * rightX + this.projBy * rightY + this.projCy; + const fill = e.side ? SIDE_COLORS[e.side] : "#c9a227"; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.beginPath(); + ctx.moveTo(px, py); + ctx.lineTo(lpx, lpy); + ctx.lineTo(rpx, rpy); + ctx.closePath(); + ctx.globalAlpha = 0.22; + ctx.fillStyle = fill; + ctx.fill(); + ctx.globalAlpha = 0.55; + ctx.strokeStyle = fill; + ctx.lineWidth = 1.25 * cs; + ctx.stroke(); + ctx.globalAlpha = 1; + } + // Draw icon (rotated, counter-scaled during zoom) const img = iconCache.get(e.iconType, e.iconVariant); if (img) { 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/leafletIcons.ts b/ui/src/renderers/leaflet/leafletIcons.ts index 6eb1cf9c3..ad2f299b1 100644 --- a/ui/src/renderers/leaflet/leafletIcons.ts +++ b/ui/src/renderers/leaflet/leafletIcons.ts @@ -31,6 +31,7 @@ const ICON_SIZES: Record = { tank: [28, 28], staticMortar: [20, 20], staticWeapon: [20, 20], + zeus: [22, 22], unknown: [28, 28], }; @@ -48,6 +49,7 @@ const ICON_PATHS: Record = { tank: `${base}images/markers/tank/`, staticMortar: `${base}images/markers/static-mortar/`, staticWeapon: `${base}images/markers/static-weapon/`, + zeus: `${base}images/markers/zeus/`, unknown: `${base}images/markers/unknown/`, }; diff --git a/ui/src/renderers/leaflet/leafletRenderer.ts b/ui/src/renderers/leaflet/leafletRenderer.ts index 07a3fbbb9..c4f46dfee 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 { @@ -215,7 +216,9 @@ export class LeafletRenderer implements MapRenderer { this.useMapLibreMode = Boolean(world.maplibre); console.debug(`[LeafletRenderer] init: mode=${this.useMapLibreMode ? "maplibre" : "legacy"}, maplibre=${world.maplibre}, maxZoom=${world.maxZoom}, tileBaseUrl=${world.tileBaseUrl}`); - const maxZoom = this.maxNativeZoom + 2; + // Allow zooming past the deepest tile level. Leaflet upscales the last + // native tile, so this only costs sharpness — +4 gives 16x over native. + const maxZoom = this.maxNativeZoom + 4; // Grid layer (created early for overlay control ordering; not added to map) this.gridLayer = createGridLayer({ @@ -231,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()) @@ -255,6 +258,7 @@ export class LeafletRenderer implements MapRenderer { const hideThreshold = this.useMapLibreMode ? 14 : 4; this.hideMarkerPopups = this.map.getZoom() <= hideThreshold; this.refreshPopupVisibility(); + this.refreshBriefingLabelVisibility(); this.fireEvent("zoom", this.map.getZoom()); }); this.map.on("dragstart", () => { @@ -271,7 +275,9 @@ export class LeafletRenderer implements MapRenderer { this.map = L.map(container, { center: [worldSizeDeg / 2, worldSizeDeg / 2], zoom: 12, - maxZoom: 20, + // 22 is MapLibre GL's own max zoom; going past it would desync the + // vector basemap from the Leaflet overlay panes. + maxZoom: 22, minZoom: 10, zoomControl: false, keyboard: false, @@ -890,11 +896,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,14 +926,16 @@ 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). // Offset to the right of the marker position, matching Arma 3 in-game rendering. layer = L.marker([0, 0], { icon: L.divIcon({ - className: "marker-text-label", + className: def.textClass + ? `marker-text-label ${def.textClass}` + : "marker-text-label", html: `${def.text}`, iconSize: [0, 0], iconAnchor: [-30, 0], @@ -978,14 +987,14 @@ export class LeafletRenderer implements MapRenderer { layer instanceof L.Marker && (layerKey === "projectileMarkers" ? this._projectileLabels() - : this._markerDisplayMode() === "all"); + : this.briefingLabelsVisible()); if (showLabel) layer.openPopup(); - // Hide text labels when in "noLabels" mode - if (def.type.includes("Empty") && def.text && this._markerDisplayMode() !== "all") { + // Hide text labels when in "noLabels" mode or below the label zoom + if (def.type.includes("Empty") && def.text && !this.briefingLabelsVisible()) { 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 +1059,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 +1101,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( @@ -1301,18 +1318,37 @@ export class LeafletRenderer implements MapRenderer { if (!this.map.hasLayer(group)) { group.addTo(this.map); } - // Toggle popups and text labels on briefing markers + this.refreshBriefingLabelVisibility(); + } + } + + /** + * Whether briefing-marker labels (trench captions, marker text, sector + * names) should be drawn right now. They follow markerDisplayMode and the + * same zoom threshold as entity name popups: zoomed out, a map full of + * captions hides the map itself, so labels only appear once player names do. + */ + private briefingLabelsVisible(): boolean { + return this._markerDisplayMode() === "all" && !this.hideMarkerPopups; + } + + /** Refresh briefing and system labels; projectile labels have their own setting. */ + private refreshBriefingLabelVisibility(): void { + const visible = this.briefingLabelsVisible(); + + 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 = mode === "all" ? "" : "none"; + if (el) el.style.display = visible ? "" : "none"; return; } // ICON markers with popups — toggle popup if (!layer.getPopup()) return; - if (mode === "all") { + if (visible) { layer.openPopup(); } else { layer.closePopup(); @@ -1328,16 +1364,16 @@ export class LeafletRenderer implements MapRenderer { */ private reopenBriefingMarkerPopups(group: L.LayerGroup): void { const isBriefing = group === this.layers.briefingMarkers; - const mode = this._markerDisplayMode(); + const briefingVisible = this.briefingLabelsVisible(); group.eachLayer((layer) => { if (!(layer instanceof L.Marker)) return; // Restore text label visibility when briefing group is re-added if (isBriefing && this.isTextLabelMarker(layer)) { const el = layer.getElement?.(); - if (el) el.style.display = mode === "all" ? "" : "none"; + if (el) el.style.display = briefingVisible ? "" : "none"; return; } - if (isBriefing && mode !== "all") return; + if (isBriefing && !briefingVisible) return; if (layer.getPopup()) { layer.openPopup(); } @@ -1347,7 +1383,9 @@ export class LeafletRenderer implements MapRenderer { /** Check whether a marker is a text-only label (e.g. sector name). */ private isTextLabelMarker(marker: L.Marker): boolean { const icon = marker.options.icon; - return icon instanceof L.DivIcon && icon.options.className === "marker-text-label"; + if (!(icon instanceof L.DivIcon)) return false; + const className = icon.options.className ?? ""; + return className.split(" ")[0] === "marker-text-label"; } /** diff --git a/ui/src/renderers/leaflet/scaleBar.ts b/ui/src/renderers/leaflet/scaleBar.ts new file mode 100644 index 000000000..216550088 --- /dev/null +++ b/ui/src/renderers/leaflet/scaleBar.ts @@ -0,0 +1,66 @@ +/** + * 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. + */ + +import type { ArmaCoord } from "../../utils/coordinates"; + +/** 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: ArmaCoord, + b: ArmaCoord, +): number { + return Math.hypot(b[0] - a[0], b[1] - a[1]); +} diff --git a/ui/src/renderers/renderer.types.ts b/ui/src/renderers/renderer.types.ts index b0075799e..977336931 100644 --- a/ui/src/renderers/renderer.types.ts +++ b/ui/src/renderers/renderer.types.ts @@ -39,6 +39,8 @@ export interface EntityMarkerOpts { name: string; isPlayer: boolean; crew?: CrewInfo; + fov?: number; + pitch?: number; } export interface EntityMarkerState { @@ -53,20 +55,31 @@ export interface EntityMarkerState { /** When true, show the "hit" flash icon instead of the normal side icon. */ hit?: boolean; crew?: CrewInfo; + /** Arma getObjectFOV when drawing a Zeus camera cone. */ + fov?: number; + pitch?: number; } // --------------- 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"; + /** + * Extra CSS class for a text-only ("Empty") label, appended to + * `marker-text-label`. Lets one kind of label restyle itself (size, spacing) + * without touching sector names. + */ + textClass?: string; } export interface BriefingMarkerState { diff --git a/ui/src/styles/__tests__/styles.test.ts b/ui/src/styles/__tests__/styles.test.ts index 53ce40ea6..f2d6e4844 100644 --- a/ui/src/styles/__tests__/styles.test.ts +++ b/ui/src/styles/__tests__/styles.test.ts @@ -43,6 +43,7 @@ describe("CSS style files", () => { expect(css).toContain("--side-opfor: #ff0000"); expect(css).toContain("--side-ind: #00cc00"); expect(css).toContain("--side-civ: #c900ff"); + expect(css).toContain("--side-virtual: #c9a227"); }); it("defines dark side color custom properties", () => { @@ -50,6 +51,7 @@ describe("CSS style files", () => { expect(css).toContain("--side-opfor-dark: #800000"); expect(css).toContain("--side-ind-dark: #007F00"); expect(css).toContain("--side-civ-dark: #650080"); + expect(css).toContain("--side-virtual-dark: #8A7018"); }); it("defines state color custom properties", () => { @@ -84,6 +86,7 @@ describe("CSS style files", () => { it("contains side colour classes using custom properties", () => { expect(css).toContain(".blufor"); expect(css).toMatch(/\.blufor\s*\{[^}]*var\(--side-blufor\)/); + expect(css).toMatch(/\.virtual\s*\{[^}]*var\(--side-virtual\)/); expect(css).toContain(".opfor"); expect(css).toMatch(/\.opfor\s*\{[^}]*var\(--side-opfor\)/); @@ -154,6 +157,21 @@ describe("CSS style files", () => { expect(content.length).toBeGreaterThan(0); }); } + + it("SidePanel side tabs wrap so VIRTUAL stays visible with 3+ factions", () => { + const css = readFileSync(resolve(componentsDir, "SidePanel.module.css"), "utf-8"); + expect(css).toMatch(/\.sideTabs\s*\{[^}]*flex-wrap:\s*wrap/); + }); + }); + + describe("RecordingSelector.module.css", () => { + const selectorDir = resolve(__dirname, "..", "..", "pages", "recording-selector"); + const css = readFileSync(resolve(selectorDir, "RecordingSelector.module.css"), "utf-8"); + + it("lets the detail sidebar scroll so VIRTUAL is not clipped", () => { + expect(css).toMatch(/\.sidebarContent\s*\{[^}]*min-height:\s*0/); + expect(css).toMatch(/\.sidebarContent\s*\{[^}]*overflow-y:\s*auto/); + }); }); describe("entities.css", () => { @@ -168,6 +186,7 @@ describe("CSS style files", () => { expect(css).toMatch(/\.side-opfor\s*\{[^}]*var\(--side-opfor-dark\)/); expect(css).toMatch(/\.side-ind\s*\{[^}]*var\(--side-ind-dark\)/); expect(css).toMatch(/\.side-civ\s*\{[^}]*var\(--side-civ-dark\)/); + expect(css).toMatch(/\.side-virtual\s*\{[^}]*var\(--side-virtual-dark\)/); }); it("contains side background colour utilities", () => { @@ -175,6 +194,7 @@ describe("CSS style files", () => { expect(css).toContain(".side-bg-opfor"); expect(css).toContain(".side-bg-ind"); expect(css).toContain(".side-bg-civ"); + expect(css).toContain(".side-bg-virtual"); }); it("contains dead and hit state colours using custom properties", () => { diff --git a/ui/src/styles/entities.css b/ui/src/styles/entities.css index 0237b3145..6d41495bb 100644 --- a/ui/src/styles/entities.css +++ b/ui/src/styles/entities.css @@ -8,11 +8,13 @@ .side-opfor { color: var(--side-opfor-dark); } .side-ind { color: var(--side-ind-dark); } .side-civ { color: var(--side-civ-dark); } +.side-virtual { color: var(--side-virtual-dark); } .side-bg-blufor { background-color: var(--side-blufor-dark); } .side-bg-opfor { background-color: var(--side-opfor-dark); } .side-bg-ind { background-color: var(--side-ind-dark); } .side-bg-civ { background-color: var(--side-civ-dark); } +.side-bg-virtual { background-color: var(--side-virtual-dark); } /* Dead / hit state colours (for markers) */ .side-dead { color: var(--color-dead); } diff --git a/ui/src/styles/global.css b/ui/src/styles/global.css index 9e653a2e9..5eec56e04 100644 --- a/ui/src/styles/global.css +++ b/ui/src/styles/global.css @@ -21,6 +21,10 @@ color: var(--side-civ); } +.virtual { + color: var(--side-virtual); +} + /* ---------- Text weights ---------- */ .bold { diff --git a/ui/src/styles/leaflet.css b/ui/src/styles/leaflet.css index 1ccf2b22b..78d233fa5 100644 --- a/ui/src/styles/leaflet.css +++ b/ui/src/styles/leaflet.css @@ -58,6 +58,21 @@ transform: translateY(-50%); } +/* + * Zeus ping labels: several lines sit next to one small icon, so they use a + * smaller type than sector names and a tight line height instead of the + * default block spacing. + */ +.marker-text-label.zeus-ping-label span { + font-size: 11px; + 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; diff --git a/ui/src/styles/variables.css b/ui/src/styles/variables.css index 66ac79d3b..b3180f5a7 100644 --- a/ui/src/styles/variables.css +++ b/ui/src/styles/variables.css @@ -50,8 +50,10 @@ /* ── Side colors: map markers ── */ --side-blufor: #00a8ff; --side-opfor: #ff0000; --side-ind: #00cc00; --side-civ: #c900ff; + --side-virtual: #c9a227; --side-blufor-dark: #004D99; --side-opfor-dark: #800000; --side-ind-dark: #007F00; --side-civ-dark: #650080; + --side-virtual-dark: #8A7018; --color-dead: #333333; --color-hit: #FF8800; /* ── Spacing scale ── */ @@ -80,5 +82,5 @@ /* ── Playback layout ── */ --pb-top-height: 48px; --pb-bottom-height: 94px; - --pb-panel-width: 370px; + --pb-panel-width: 420px; }