Skip to main content

vimp_engine_core/client/
game.rs

1//! Граница клиентского generic-каркаса ↔ игровой предикт (Этап 4b плана
2//! распила, PLAN.md §3.6/§4b). `ClientState<G>` (этот модуль) владеет сетевым
3//! буфером (`Interpolator`), очередью событийных кадров и hot-буфером
4//! рендер-тика; конкретная игра (`TanksClient` и т.п.) реализует
5//! `GameClientDef` — client-side prediction своего актора, визуальный
6//! спавн эффектов, панель. Форма трейта валидирована фикстурой
7//! (`tests` в этом модуле, `TestClient`) до миграции танков — см.
8//! PLAN_4_details.md.
9//!
10//! Камера и predicted-хвост hot-буфера разделены: движок пишет камеру как
11//! `[f32; 2]` (общий для любой игры смысл — «куда смотреть»), а форму
12//! хвоста (набор полей актора) собирает игра — движок дописывает его в
13//! hot-буфер как непрозрачный `Vec<f32>`, не зная раскладки (см.
14//! `RenderOverlay`).
15
16use serde_json::{Map, Value, json};
17
18use super::interpolator::{FrameData, InterpolatedGame, Interpolator};
19use super::unpack::{self, DecodedSnapshot, UnpackError};
20use crate::config::{BlockKind, EngineClientConfig, FieldValue, PLAYER_STATE_LEN, SnapshotConfig};
21
22/// Predicted-запись рендер-тика: камера (общий смысл) + непрозрачный
23/// хвост hot-буфера (раскладку полей актора знает только игра).
24pub struct RenderOverlay {
25    pub camera: [f32; 2],
26    pub tail: Vec<f32>,
27}
28
29/// Игровая половина клиентского ядра — зеркало `crate::sim::GameSim<G>` на
30/// клиентской стороне. `ClientState<G>` (ниже) зовёт эти методы в фиксированных
31/// точках рендер-тика; `Self` хранит всё нужное для предикта (модели, оружие,
32/// историю ввода) само — `ClientState<G>` игровых конфигов не хранит.
33pub trait GameClientDef: Sized {
34    type Config: serde::de::DeserializeOwned;
35
36    fn new(cfg: &Self::Config, engine_cfg: &EngineClientConfig) -> Self;
37
38    /// Reconciliation по player-блоку кадра (авторитетное состояние своего
39    /// актора). Раскладка `[f32; PLAYER_STATE_LEN]` — движковая (общая с
40    /// серверным `GameSim::prediction_state`), поэтому не параметризована
41    /// трейтом отдельно.
42    fn on_server_state(
43        &mut self,
44        state: [f32; PLAYER_STATE_LEN],
45        centering: bool,
46        server_time: f64,
47        offset: f64,
48        local_now: f64,
49    );
50
51    /// Оценка задержки (для RTT-компенсации визуальных эффектов).
52    fn set_server_offset(&mut self, offset: Option<f64>);
53
54    /// Шаг предикта до текущего рендер-времени.
55    fn update(&mut self, local_now: f64);
56
57    /// Отслеживание своего актора в пересечённом кадре (дискретные поля,
58    /// freeze при уничтожении, reset по forceReset камеры). `my_game_id` —
59    /// текущий id своего актора (из последнего player-блока).
60    fn track_frame(&mut self, my_game_id: Option<u32>, frame: &FrameData);
61
62    /// Фильтр собственных событий в JSON-форме кадра (подавление дублей
63    /// локально предсказанных эффектов).
64    fn filter_frame_game(&mut self, game: &mut Map<String, Value>, my_game_id: Option<u32>, local_now: f64);
65
66    /// Обновление игрового мира для дальнейшего локального спавна эффектов
67    /// (raycast и т.п.) — по каждому пересечённому кадру.
68    fn update_world(&mut self, snapshot: &DecodedSnapshot);
69
70    /// То же — по интерполированному состоянию (между кадрами).
71    fn update_world_interpolated(&mut self, game: &InterpolatedGame);
72
73    /// Полная predicted-запись рендер-тика; `None` — предикт не готов (нет
74    /// своего актора/модели/меты), тогда камера рендер-тика берётся из
75    /// интерполяции, а флаг HOT_HAS_PREDICTED не выставляется.
76    fn render_overlay(&self, my_game_id: Option<u32>) -> Option<RenderOverlay>;
77
78    fn apply_input(&mut self, action: &str, key_name: &str, local_now: f64);
79    fn set_model(&mut self, model_name: &str);
80    fn set_active(&mut self, active: bool);
81    fn set_map(&mut self, map_json: &str) -> Result<(), String>;
82    fn sync_panel(&mut self, items: &[String]);
83    fn reset(&mut self);
84
85    /// Циклический выбор активного предмета/режима актора (нейтральный
86    /// аналог смены оружия — конкретный смысл определяет игра).
87    fn cycle_item(&mut self, back: bool);
88
89    /// Локальное визуальное действие актора (выстрел и т.п.; гейты внутри —
90    /// предикт активен, свой актор жив). JSON спавна либо `None`.
91    fn try_action(&mut self, my_game_id: Option<u32>, local_now: f64) -> Option<String>;
92}
93
94// приводит любое поле строки к f32 для плоского hot-буфера
95fn field_as_f32(value: FieldValue) -> f32 {
96    match value {
97        FieldValue::F32(v) => v,
98        FieldValue::U8(v) => v as f32,
99        FieldValue::U16(v) => v as f32,
100        FieldValue::U32(v) => v as f32,
101    }
102}
103
104// блоки интерполированного кадра нужной формы (kind), с id ключа реестра
105// snapshot.keys (записывается в hot-буфер как keyId).
106fn blocks_of_kind<'a>(
107    snapshot_cfg: &'a SnapshotConfig,
108    game: &'a InterpolatedGame,
109    kind: BlockKind,
110) -> impl Iterator<Item = (u8, &'a Vec<super::interpolator::InterpolatedRow>)> {
111    game.blocks.iter().filter_map(move |(key, rows)| {
112        let schema = snapshot_cfg.keys.get(key)?;
113
114        (schema.kind == kind).then_some((schema.id, rows))
115    })
116}
117
118/// Generic оркестрация клиентского ядра поверх игровой `G: GameClientDef`:
119/// сетевой буфер (interpolator), очередь событийных кадров, hot-буфер
120/// рендер-тика. Байтовая раскладка hot-буфера (флаги/камера/N танков×поля/M
121/// динамики×поля/predicted-хвост) не меняется — см. `write_hot`.
122pub struct ClientState<G: GameClientDef> {
123    cfg: EngineClientConfig,
124    interpolator: Interpolator,
125    game: G,
126
127    // id своего актора из последнего player-блока
128    my_game_id: Option<u32>,
129
130    // очередь событийных кадров на take_frames (в форме applyShot)
131    frames_out: Vec<Value>,
132
133    // переиспользуемый плоский буфер рендер-тика
134    hot: Vec<f32>,
135}
136
137impl<G: GameClientDef> ClientState<G> {
138    pub fn new(cfg: EngineClientConfig, game_cfg: &G::Config) -> Self {
139        let interpolator = Interpolator::new(&cfg.interpolation, cfg.snapshot.clone());
140        let game = G::new(game_cfg, &cfg);
141
142        Self {
143            cfg,
144            interpolator,
145            game,
146            my_game_id: None,
147            frames_out: Vec::new(),
148            hot: Vec::new(),
149        }
150    }
151
152    /// Бинарный кадр из транспорта: распаковка, вставка в буфер по seq,
153    /// reconciliation предикта по player-блоку. false — кадр отброшен
154    /// (чужой порт, версия или повреждённые данные).
155    pub fn push_frame(&mut self, data: &[u8], local_now: f64) -> bool {
156        let frame = match unpack::unpack_frame(data, &self.cfg.snapshot) {
157            Ok(frame) => frame,
158            Err(UnpackError::WrongVersion | UnpackError::Truncated) => return false,
159        };
160
161        if frame.port != self.cfg.snapshot.port {
162            return false;
163        }
164
165        self.interpolator.push(
166            FrameData {
167                snapshot: frame.snapshot,
168                camera: frame.camera,
169            },
170            frame.server_time,
171            local_now,
172            frame.seq,
173        );
174
175        if let Some(player) = frame.player {
176            self.my_game_id = Some(player.game_id as u32);
177
178            // после push оффсет всегда известен
179            let offset = self.interpolator.offset().unwrap_or(0.0);
180
181            self.game.on_server_state(
182                player.state,
183                player.centering,
184                frame.server_time,
185                offset,
186                local_now,
187            );
188        }
189
190        true
191    }
192
193    pub fn my_game_id(&self) -> Option<u32> {
194        self.my_game_id
195    }
196
197    pub fn offset(&self) -> Option<f64> {
198        self.interpolator.offset()
199    }
200
201    /// Рендер-тик: выдача пересечённых кадров (фильтр дублей → JSON-очередь),
202    /// интерполяция, шаг предикта, запись hot-буфера. Возвращает длину
203    /// hot-буфера в f32-элементах.
204    pub fn sample(&mut self, local_now: f64) -> usize {
205        self.game.set_server_offset(self.interpolator.offset());
206
207        let result = self.interpolator.sample(local_now);
208
209        // событийные кадры: свой актор → фильтр дублей → очередь → мир
210        for frame in result.frames {
211            self.game.track_frame(self.my_game_id, &frame);
212
213            let mut game = unpack::snapshot_to_json(&frame.snapshot);
214
215            self.game
216                .filter_frame_game(&mut game, self.my_game_id, local_now);
217
218            self.frames_out.push(json!({
219                "game": game,
220                "camera": unpack::camera_to_json(frame.camera.as_ref()),
221            }));
222
223            self.game.update_world(&frame.snapshot);
224        }
225
226        if let Some(game) = &result.game {
227            self.game.update_world_interpolated(game);
228        }
229
230        self.game.update(local_now);
231
232        let overlay = self.game.render_overlay(self.my_game_id);
233
234        self.write_hot(result.game.as_ref(), result.camera, overlay.as_ref());
235        self.hot.len()
236    }
237
238    pub fn hot(&self) -> &[f32] {
239        &self.hot
240    }
241
242    /// Событийные кадры JSON-строкой [{game, camera}, ...]; очередь очищается.
243    pub fn take_frames(&mut self) -> String {
244        let frames = std::mem::take(&mut self.frames_out);
245
246        serde_json::to_string(&frames).unwrap_or_else(|_| "[]".to_string())
247    }
248
249    pub fn apply_input(&mut self, action: &str, key_name: &str, local_now: f64) {
250        self.game.apply_input(action, key_name, local_now);
251    }
252
253    pub fn try_action(&mut self, local_now: f64) -> Option<String> {
254        self.game.try_action(self.my_game_id, local_now)
255    }
256
257    pub fn cycle_item(&mut self, back: bool) {
258        self.game.cycle_item(back);
259    }
260
261    pub fn set_model(&mut self, model_name: &str) {
262        self.game.set_model(model_name);
263    }
264
265    /// Смена режима игрок/спектатор (KEYSET_DATA).
266    pub fn set_active(&mut self, active: bool) {
267        self.game.set_active(active);
268    }
269
270    /// Данные карты (MAP_DATA): мир raycast + сброс буфера и предикта.
271    pub fn set_map(&mut self, map_json: &str) -> Result<(), String> {
272        self.interpolator.reset();
273        self.frames_out.clear();
274        self.game.set_map(map_json)
275    }
276
277    pub fn sync_panel(&mut self, panel_json: &str) {
278        let Ok(Value::Array(items)) = serde_json::from_str(panel_json) else {
279            return;
280        };
281
282        let items: Vec<String> = items
283            .iter()
284            .map(|item| match item {
285                Value::String(s) => s.clone(),
286                other => other.to_string(),
287            })
288            .collect();
289
290        self.game.sync_panel(&items);
291    }
292
293    /// Полный сброс (порт CLEAR).
294    pub fn reset(&mut self) {
295        self.interpolator.reset();
296        self.game.reset();
297        self.frames_out.clear();
298    }
299
300    /// Чистая распаковка кадра v3 в JSON-форму unpackFrame (тесты/харнесс).
301    pub fn decode_frame(&self, data: &[u8]) -> String {
302        match unpack::unpack_frame(data, &self.cfg.snapshot) {
303            Ok(frame) => unpack::frame_to_json(&frame).to_string(),
304            Err(_) => "null".to_string(),
305        }
306    }
307
308    // плоский Float32-буфер рендер-тика:
309    // [0] flags, [1..2] камера x/y, [3] N строк hot-блоков (Indexed8), N×(2+поля),
310    // [..] M строк динамики (IndexedNoNull8), M×(2+поля), затем — непрозрачный
311    // predicted-хвост игры (см. GameClientDef::render_overlay).
312    fn write_hot(
313        &mut self,
314        game: Option<&InterpolatedGame>,
315        camera: Option<[f32; 2]>,
316        overlay: Option<&RenderOverlay>,
317    ) {
318        self.hot.clear();
319
320        let mut flags = 0u32;
321
322        if game.is_some() {
323            flags |= super::HOT_HAS_GAME;
324        }
325
326        if !self.frames_out.is_empty() {
327            flags |= super::HOT_HAS_FRAMES;
328        }
329
330        if overlay.is_some() {
331            flags |= super::HOT_HAS_PREDICTED;
332        }
333
334        // камера: предсказанная позиция либо интерполированная
335        let camera = overlay.map(|o| o.camera).or(camera);
336
337        if camera.is_some() {
338            flags |= super::HOT_HAS_CAMERA;
339        }
340
341        self.hot.push(flags as f32);
342
343        let camera = camera.unwrap_or([0.0, 0.0]);
344
345        self.hot.push(camera[0]);
346        self.hot.push(camera[1]);
347
348        let empty = InterpolatedGame::default();
349        let game = game.unwrap_or(&empty);
350
351        // строки блоков — по форме (BlockKind), не по игровой сущности:
352        // Indexed8 пишется как «танк» (keyId, gameId, поля), IndexedNoNull8
353        // — как «динамика карты» (keyId, index, поля); порядок и число
354        // полей идут напрямую из schema.fields.
355        let tank_count: usize = blocks_of_kind(&self.cfg.snapshot, game, BlockKind::Indexed8)
356            .map(|(_, rows)| rows.len())
357            .sum();
358
359        self.hot.push(tank_count as f32);
360
361        for (key_id, rows) in blocks_of_kind(&self.cfg.snapshot, game, BlockKind::Indexed8) {
362            for row in rows {
363                self.hot.push(key_id as f32);
364                self.hot.push(row.id as f32);
365
366                for field in &row.fields {
367                    self.hot.push(field_as_f32(*field));
368                }
369            }
370        }
371
372        let dynamic_count: usize =
373            blocks_of_kind(&self.cfg.snapshot, game, BlockKind::IndexedNoNull8)
374                .map(|(_, rows)| rows.len())
375                .sum();
376
377        self.hot.push(dynamic_count as f32);
378
379        for (key_id, rows) in blocks_of_kind(&self.cfg.snapshot, game, BlockKind::IndexedNoNull8) {
380            for row in rows {
381                self.hot.push(key_id as f32);
382                self.hot.push(row.id as f32);
383
384                for field in &row.fields {
385                    self.hot.push(field_as_f32(*field));
386                }
387            }
388        }
389
390        if let Some(overlay) = overlay {
391            self.hot.extend_from_slice(&overlay.tail);
392        }
393    }
394}
395
396/// Фикстурный второй клиент — валидатор формы `GameClientDef` (Этап 4b,
397/// PLAN_4_details.md): доказывает, что трейт не завязан по форме на танки,
398/// до миграции `games/tanks` на него (см. `TanksClient`). Состояние —
399/// тривиальная линейная интеграция позиции по vx/vy, без формул движения
400/// игры (мотор здесь не нужен — только форма трейта).
401#[cfg(test)]
402mod fixture {
403    use super::*;
404    use serde::Deserialize;
405
406    #[derive(Deserialize)]
407    pub struct TestConfig {}
408
409    pub struct TestClient {
410        x: f32,
411        y: f32,
412        vx: f32,
413        vy: f32,
414        active: bool,
415        alive: bool,
416        last_update: Option<f64>,
417    }
418
419    impl GameClientDef for TestClient {
420        type Config = TestConfig;
421
422        fn new(_cfg: &Self::Config, _engine_cfg: &EngineClientConfig) -> Self {
423            Self {
424                x: 0.0,
425                y: 0.0,
426                vx: 0.0,
427                vy: 0.0,
428                active: false,
429                alive: true,
430                last_update: None,
431            }
432        }
433
434        fn on_server_state(
435            &mut self,
436            state: [f32; PLAYER_STATE_LEN],
437            _centering: bool,
438            _server_time: f64,
439            _offset: f64,
440            _local_now: f64,
441        ) {
442            self.x = state[0];
443            self.y = state[1];
444            self.vx = state[3];
445            self.vy = state[4];
446        }
447
448        fn set_server_offset(&mut self, _offset: Option<f64>) {}
449
450        fn update(&mut self, local_now: f64) {
451            let dt = self
452                .last_update
453                .map(|last| (local_now - last) / 1000.0)
454                .unwrap_or(0.0) as f32;
455
456            self.x += self.vx * dt;
457            self.y += self.vy * dt;
458            self.last_update = Some(local_now);
459        }
460
461        fn track_frame(&mut self, _my_game_id: Option<u32>, _frame: &FrameData) {}
462
463        fn filter_frame_game(
464            &mut self,
465            _game: &mut Map<String, Value>,
466            _my_game_id: Option<u32>,
467            _local_now: f64,
468        ) {
469        }
470
471        fn update_world(&mut self, _snapshot: &DecodedSnapshot) {}
472
473        fn update_world_interpolated(&mut self, _game: &InterpolatedGame) {}
474
475        fn render_overlay(&self, my_game_id: Option<u32>) -> Option<RenderOverlay> {
476            let game_id = my_game_id?;
477
478            (self.active && self.alive).then(|| RenderOverlay {
479                camera: [self.x, self.y],
480                tail: vec![0.0, game_id as f32, self.x, self.y],
481            })
482        }
483
484        fn apply_input(&mut self, _action: &str, _key_name: &str, _local_now: f64) {}
485
486        fn set_model(&mut self, _model_name: &str) {}
487
488        fn set_active(&mut self, active: bool) {
489            self.active = active;
490        }
491
492        fn set_map(&mut self, _map_json: &str) -> Result<(), String> {
493            Ok(())
494        }
495
496        fn sync_panel(&mut self, _items: &[String]) {}
497
498        fn reset(&mut self) {
499            self.x = 0.0;
500            self.y = 0.0;
501            self.last_update = None;
502        }
503
504        fn cycle_item(&mut self, _back: bool) {}
505
506        fn try_action(&mut self, _my_game_id: Option<u32>, _local_now: f64) -> Option<String> {
507            None
508        }
509    }
510}
511
512#[cfg(test)]
513mod tests {
514    use super::fixture::{TestClient, TestConfig};
515    use super::*;
516    use crate::client::{HOT_HAS_CAMERA, HOT_HAS_FRAMES, HOT_HAS_GAME, HOT_HAS_PREDICTED};
517    use crate::snapshot::{Block, CameraData, PlayerBlock, SnapshotPacker};
518
519    fn config_json() -> serde_json::Value {
520        serde_json::json!({
521            "timeStepMs": 1000.0 / 120.0,
522            "snapshot": {
523                "version": 3,
524                "port": 5,
525                "keys": {
526                    "actor": { "id": 1, "kind": "indexed8", "class": "hot", "fields": [
527                        { "name": "x", "ty": "f32", "interp": "lerp" },
528                        { "name": "y", "ty": "f32", "interp": "lerp" }
529                    ] }
530                }
531            },
532            "interpolation": { "delay": 100, "maxFrameAge": 1000 }
533        })
534    }
535
536    fn engine_client_config() -> EngineClientConfig {
537        serde_json::from_value(config_json()).unwrap()
538    }
539
540    fn make_state() -> ClientState<TestClient> {
541        ClientState::new(engine_client_config(), &TestConfig {})
542    }
543
544    fn frame_bytes(server_time: f64, seq: u32, x: f32, with_player: bool) -> Vec<u8> {
545        let cfg = engine_client_config();
546        let mut packer = SnapshotPacker::new(cfg.snapshot.clone());
547
548        packer
549            .pack_body(&[(
550                "actor".to_string(),
551                Block::Indexed8(vec![(2, Some(vec![FieldValue::F32(x), FieldValue::F32(0.0)]))]),
552            )])
553            .unwrap();
554
555        let camera = CameraData {
556            x,
557            y: 0.0,
558            force_reset: false,
559            shake: None,
560        };
561        let player = PlayerBlock {
562            game_id: 2,
563            input_seq: 0,
564            state: [x, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
565            centering: false,
566        };
567
568        packer
569            .pack_frame(
570                server_time,
571                seq,
572                Some(&camera),
573                with_player.then_some(&player),
574            )
575            .to_vec()
576    }
577
578    #[test]
579    fn push_frame_and_sample_writes_hot_layout() {
580        let mut state = make_state();
581
582        state.push_frame(&frame_bytes(1000.0, 1, 10.0, false), 1000.0);
583        state.push_frame(&frame_bytes(1100.0, 2, 20.0, false), 1100.0);
584
585        // renderTime = 1150 − 100 = 1050 → alpha 0.5
586        let len = state.sample(1150.0);
587        let hot = state.hot().to_vec();
588
589        assert_eq!(len, hot.len());
590
591        let flags = hot[0] as u32;
592
593        assert!(flags & HOT_HAS_GAME != 0);
594        assert!(flags & HOT_HAS_CAMERA != 0);
595        assert!(flags & HOT_HAS_FRAMES != 0);
596        assert!(flags & HOT_HAS_PREDICTED == 0);
597
598        // один актор: keyId 1, gameId 2, x = 15 (лерп)
599        assert_eq!(hot[3], 1.0);
600        assert_eq!(hot[4], 1.0);
601        assert_eq!(hot[5], 2.0);
602        assert_eq!(hot[6], 15.0);
603
604        let frames: Vec<serde_json::Value> =
605            serde_json::from_str(&state.take_frames()).unwrap();
606
607        assert_eq!(frames.len(), 1);
608        assert_eq!(state.take_frames(), "[]");
609    }
610
611    #[test]
612    fn render_overlay_appends_opaque_tail_and_sets_flag() {
613        let mut state = make_state();
614
615        state.set_active(true);
616        state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
617
618        assert_eq!(state.my_game_id(), Some(2));
619
620        state.sample(1150.0);
621
622        let hot = state.hot().to_vec();
623        let flags = hot[0] as u32;
624
625        assert!(flags & HOT_HAS_PREDICTED != 0);
626
627        // хвост — последние 4 f32 (форма TestClient::render_overlay)
628        let tail = &hot[hot.len() - 4..];
629
630        assert_eq!(tail[1], 2.0); // gameId
631        assert_eq!(hot[1], tail[2]); // камера следует хвосту (x)
632    }
633
634    #[test]
635    fn reset_clears_predictor_and_frame_queue() {
636        let mut state = make_state();
637
638        state.set_active(true);
639        state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
640        state.sample(1150.0);
641
642        state.reset();
643
644        assert_eq!(state.take_frames(), "[]");
645    }
646
647    // Расширение сценариев фикстуры (Этап 7 плана отделения движка): второй
648    // ключ схемы другого BlockKind (IndexedNoNull8 — «динамика карты» по
649    // форме) наряду с Indexed8 («танк» по форме) — доказывает, что hot-буфер
650    // остаётся schema-driven для произвольного набора ключей, а не только
651    // для одного actor-блока выше.
652    #[test]
653    fn second_schema_key_of_different_block_kind_flows_into_hot_buffer() {
654        let config = serde_json::json!({
655            "timeStepMs": 1000.0 / 120.0,
656            "snapshot": {
657                "version": 3,
658                "port": 5,
659                "keys": {
660                    "actor": { "id": 1, "kind": "indexed8", "class": "hot", "fields": [
661                        { "name": "x", "ty": "f32", "interp": "lerp" },
662                        { "name": "y", "ty": "f32", "interp": "lerp" }
663                    ] },
664                    "zone": { "id": 2, "kind": "indexedNoNull8", "class": "hot", "fields": [
665                        { "name": "level", "ty": "f32", "interp": "discrete" }
666                    ] }
667                }
668            },
669            "interpolation": { "delay": 100, "maxFrameAge": 1000 }
670        });
671        let cfg: EngineClientConfig = serde_json::from_value(config).unwrap();
672        let mut state = ClientState::<TestClient>::new(cfg.clone(), &TestConfig {});
673        let mut packer = SnapshotPacker::new(cfg.snapshot.clone());
674
675        packer
676            .pack_body(&[
677                (
678                    "actor".to_string(),
679                    Block::Indexed8(vec![(2, Some(vec![FieldValue::F32(10.0), FieldValue::F32(0.0)]))]),
680                ),
681                (
682                    "zone".to_string(),
683                    Block::IndexedNoNull8(vec![(0, vec![FieldValue::F32(7.0)])]),
684                ),
685            ])
686            .unwrap();
687
688        let frame = packer.pack_frame(1000.0, 1, None, None).to_vec();
689
690        state.push_frame(&frame, 1000.0);
691        state.push_frame(&frame, 1100.0);
692        state.sample(1150.0);
693
694        let hot = state.hot().to_vec();
695
696        // [flags, camX, camY, tankCount, keyId, gameId, x, y, dynamicCount, keyId, index, level]
697        assert_eq!(hot[3], 1.0); // tankCount (Indexed8)
698        assert_eq!(hot[8], 1.0); // dynamicCount (IndexedNoNull8)
699        assert_eq!(hot[9], 2.0); // keyId зоны
700        assert_eq!(hot[11], 7.0); // level
701    }
702}