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::divergence::{DivergenceTracker, Observation, Source};
19use super::interpolator::{FrameData, InterpolatedGame, Interpolator};
20use super::unpack::{self, DecodedSnapshot, UnpackError};
21use crate::config::{BlockKind, EngineClientConfig, FieldValue, PLAYER_STATE_LEN, SnapshotConfig};
22
23/// Predicted-запись рендер-тика: камера (общий смысл) + непрозрачный
24/// хвост hot-буфера (раскладку полей актора знает только игра).
25pub struct RenderOverlay {
26    pub camera: [f32; 2],
27    pub tail: Vec<f32>,
28}
29
30/// Игровая половина клиентского ядра — зеркало `crate::sim::GameSim<G>` на
31/// клиентской стороне. `ClientState<G>` (ниже) зовёт эти методы в фиксированных
32/// точках рендер-тика; `Self` хранит всё нужное для предикта (модели, оружие,
33/// историю ввода) само — `ClientState<G>` игровых конфигов не хранит.
34pub trait GameClientDef: Sized {
35    type Config: serde::de::DeserializeOwned;
36
37    fn new(cfg: &Self::Config, engine_cfg: &EngineClientConfig) -> Self;
38
39    /// Reconciliation по player-блоку кадра (авторитетное состояние своего
40    /// актора). Раскладка `[f32; PLAYER_STATE_LEN]` — движковая (общая с
41    /// серверным `GameSim::prediction_state`), поэтому не параметризована
42    /// трейтом отдельно.
43    fn on_server_state(
44        &mut self,
45        state: [f32; PLAYER_STATE_LEN],
46        centering: bool,
47        server_time: f64,
48        offset: f64,
49        local_now: f64,
50    );
51
52    /// Шаг предикта до текущего рендер-времени.
53    fn update(&mut self, local_now: f64);
54
55    /// Отслеживание своего актора в пересечённом кадре (дискретные поля,
56    /// freeze при уничтожении, reset по forceReset камеры). `my_game_id` —
57    /// текущий id своего актора (из последнего player-блока).
58    fn track_frame(&mut self, my_game_id: Option<u32>, frame: &FrameData);
59
60    /// Фильтр собственных событий в JSON-форме кадра (подавление дублей
61    /// локально предсказанных эффектов).
62    fn filter_frame_game(&mut self, game: &mut Map<String, Value>, my_game_id: Option<u32>, local_now: f64);
63
64    /// Обновление игрового мира для дальнейшего локального спавна эффектов
65    /// (raycast и т.п.) — по каждому пересечённому кадру.
66    fn update_world(&mut self, snapshot: &DecodedSnapshot);
67
68    /// То же — по интерполированному состоянию (между кадрами).
69    fn update_world_interpolated(&mut self, game: &InterpolatedGame);
70
71    /// Полная predicted-запись рендер-тика; `None` — предикт не готов (нет
72    /// своего актора/модели/меты), тогда камера рендер-тика берётся из
73    /// интерполяции, а флаг HOT_HAS_PREDICTED не выставляется.
74    fn render_overlay(&self, my_game_id: Option<u32>) -> Option<RenderOverlay>;
75
76    /// Предсказанное состояние своего актора в раскладке player-блока —
77    /// уровень 1 детектора рассинхрона (`client::divergence`). Движок
78    /// снимает его непосредственно перед `on_server_state`, то есть до
79    /// затирания предикта авторитетным состоянием. Дефолт `None` — тогда
80    /// движок сравнивает камеру predicted-оверлея с x/y кадра (уровень 0),
81    /// и от плагина не требуется ничего.
82    fn predicted_state(&self) -> Option<[f32; PLAYER_STATE_LEN]> {
83        None
84    }
85
86    /// Окно локального времени истории ввода, переигранное последним
87    /// реконсилем: (начало, конец, число вводов). Реконсиляция идёт по
88    /// времени, а не по `seq`, поэтому именно окно локализует расхождение
89    /// в формуле движения. Дефолт `None`.
90    fn replayed_inputs(&self) -> Option<(f64, f64, usize)> {
91        None
92    }
93
94    fn apply_input(&mut self, action: &str, key_name: &str, local_now: f64);
95    fn set_model(&mut self, model_name: &str);
96    fn set_active(&mut self, active: bool);
97    fn set_map(&mut self, map_json: &str) -> Result<(), String>;
98    fn sync_panel(&mut self, items: &[String]);
99    fn reset(&mut self);
100
101    /// Циклический выбор активного предмета/режима актора (нейтральный
102    /// аналог смены оружия — конкретный смысл определяет игра).
103    fn cycle_item(&mut self, back: bool);
104
105    /// Локальное визуальное действие актора (выстрел и т.п.; гейты внутри —
106    /// предикт активен, свой актор жив). JSON спавна либо `None`.
107    fn try_action(&mut self, my_game_id: Option<u32>, local_now: f64) -> Option<String>;
108}
109
110// приводит любое поле строки к f32 для плоского hot-буфера
111fn field_as_f32(value: FieldValue) -> f32 {
112    match value {
113        FieldValue::F32(v) => v,
114        FieldValue::U8(v) => v as f32,
115        FieldValue::U16(v) => v as f32,
116        FieldValue::U32(v) => v as f32,
117    }
118}
119
120// блоки интерполированного кадра нужной формы (kind), с id ключа реестра
121// snapshot.keys (записывается в hot-буфер как keyId).
122fn blocks_of_kind<'a>(
123    snapshot_cfg: &'a SnapshotConfig,
124    game: &'a InterpolatedGame,
125    kind: BlockKind,
126) -> impl Iterator<Item = (u8, &'a Vec<super::interpolator::InterpolatedRow>)> {
127    game.blocks.iter().filter_map(move |(key, rows)| {
128        let schema = snapshot_cfg.keys.get(key)?;
129
130        (schema.kind == kind).then_some((schema.id, rows))
131    })
132}
133
134/// Generic оркестрация клиентского ядра поверх игровой `G: GameClientDef`:
135/// сетевой буфер (interpolator), очередь событийных кадров, hot-буфер
136/// рендер-тика. Байтовая раскладка hot-буфера (флаги/камера/N танков×поля/M
137/// динамики×поля/predicted-хвост) не меняется — см. `write_hot`.
138pub struct ClientState<G: GameClientDef> {
139    cfg: EngineClientConfig,
140    interpolator: Interpolator,
141    game: G,
142
143    // id своего актора из последнего player-блока
144    my_game_id: Option<u32>,
145
146    // очередь событийных кадров на take_frames (в форме applyShot)
147    frames_out: Vec<Value>,
148
149    // переиспользуемый плоский буфер рендер-тика
150    hot: Vec<f32>,
151
152    // детектор рассинхрона предикта; None в боевом конфиге — путь кадра
153    // остаётся ровно таким же, как до этапа 5
154    divergence: Option<DivergenceTracker>,
155}
156
157impl<G: GameClientDef> ClientState<G> {
158    pub fn new(cfg: EngineClientConfig, game_cfg: &G::Config) -> Self {
159        let interpolator = Interpolator::new(&cfg.interpolation, cfg.snapshot.clone());
160        let game = G::new(game_cfg, &cfg);
161        let divergence = cfg.divergence.clone().map(DivergenceTracker::new);
162
163        Self {
164            cfg,
165            interpolator,
166            game,
167            my_game_id: None,
168            frames_out: Vec::new(),
169            hot: Vec::new(),
170            divergence,
171        }
172    }
173
174    /// Бинарный кадр из транспорта: распаковка, вставка в буфер по seq,
175    /// reconciliation предикта по player-блоку. false — кадр отброшен
176    /// (чужой порт, версия или повреждённые данные).
177    pub fn push_frame(&mut self, data: &[u8], local_now: f64) -> bool {
178        let frame = match unpack::unpack_frame(data, &self.cfg.snapshot) {
179            Ok(frame) => frame,
180            Err(UnpackError::WrongVersion | UnpackError::Truncated) => return false,
181        };
182
183        if frame.port != self.cfg.snapshot.port {
184            return false;
185        }
186
187        self.interpolator.push(
188            FrameData {
189                snapshot: frame.snapshot,
190                camera: frame.camera,
191            },
192            frame.server_time,
193            local_now,
194            frame.seq,
195        );
196
197        if let Some(player) = frame.player {
198            self.my_game_id = Some(player.game_id as u32);
199
200            // после push оффсет всегда известен
201            let offset = self.interpolator.offset().unwrap_or(0.0);
202
203            self.observe_divergence(&player, frame.server_time, offset, local_now);
204
205            self.game.on_server_state(
206                player.state,
207                player.centering,
208                frame.server_time,
209                offset,
210                local_now,
211            );
212        }
213
214        true
215    }
216
217    pub fn my_game_id(&self) -> Option<u32> {
218        self.my_game_id
219    }
220
221    pub fn offset(&self) -> Option<f64> {
222        self.interpolator.offset()
223    }
224
225    /// Рендер-тик: выдача пересечённых кадров (фильтр дублей → JSON-очередь),
226    /// интерполяция, шаг предикта, запись hot-буфера. Возвращает длину
227    /// hot-буфера в f32-элементах.
228    pub fn sample(&mut self, local_now: f64) -> usize {
229        let result = self.interpolator.sample(local_now);
230
231        // событийные кадры: свой актор → фильтр дублей → очередь → мир
232        for frame in result.frames {
233            self.game.track_frame(self.my_game_id, &frame);
234
235            let mut game = unpack::snapshot_to_json(&frame.snapshot);
236
237            self.game
238                .filter_frame_game(&mut game, self.my_game_id, local_now);
239
240            self.frames_out.push(json!({
241                "game": game,
242                "camera": unpack::camera_to_json(frame.camera.as_ref()),
243            }));
244
245            self.game.update_world(&frame.snapshot);
246        }
247
248        if let Some(game) = &result.game {
249            self.game.update_world_interpolated(game);
250        }
251
252        self.game.update(local_now);
253
254        let overlay = self.game.render_overlay(self.my_game_id);
255
256        self.write_hot(result.game.as_ref(), result.camera, overlay.as_ref());
257        self.hot.len()
258    }
259
260    pub fn hot(&self) -> &[f32] {
261        &self.hot
262    }
263
264    /// Событийные кадры JSON-строкой [{game, camera}, ...]; очередь очищается.
265    pub fn take_frames(&mut self) -> String {
266        let frames = std::mem::take(&mut self.frames_out);
267
268        serde_json::to_string(&frames).unwrap_or_else(|_| "[]".to_string())
269    }
270
271    pub fn apply_input(&mut self, action: &str, key_name: &str, local_now: f64) {
272        self.game.apply_input(action, key_name, local_now);
273    }
274
275    pub fn try_action(&mut self, local_now: f64) -> Option<String> {
276        self.game.try_action(self.my_game_id, local_now)
277    }
278
279    pub fn cycle_item(&mut self, back: bool) {
280        self.game.cycle_item(back);
281    }
282
283    pub fn set_model(&mut self, model_name: &str) {
284        self.game.set_model(model_name);
285    }
286
287    /// Смена режима игрок/спектатор (KEYSET_DATA).
288    pub fn set_active(&mut self, active: bool) {
289        self.game.set_active(active);
290    }
291
292    /// Данные карты (MAP_DATA): мир raycast + сброс буфера и предикта.
293    pub fn set_map(&mut self, map_json: &str) -> Result<(), String> {
294        self.interpolator.reset();
295        self.frames_out.clear();
296        self.game.set_map(map_json)
297    }
298
299    pub fn sync_panel(&mut self, panel_json: &str) {
300        let Ok(Value::Array(items)) = serde_json::from_str(panel_json) else {
301            return;
302        };
303
304        let items: Vec<String> = items
305            .iter()
306            .map(|item| match item {
307                Value::String(s) => s.clone(),
308                other => other.to_string(),
309            })
310            .collect();
311
312        self.game.sync_panel(&items);
313    }
314
315    /// Полный сброс (порт CLEAR).
316    pub fn reset(&mut self) {
317        self.interpolator.reset();
318        self.game.reset();
319        self.frames_out.clear();
320        // мира больше нет: своя идентичность восстановится из первого же
321        // player-блока, а у наблюдателя его нет — значит и предсказанной
322        // сущности быть не должно
323        self.my_game_id = None;
324    }
325
326    /// Ресинк часов после долгой паузы вкладки (visibilitychange → visible):
327    /// сетевая половина начинает с чистого листа, чтобы следующий кадр
328    /// пересеял оффсет точно, а не догонял EMA десятки кадров. Игровую
329    /// половину (предикт, своя идентичность) не трогаем — сущности на
330    /// полотне живы.
331    pub fn resync(&mut self) {
332        self.interpolator.reset();
333        self.frames_out.clear();
334    }
335
336    /// Зеркало серверного `EngineSim::debug_json` на клиенте: состояние
337    /// сетевого буфера (глубина, окно seq, оффсет, последний кадр), свой
338    /// gameId, размеры hot-буфера и очереди событийных кадров.
339    pub fn debug_json(&self) -> String {
340        json!({
341            "myGameId": self.my_game_id,
342            "offset": self.interpolator.offset(),
343            "interpolator": self.interpolator.debug_json(),
344            "hotLen": self.hot.len(),
345            "framesOut": self.frames_out.len(),
346        })
347        .to_string()
348    }
349
350    /// Записи расхождения предикта с авторитетным состоянием (JSON,
351    /// см. `client::divergence`); очередь очищается. `"null"` — детектор
352    /// выключен (боевой конфиг без секции `divergence`).
353    pub fn take_divergence(&mut self) -> String {
354        match &mut self.divergence {
355            Some(tracker) => tracker.take_json(),
356            None => "null".to_string(),
357        }
358    }
359
360    // Снимок предикта ДО реконсиляции: после on_server_state сравнивать уже
361    // не с чем — состояние затёрто авторитетным.
362    fn observe_divergence(
363        &mut self,
364        player: &unpack::DecodedPlayer,
365        server_time: f64,
366        offset: f64,
367        local_now: f64,
368    ) {
369        let Some(tracker) = &mut self.divergence else {
370            return;
371        };
372
373        // уровень 1 (predicted_state игры) либо уровень 0 (камера оверлея)
374        let (source, predicted) = match self.game.predicted_state() {
375            Some(state) => (Source::State, state.to_vec()),
376            None => match self.game.render_overlay(self.my_game_id) {
377                Some(overlay) => (Source::Camera, overlay.camera.to_vec()),
378                None => return,
379            },
380        };
381
382        tracker.observe(Observation {
383            source,
384            predicted: &predicted,
385            authoritative: &player.state,
386            server_time,
387            local_now,
388            offset,
389            input_seq: player.input_seq,
390            replayed: self.game.replayed_inputs(),
391        });
392    }
393
394    /// Чистая распаковка кадра v3 в JSON-форму unpackFrame (тесты/харнесс).
395    pub fn decode_frame(&self, data: &[u8]) -> String {
396        match unpack::unpack_frame(data, &self.cfg.snapshot) {
397            Ok(frame) => unpack::frame_to_json(&frame).to_string(),
398            Err(_) => "null".to_string(),
399        }
400    }
401
402    // плоский Float32-буфер рендер-тика:
403    // [0] flags, [1..2] камера x/y, [3] N строк hot-блоков (Indexed8), N×(2+поля),
404    // [..] M строк динамики (IndexedNoNull8), M×(2+поля), затем — непрозрачный
405    // predicted-хвост игры (см. GameClientDef::render_overlay).
406    fn write_hot(
407        &mut self,
408        game: Option<&InterpolatedGame>,
409        camera: Option<[f32; 2]>,
410        overlay: Option<&RenderOverlay>,
411    ) {
412        self.hot.clear();
413
414        let mut flags = 0u32;
415
416        if game.is_some() {
417            flags |= super::HOT_HAS_GAME;
418        }
419
420        if !self.frames_out.is_empty() {
421            flags |= super::HOT_HAS_FRAMES;
422        }
423
424        if overlay.is_some() {
425            flags |= super::HOT_HAS_PREDICTED;
426        }
427
428        // камера: предсказанная позиция либо интерполированная
429        let camera = overlay.map(|o| o.camera).or(camera);
430
431        if camera.is_some() {
432            flags |= super::HOT_HAS_CAMERA;
433        }
434
435        self.hot.push(flags as f32);
436
437        let camera = camera.unwrap_or([0.0, 0.0]);
438
439        self.hot.push(camera[0]);
440        self.hot.push(camera[1]);
441
442        let empty = InterpolatedGame::default();
443        let game = game.unwrap_or(&empty);
444
445        // строки блоков — по форме (BlockKind), не по игровой сущности:
446        // Indexed8 пишется как «танк» (keyId, gameId, поля), IndexedNoNull8
447        // — как «динамика карты» (keyId, index, поля); порядок и число
448        // полей идут напрямую из schema.fields.
449        let tank_count: usize = blocks_of_kind(&self.cfg.snapshot, game, BlockKind::Indexed8)
450            .map(|(_, rows)| rows.len())
451            .sum();
452
453        self.hot.push(tank_count as f32);
454
455        for (key_id, rows) in blocks_of_kind(&self.cfg.snapshot, game, BlockKind::Indexed8) {
456            for row in rows {
457                self.hot.push(key_id as f32);
458                self.hot.push(row.id as f32);
459
460                for field in &row.fields {
461                    self.hot.push(field_as_f32(*field));
462                }
463            }
464        }
465
466        let dynamic_count: usize =
467            blocks_of_kind(&self.cfg.snapshot, game, BlockKind::IndexedNoNull8)
468                .map(|(_, rows)| rows.len())
469                .sum();
470
471        self.hot.push(dynamic_count as f32);
472
473        for (key_id, rows) in blocks_of_kind(&self.cfg.snapshot, game, BlockKind::IndexedNoNull8) {
474            for row in rows {
475                self.hot.push(key_id as f32);
476                self.hot.push(row.id as f32);
477
478                for field in &row.fields {
479                    self.hot.push(field_as_f32(*field));
480                }
481            }
482        }
483
484        if let Some(overlay) = overlay {
485            self.hot.extend_from_slice(&overlay.tail);
486        }
487    }
488}
489
490/// Фикстурный второй клиент — валидатор формы `GameClientDef` (Этап 4b,
491/// PLAN_4_details.md): доказывает, что трейт не завязан по форме на танки,
492/// до миграции `games/tanks` на него (см. `TanksClient`). Состояние —
493/// тривиальная линейная интеграция позиции по vx/vy, без формул движения
494/// игры (мотор здесь не нужен — только форма трейта).
495#[cfg(test)]
496mod fixture {
497    use super::*;
498    use serde::Deserialize;
499
500    #[derive(Deserialize)]
501    pub struct TestConfig {}
502
503    pub struct TestClient {
504        x: f32,
505        y: f32,
506        vx: f32,
507        vy: f32,
508        active: bool,
509        alive: bool,
510        last_update: Option<f64>,
511        // уровень детектора рассинхрона, которым фикстура притворяется:
512        // set_model("predicted") → уровень 1 (predicted_state), иначе — 0
513        report_state: bool,
514    }
515
516    impl GameClientDef for TestClient {
517        type Config = TestConfig;
518
519        fn new(_cfg: &Self::Config, _engine_cfg: &EngineClientConfig) -> Self {
520            Self {
521                x: 0.0,
522                y: 0.0,
523                vx: 0.0,
524                vy: 0.0,
525                active: false,
526                alive: true,
527                last_update: None,
528                report_state: false,
529            }
530        }
531
532        fn on_server_state(
533            &mut self,
534            state: [f32; PLAYER_STATE_LEN],
535            _centering: bool,
536            _server_time: f64,
537            _offset: f64,
538            _local_now: f64,
539        ) {
540            self.x = state[0];
541            self.y = state[1];
542            self.vx = state[3];
543            self.vy = state[4];
544        }
545
546        fn update(&mut self, local_now: f64) {
547            let dt = self
548                .last_update
549                .map(|last| (local_now - last) / 1000.0)
550                .unwrap_or(0.0) as f32;
551
552            self.x += self.vx * dt;
553            self.y += self.vy * dt;
554            self.last_update = Some(local_now);
555        }
556
557        fn track_frame(&mut self, _my_game_id: Option<u32>, _frame: &FrameData) {}
558
559        fn filter_frame_game(
560            &mut self,
561            _game: &mut Map<String, Value>,
562            _my_game_id: Option<u32>,
563            _local_now: f64,
564        ) {
565        }
566
567        fn update_world(&mut self, _snapshot: &DecodedSnapshot) {}
568
569        fn update_world_interpolated(&mut self, _game: &InterpolatedGame) {}
570
571        fn render_overlay(&self, my_game_id: Option<u32>) -> Option<RenderOverlay> {
572            let game_id = my_game_id?;
573
574            (self.active && self.alive).then(|| RenderOverlay {
575                camera: [self.x, self.y],
576                tail: vec![0.0, game_id as f32, self.x, self.y],
577            })
578        }
579
580        fn predicted_state(&self) -> Option<[f32; PLAYER_STATE_LEN]> {
581            self.report_state
582                .then_some([self.x, self.y, 0.0, self.vx, self.vy, 0.0, 0.0, 0.0])
583        }
584
585        fn replayed_inputs(&self) -> Option<(f64, f64, usize)> {
586            self.last_update.map(|last| (last - 50.0, last, 2))
587        }
588
589        fn apply_input(&mut self, _action: &str, _key_name: &str, _local_now: f64) {}
590
591        fn set_model(&mut self, model_name: &str) {
592            self.report_state = model_name == "predicted";
593        }
594
595        fn set_active(&mut self, active: bool) {
596            self.active = active;
597        }
598
599        fn set_map(&mut self, _map_json: &str) -> Result<(), String> {
600            Ok(())
601        }
602
603        fn sync_panel(&mut self, _items: &[String]) {}
604
605        fn reset(&mut self) {
606            self.x = 0.0;
607            self.y = 0.0;
608            self.last_update = None;
609        }
610
611        fn cycle_item(&mut self, _back: bool) {}
612
613        fn try_action(&mut self, _my_game_id: Option<u32>, _local_now: f64) -> Option<String> {
614            None
615        }
616    }
617}
618
619#[cfg(test)]
620mod tests {
621    use super::fixture::{TestClient, TestConfig};
622    use super::*;
623    use crate::client::{HOT_HAS_CAMERA, HOT_HAS_FRAMES, HOT_HAS_GAME, HOT_HAS_PREDICTED};
624    use crate::snapshot::{Block, CameraData, PlayerBlock, SnapshotPacker};
625
626    fn config_json() -> serde_json::Value {
627        serde_json::json!({
628            "timeStepMs": 1000.0 / 120.0,
629            "snapshot": {
630                "version": 3,
631                "port": 5,
632                "keys": {
633                    "actor": { "id": 1, "kind": "indexed8", "class": "hot", "fields": [
634                        { "name": "x", "ty": "f32", "interp": "lerp" },
635                        { "name": "y", "ty": "f32", "interp": "lerp" }
636                    ] }
637                }
638            },
639            "interpolation": { "delay": 100, "maxFrameAge": 1000 }
640        })
641    }
642
643    fn engine_client_config() -> EngineClientConfig {
644        serde_json::from_value(config_json()).unwrap()
645    }
646
647    // конфиг с включённым детектором рассинхрона (в боевом конфиге секции
648    // divergence нет — путь кадра тогда не меняется вовсе)
649    fn config_with_divergence(capacity: usize) -> EngineClientConfig {
650        let mut json = config_json();
651
652        json["divergence"] = serde_json::json!({
653            "defaultThreshold": 1.0,
654            "capacity": capacity,
655        });
656
657        serde_json::from_value(json).unwrap()
658    }
659
660    fn make_state() -> ClientState<TestClient> {
661        ClientState::new(engine_client_config(), &TestConfig {})
662    }
663
664    fn frame_bytes(server_time: f64, seq: u32, x: f32, with_player: bool) -> Vec<u8> {
665        let cfg = engine_client_config();
666        let mut packer = SnapshotPacker::new(cfg.snapshot.clone());
667
668        packer
669            .pack_body(&[(
670                "actor".to_string(),
671                Block::Indexed8(vec![(2, Some(vec![FieldValue::F32(x), FieldValue::F32(0.0)]))]),
672            )])
673            .unwrap();
674
675        let camera = CameraData {
676            x,
677            y: 0.0,
678            force_reset: false,
679            shake: None,
680        };
681        let player = PlayerBlock {
682            game_id: 2,
683            input_seq: 0,
684            state: [x, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
685            centering: false,
686        };
687
688        packer
689            .pack_frame(
690                server_time,
691                seq,
692                Some(&camera),
693                with_player.then_some(&player),
694            )
695            .to_vec()
696    }
697
698    #[test]
699    fn push_frame_and_sample_writes_hot_layout() {
700        let mut state = make_state();
701
702        state.push_frame(&frame_bytes(1000.0, 1, 10.0, false), 1000.0);
703        state.push_frame(&frame_bytes(1100.0, 2, 20.0, false), 1100.0);
704
705        // renderTime = 1150 − 100 = 1050 → alpha 0.5
706        let len = state.sample(1150.0);
707        let hot = state.hot().to_vec();
708
709        assert_eq!(len, hot.len());
710
711        let flags = hot[0] as u32;
712
713        assert!(flags & HOT_HAS_GAME != 0);
714        assert!(flags & HOT_HAS_CAMERA != 0);
715        assert!(flags & HOT_HAS_FRAMES != 0);
716        assert!(flags & HOT_HAS_PREDICTED == 0);
717
718        // один актор: keyId 1, gameId 2, x = 15 (лерп)
719        assert_eq!(hot[3], 1.0);
720        assert_eq!(hot[4], 1.0);
721        assert_eq!(hot[5], 2.0);
722        assert_eq!(hot[6], 15.0);
723
724        let frames: Vec<serde_json::Value> =
725            serde_json::from_str(&state.take_frames()).unwrap();
726
727        assert_eq!(frames.len(), 1);
728        assert_eq!(state.take_frames(), "[]");
729    }
730
731    #[test]
732    fn render_overlay_appends_opaque_tail_and_sets_flag() {
733        let mut state = make_state();
734
735        state.set_active(true);
736        state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
737
738        assert_eq!(state.my_game_id(), Some(2));
739
740        state.sample(1150.0);
741
742        let hot = state.hot().to_vec();
743        let flags = hot[0] as u32;
744
745        assert!(flags & HOT_HAS_PREDICTED != 0);
746
747        // хвост — последние 4 f32 (форма TestClient::render_overlay)
748        let tail = &hot[hot.len() - 4..];
749
750        assert_eq!(tail[1], 2.0); // gameId
751        assert_eq!(hot[1], tail[2]); // камера следует хвосту (x)
752    }
753
754    #[test]
755    fn reset_clears_predictor_and_frame_queue() {
756        let mut state = make_state();
757
758        state.set_active(true);
759        state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
760        state.sample(1150.0);
761
762        state.reset();
763
764        assert_eq!(state.take_frames(), "[]");
765    }
766
767    #[test]
768    fn resync_clears_network_half_only() {
769        let mut state = make_state();
770
771        state.set_active(true);
772        state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
773        state.push_frame(&frame_bytes(1100.0, 2, 20.0, false), 1100.0);
774        state.sample(1150.0);
775
776        state.resync();
777
778        let dump: serde_json::Value = serde_json::from_str(&state.debug_json()).unwrap();
779
780        assert_eq!(dump["interpolator"]["buffered"], 0);
781        assert!(dump["interpolator"]["lastFrame"].is_null());
782        assert!(dump["offset"].is_null());
783        assert_eq!(dump["framesOut"], 0);
784        assert_eq!(state.take_frames(), "[]");
785
786        // игровая половина цела: своя идентичность не потеряна
787        assert_eq!(state.my_game_id(), Some(2));
788    }
789
790    #[test]
791    fn debug_json_reports_buffer_seq_window_and_offset() {
792        let mut state = make_state();
793
794        let empty: serde_json::Value = serde_json::from_str(&state.debug_json()).unwrap();
795
796        assert!(empty["myGameId"].is_null());
797        assert_eq!(empty["interpolator"]["buffered"], 0);
798        assert!(empty["interpolator"]["lastFrame"].is_null());
799
800        state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
801        state.push_frame(&frame_bytes(1100.0, 2, 20.0, false), 1100.0);
802        state.sample(1150.0);
803
804        let dump: serde_json::Value = serde_json::from_str(&state.debug_json()).unwrap();
805
806        assert_eq!(dump["myGameId"], 2);
807        assert_eq!(dump["interpolator"]["buffered"], 2);
808        assert_eq!(dump["interpolator"]["seqWindow"], serde_json::json!([1, 2]));
809        assert_eq!(dump["interpolator"]["lastFrame"]["seq"], 2);
810        assert_eq!(dump["interpolator"]["lastFrame"]["serverTime"], 1100.0);
811        assert_eq!(dump["interpolator"]["delay"], 100.0);
812        assert_eq!(dump["offset"], dump["interpolator"]["offset"]);
813        assert_eq!(dump["hotLen"], state.hot().len());
814        assert_eq!(dump["framesOut"], 1);
815    }
816
817    // ***** детектор рассинхрона предикта (этап 5 plan/done/ai-debug) ***** //
818
819    #[test]
820    fn divergence_is_off_without_config() {
821        let mut state = make_state();
822
823        state.set_active(true);
824        state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
825
826        assert_eq!(state.take_divergence(), "null");
827    }
828
829    // уровень 0: плагин не реализовал predicted_state — сравнивается камера
830    // predicted-оверлея с x/y авторитетного состояния
831    #[test]
832    fn divergence_falls_back_to_overlay_camera() {
833        let mut state = ClientState::<TestClient>::new(config_with_divergence(8), &TestConfig {});
834
835        state.set_active(true);
836        state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
837
838        let dump: serde_json::Value = serde_json::from_str(&state.take_divergence()).unwrap();
839        let record = &dump["records"][0];
840
841        assert_eq!(dump["samples"], 1);
842        assert_eq!(dump["violations"], 1);
843        assert_eq!(record["source"], "camera");
844        assert_eq!(record["predicted"], serde_json::json!([0.0, 0.0]));
845        assert_eq!(record["authoritative"], serde_json::json!([10.0, 0.0]));
846        assert_eq!(record["delta"][0], -10.0);
847        assert_eq!(record["exceeded"], serde_json::json!([0]));
848        assert_eq!(record["thresholds"][0], 1.0);
849        assert!(record["replayed"].is_null());
850    }
851
852    // уровень 1: predicted_state сравнивается покомпонентно, а отчёт несёт
853    // serverTime/offset/окно переигранного ввода — реконсиляция идёт по
854    // времени, а не по seq
855    #[test]
856    fn divergence_reports_predicted_state_and_replay_window() {
857        let mut state = ClientState::<TestClient>::new(config_with_divergence(8), &TestConfig {});
858
859        state.set_active(true);
860        state.set_model("predicted");
861
862        // первый кадр совпадает с предиктом — записи быть не должно
863        state.push_frame(&frame_bytes(1000.0, 1, 0.0, true), 1000.0);
864        state.sample(1100.0);
865        state.push_frame(&frame_bytes(1100.0, 2, 50.0, true), 1100.0);
866
867        let dump: serde_json::Value = serde_json::from_str(&state.take_divergence()).unwrap();
868
869        assert_eq!(dump["samples"], 2);
870        assert_eq!(dump["violations"], 1);
871        assert_eq!(dump["records"].as_array().unwrap().len(), 1);
872        assert_eq!(dump["maxDelta"][0], 50.0);
873
874        let record = &dump["records"][0];
875
876        assert_eq!(record["source"], "state");
877        assert_eq!(record["serverTime"], 1100.0);
878        assert_eq!(record["localNow"], 1100.0);
879        assert_eq!(record["inputSeq"], 0);
880        assert_eq!(record["delta"][0], -50.0);
881        assert_eq!(record["exceeded"], serde_json::json!([0]));
882        assert_eq!(record["replayed"]["from"], 1050.0);
883        assert_eq!(record["replayed"]["to"], 1100.0);
884        assert_eq!(record["replayed"]["count"], 2);
885
886        // очередь вычерпана, агрегаты — накопительные
887        let drained: serde_json::Value = serde_json::from_str(&state.take_divergence()).unwrap();
888
889        assert_eq!(drained["records"].as_array().unwrap().len(), 0);
890        assert_eq!(drained["samples"], 2);
891        assert_eq!(drained["maxDelta"][0], 50.0);
892    }
893
894    #[test]
895    fn divergence_ring_buffer_evicts_oldest_records() {
896        let mut state = ClientState::<TestClient>::new(config_with_divergence(1), &TestConfig {});
897
898        state.set_active(true);
899        state.set_model("predicted");
900        state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
901        state.push_frame(&frame_bytes(1100.0, 2, 90.0, true), 1100.0);
902
903        let dump: serde_json::Value = serde_json::from_str(&state.take_divergence()).unwrap();
904
905        assert_eq!(dump["violations"], 2);
906        assert_eq!(dump["dropped"], 1);
907        assert_eq!(dump["records"].as_array().unwrap().len(), 1);
908        assert_eq!(dump["records"][0]["serverTime"], 1100.0);
909    }
910
911    // capacity: 0 — буфер всё равно держит одну запись, значит и вытеснений
912    // на две записи ровно одно: иначе отчёт врал бы про «вытеснено N»
913    #[test]
914    fn divergence_zero_capacity_counts_evictions_honestly() {
915        let mut state = ClientState::<TestClient>::new(config_with_divergence(0), &TestConfig {});
916
917        state.set_active(true);
918        state.set_model("predicted");
919        state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
920        state.push_frame(&frame_bytes(1100.0, 2, 90.0, true), 1100.0);
921
922        let dump: serde_json::Value = serde_json::from_str(&state.take_divergence()).unwrap();
923
924        assert_eq!(dump["violations"], 2);
925        assert_eq!(dump["dropped"], 1);
926        assert_eq!(dump["records"].as_array().unwrap().len(), 1);
927    }
928
929    // Расширение сценариев фикстуры (Этап 7 плана отделения движка): второй
930    // ключ схемы другого BlockKind (IndexedNoNull8 — «динамика карты» по
931    // форме) наряду с Indexed8 («танк» по форме) — доказывает, что hot-буфер
932    // остаётся schema-driven для произвольного набора ключей, а не только
933    // для одного actor-блока выше.
934    #[test]
935    fn second_schema_key_of_different_block_kind_flows_into_hot_buffer() {
936        let config = serde_json::json!({
937            "timeStepMs": 1000.0 / 120.0,
938            "snapshot": {
939                "version": 3,
940                "port": 5,
941                "keys": {
942                    "actor": { "id": 1, "kind": "indexed8", "class": "hot", "fields": [
943                        { "name": "x", "ty": "f32", "interp": "lerp" },
944                        { "name": "y", "ty": "f32", "interp": "lerp" }
945                    ] },
946                    "zone": { "id": 2, "kind": "indexedNoNull8", "class": "hot", "fields": [
947                        { "name": "level", "ty": "f32", "interp": "discrete" }
948                    ] }
949                }
950            },
951            "interpolation": { "delay": 100, "maxFrameAge": 1000 }
952        });
953        let cfg: EngineClientConfig = serde_json::from_value(config).unwrap();
954        let mut state = ClientState::<TestClient>::new(cfg.clone(), &TestConfig {});
955        let mut packer = SnapshotPacker::new(cfg.snapshot.clone());
956
957        packer
958            .pack_body(&[
959                (
960                    "actor".to_string(),
961                    Block::Indexed8(vec![(2, Some(vec![FieldValue::F32(10.0), FieldValue::F32(0.0)]))]),
962                ),
963                (
964                    "zone".to_string(),
965                    Block::IndexedNoNull8(vec![(0, vec![FieldValue::F32(7.0)])]),
966                ),
967            ])
968            .unwrap();
969
970        let frame = packer.pack_frame(1000.0, 1, None, None).to_vec();
971
972        state.push_frame(&frame, 1000.0);
973        state.push_frame(&frame, 1100.0);
974        state.sample(1150.0);
975
976        let hot = state.hot().to_vec();
977
978        // [flags, camX, camY, tankCount, keyId, gameId, x, y, dynamicCount, keyId, index, level]
979        assert_eq!(hot[3], 1.0); // tankCount (Indexed8)
980        assert_eq!(hot[8], 1.0); // dynamicCount (IndexedNoNull8)
981        assert_eq!(hot[9], 2.0); // keyId зоны
982        assert_eq!(hot[11], 7.0); // level
983    }
984
985    #[test]
986    fn reset_clears_my_game_id() {
987        let mut state = make_state();
988
989        state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
990        state.push_frame(&frame_bytes(1100.0, 2, 20.0, true), 1100.0);
991        state.sample(1150.0);
992        assert_eq!(state.my_game_id(), Some(2));
993
994        // CLEAR означает «мира больше нет»: без сброса идентичности предикт
995        // продолжил бы рисовать сущность, которой на хосте уже нет
996        state.reset();
997        assert_eq!(state.my_game_id(), None);
998    }
999}